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:
YeonGyu-Kim
2026-02-08 15:01:42 +09:00
parent 29155ec7bc
commit 119e18c810
158 changed files with 7806 additions and 7050 deletions
+25
View 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 }),
}
}
@@ -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
View 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
View 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
View File
@@ -0,0 +1 @@
export const HOOK_NAME = "atlas"
+3 -804
View File
@@ -1,804 +1,3 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { execSync } from "node:child_process"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import {
readBoulderState,
appendSessionId,
getPlanProgress,
} from "../../features/boulder-state"
import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils"
import type { BackgroundManager } from "../../features/background-agent"
export const HOOK_NAME = "atlas"
/**
* Cross-platform check if a path is inside .sisyphus/ directory.
* Handles both forward slashes (Unix) and backslashes (Windows).
*/
function isSisyphusPath(filePath: string): boolean {
return /\.sisyphus[/\\]/.test(filePath)
}
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
function getLastAgentFromSession(sessionID: string): string | null {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return null
const nearest = findNearestMessageWithFields(messageDir)
return nearest?.agent?.toLowerCase() ?? null
}
const DIRECT_WORK_REMINDER = `
---
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
You just performed direct file modifications outside \`.sisyphus/\`.
**You are an ORCHESTRATOR, not an IMPLEMENTER.**
As an orchestrator, you should:
- **DELEGATE** implementation work to subagents via \`task\`
- **VERIFY** the work done by subagents
- **COORDINATE** multiple tasks and ensure completion
You should NOT:
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
- Make direct file edits outside \`.sisyphus/\`
- Implement features yourself
**If you need to make changes:**
1. Use \`task\` to delegate to an appropriate subagent
2. Provide clear instructions in the prompt
3. Verify the subagent's work after completion
---
`
const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)}
You have an active work plan with incomplete tasks. Continue working.
RULES:
- Proceed without asking for permission
- Change \`- [ ]\` to \`- [x]\` in the plan file when done
- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings
- Do not stop until all tasks are complete
- If blocked, document the blocker and move to the next task`
const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CRITICAL: Subagents FREQUENTLY LIE about completion.
Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done".
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)**
Run these commands YOURSELF - do NOT trust agent's claims:
1. \`lsp_diagnostics\` on changed files → Must be CLEAN
2. \`bash\` to run tests → Must PASS
3. \`bash\` to run build/typecheck → Must succeed
4. \`Read\` the actual code → Must match requirements
**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED**
| Deliverable Type | QA Method | Tool |
|------------------|-----------|------|
| **Frontend/UI** | Browser interaction | \`/playwright\` skill |
| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) |
| **API/Backend** | Send real requests | \`bash\` with curl |
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages.
**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY**
\`\`\`
todowrite([
{ id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" }
])
\`\`\`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**BLOCKING: DO NOT proceed to Step 4 until Steps 1-3 are VERIFIED.**`
const ORCHESTRATOR_DELEGATION_REQUIRED = `
---
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
**Path attempted:** $FILE_PATH
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**THIS IS FORBIDDEN** (except for VERIFICATION purposes)
As an ORCHESTRATOR, you MUST:
1. **DELEGATE** all implementation work via \`task\`
2. **VERIFY** the work done by subagents (reading files is OK)
3. **COORDINATE** - you orchestrate, you don't implement
**ALLOWED direct file operations:**
- Files inside \`.sisyphus/\` (plans, notepads, drafts)
- Reading files for verification
- Running diagnostics/tests
**FORBIDDEN direct file operations:**
- Writing/editing source code
- Creating new files outside \`.sisyphus/\`
- Any implementation work
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**IF THIS IS FOR VERIFICATION:**
Proceed if you are verifying subagent work by making a small fix.
But for any substantial changes, USE \`task\`.
**CORRECT APPROACH:**
\`\`\`
task(
category="...",
prompt="[specific single task with clear acceptance criteria]"
)
\`\`\`
DELEGATE. DON'T IMPLEMENT.
---
`
const SINGLE_TASK_DIRECTIVE = `
${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)}
**STOP. READ THIS BEFORE PROCEEDING.**
If you were NOT given **exactly ONE atomic task**, you MUST:
1. **IMMEDIATELY REFUSE** this request
2. **DEMAND** the orchestrator provide a single, specific task
**Your response if multiple tasks detected:**
> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality.
>
> PROVIDE EXACTLY ONE TASK. One file. One change. One verification.
>
> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context."
**WARNING TO ORCHESTRATOR:**
- Your hasty batching RUINS deliverables
- Each task needs FULL attention and PROPER verification
- Batch delegation = sloppy work = rework = wasted tokens
**REFUSE multi-task requests. DEMAND single-task clarity.**
`
function buildVerificationReminder(sessionId: string): string {
return `${VERIFICATION_REMINDER}
---
**If ANY verification fails, use this immediately:**
\`\`\`
task(session_id="${sessionId}", prompt="fix: [describe the specific failure]")
\`\`\``
}
function buildOrchestratorReminder(planName: string, progress: { total: number; completed: number }, sessionId: string): string {
const remaining = progress.total - progress.completed
return `
---
**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining
---
${buildVerificationReminder(sessionId)}
**STEP 4: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
Update the plan file \`.sisyphus/tasks/${planName}.yaml\`:
- Change \`- [ ]\` to \`- [x]\` for the completed task
- Use \`Edit\` tool to modify the checkbox
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
**STEP 5: COMMIT ATOMIC UNIT**
- Stage ONLY the verified changes
- Commit with clear message describing what was done
**STEP 6: PROCEED TO NEXT TASK**
- Read the plan file to identify the next \`- [ ]\` task
- Start immediately - DO NOT STOP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**${remaining} tasks remain. Keep bouldering.**`
}
function buildStandaloneVerificationReminder(sessionId: string): string {
return `
---
${buildVerificationReminder(sessionId)}
**STEP 4: UPDATE TODO STATUS (IMMEDIATELY)**
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
1. Run \`todoread\` to see your todo list
2. Mark the completed task as \`completed\` using \`todowrite\`
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
**STEP 5: EXECUTE QA TASKS (IF ANY)**
If QA tasks exist in your todo list:
- Execute them BEFORE proceeding
- Mark each QA task complete after successful verification
**STEP 6: PROCEED TO NEXT PENDING TASK**
- Identify the next \`pending\` task from your todo list
- Start immediately - DO NOT STOP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**`
}
function extractSessionIdFromOutput(output: string): string {
const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/)
return match?.[1] ?? "<session_id>"
}
interface GitFileStat {
path: string
added: number
removed: number
status: "modified" | "added" | "deleted"
}
function getGitDiffStats(directory: string): GitFileStat[] {
try {
const output = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!output) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = new Map<string, "modified" | "added" | "deleted">()
for (const line of statusOutput.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (status === "A" || status === "??") {
statusMap.set(filePath, "added")
} else if (status === "D") {
statusMap.set(filePath, "deleted")
} else {
statusMap.set(filePath, "modified")
}
}
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
} catch {
return []
}
}
function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}
interface ToolExecuteAfterInput {
tool: string
sessionID?: string
callID?: string
}
interface ToolExecuteAfterOutput {
title: string
output: string
metadata: Record<string, unknown>
}
interface SessionState {
lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number
promptFailureCount: number
}
const CONTINUATION_COOLDOWN_MS = 5000
export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
isContinuationStopped?: (sessionID: string) => boolean
}
function isAbortError(error: unknown): boolean {
if (!error) return false
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
const name = errObj.name as string | undefined
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
if (name === "MessageAbortedError" || name === "AbortError") return true
if (name === "DOMException" && message.includes("abort")) return true
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
}
if (typeof error === "string") {
const lower = error.toLowerCase()
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
}
return false
}
export function createAtlasHook(
ctx: PluginInput,
options?: AtlasHookOptions
) {
const backgroundManager = options?.backgroundManager
const sessions = new Map<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
function getState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = { promptFailureCount: 0 }
sessions.set(sessionID, state)
}
return state
}
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number, agent?: string): Promise<void> {
const state = getState(sessionID)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
const prompt = BOULDER_CONTINUATION_PROMPT
.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]`
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
const msgModel = info?.model
if (msgModel?.providerID && msgModel?.modelID) {
model = { providerID: msgModel.providerID, modelID: msgModel.modelID }
break
}
if (info?.providerID && info?.modelID) {
model = { providerID: info.providerID, modelID: info.modelID }
break
}
}
} catch {
const messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: agent ?? "atlas",
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
state.promptFailureCount = 0
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} catch (err) {
state.promptFailureCount += 1
log(`[${HOOK_NAME}] Boulder continuation failed`, {
sessionID,
error: String(err),
promptFailureCount: state.promptFailureCount,
})
}
}
return {
handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const state = getState(sessionID)
const isAbort = isAbortError(props?.error)
state.lastEventWasAbortError = isAbort
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle`, { sessionID })
// Read boulder state FIRST to check if this session is part of an active boulder
const boulderState = readBoulderState(ctx.directory)
const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false
const mainSessionID = getMainSessionID()
const isMainSession = sessionID === mainSessionID
const isBackgroundTaskSession = subagentSessions.has(sessionID)
// Allow continuation if: main session OR background task OR boulder session
if (mainSessionID && !isMainSession && !isBackgroundTaskSession && !isBoulderSession) {
log(`[${HOOK_NAME}] Skipped: not main, background task, or boulder session`, { sessionID })
return
}
const state = getState(sessionID)
if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
if (state.promptFailureCount >= 2) {
log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, {
sessionID,
promptFailureCount: state.promptFailureCount,
})
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (!boulderState) {
log(`[${HOOK_NAME}] No active boulder`, { sessionID })
return
}
if (options?.isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase()
const lastAgent = getLastAgentFromSession(sessionID)
if (!lastAgent || lastAgent !== requiredAgent) {
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
sessionID,
lastAgent: lastAgent ?? "unknown",
requiredAgent,
})
return
}
const progress = getPlanProgress(boulderState.active_plan)
if (progress.isComplete) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
const now = Date.now()
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { sessionID, cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt) })
return
}
state.lastContinuationInjectedAt = now
const remaining = progress.total - progress.completed
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total, boulderState.agent)
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (!sessionID) return
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
return
}
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
return
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as
| string
| undefined
if (sessionID) {
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID })
}
return
}
},
"tool.execute.before": async (
input: { tool: string; sessionID?: string; callID?: string },
output: { args: Record<string, unknown>; message?: string }
): Promise<void> => {
if (!isCallerOrchestrator(input.sessionID)) {
return
}
// Check Write/Edit tools for orchestrator - inject strong warning
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
if (filePath && !isSisyphusPath(filePath)) {
// Store filePath for use in tool.execute.after
if (input.callID) {
pendingFilePaths.set(input.callID, filePath)
}
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
output.message = (output.message || "") + warning
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
sessionID: input.sessionID,
tool: input.tool,
filePath,
})
}
return
}
// Check task - inject single-task directive
if (input.tool === "task") {
const prompt = output.args.prompt as string | undefined
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
output.args.prompt = `<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>\n` + prompt
log(`[${HOOK_NAME}] Injected single-task directive to task`, {
sessionID: input.sessionID,
})
}
}
},
"tool.execute.after": async (
input: ToolExecuteAfterInput,
output: ToolExecuteAfterOutput
): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!output) {
return
}
if (!isCallerOrchestrator(input.sessionID)) {
return
}
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
let filePath = input.callID ? pendingFilePaths.get(input.callID) : undefined
if (input.callID) {
pendingFilePaths.delete(input.callID)
}
if (!filePath) {
filePath = output.metadata?.filePath as string | undefined
}
if (filePath && !isSisyphusPath(filePath)) {
output.output = (output.output || "") + DIRECT_WORK_REMINDER
log(`[${HOOK_NAME}] Direct work reminder appended`, {
sessionID: input.sessionID,
tool: input.tool,
filePath,
})
}
return
}
if (input.tool !== "task") {
return
}
const outputStr = output.output && typeof output.output === "string" ? output.output : ""
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued")
if (isBackgroundLaunch) {
return
}
if (output.output && typeof output.output === "string") {
const gitStats = getGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const subagentSessionId = extractSessionIdFromOutput(output.output)
const boulderState = readBoulderState(ctx.directory)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
if (input.sessionID && !boulderState.session_ids.includes(input.sessionID)) {
appendSessionId(ctx.directory, input.sessionID)
log(`[${HOOK_NAME}] Appended session to boulder`, {
sessionID: input.sessionID,
plan: boulderState.plan_name,
})
}
// Preserve original subagent response - critical for debugging failed tasks
const originalResponse = output.output
output.output = `
## SUBAGENT WORK COMPLETED
${fileChanges}
---
**Subagent Response:**
${originalResponse}
<system-reminder>
${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)}
</system-reminder>`
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
plan: boulderState.plan_name,
progress: `${progress.completed}/${progress.total}`,
fileCount: gitStats.length,
})
} else {
output.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
sessionID: input.sessionID,
fileCount: gitStats.length,
})
}
}
},
}
}
export { HOOK_NAME } from "./hook-name"
export { createAtlasHook } from "./atlas-hook"
export type { AtlasHookOptions } from "./types"
+20
View 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
View 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
View 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
View 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
View 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>"
}
@@ -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
View 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
View 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
View 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
View 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.**`
}
@@ -0,0 +1,5 @@
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
export function isWriteOrEditToolName(toolName: string): boolean {
return WRITE_EDIT_TOOLS.includes(toolName)
}
+8 -298
View File
@@ -1,298 +1,8 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
import {
PACKAGE_NAME,
NPM_REGISTRY_URL,
NPM_FETCH_TIMEOUT,
INSTALLED_PACKAGE_JSON,
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
USER_CONFIG_DIR,
getWindowsAppdataDir,
} from "./constants"
import * as os from "node:os"
import { log } from "../../shared/logger"
export function isLocalDevMode(directory: string): boolean {
return getLocalDevPath(directory) !== null
}
function stripJsonComments(json: string): string {
return json
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m))
.replace(/,(\s*[}\]])/g, "$1")
}
function getConfigPaths(directory: string): string[] {
const paths = [
path.join(directory, ".opencode", "opencode.json"),
path.join(directory, ".opencode", "opencode.jsonc"),
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
]
if (process.platform === "win32") {
const crossPlatformDir = path.join(os.homedir(), ".config")
const appdataDir = getWindowsAppdataDir()
if (appdataDir) {
const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
if (!paths.includes(alternateConfig)) {
paths.push(alternateConfig)
}
if (!paths.includes(alternateConfigJsonc)) {
paths.push(alternateConfigJsonc)
}
}
}
return paths
}
export function getLocalDevPath(directory: string): string | null {
for (const configPath of getConfigPaths(directory)) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
try {
return fileURLToPath(entry)
} catch {
return entry.replace("file://", "")
}
}
}
} catch {
continue
}
}
return null
}
function findPackageJsonUp(startPath: string): string | null {
try {
const stat = fs.statSync(startPath)
let dir = stat.isDirectory() ? startPath : path.dirname(startPath)
for (let i = 0; i < 10; i++) {
const pkgPath = path.join(dir, "package.json")
if (fs.existsSync(pkgPath)) {
try {
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.name === PACKAGE_NAME) return pkgPath
} catch {}
}
const parent = path.dirname(dir)
if (parent === dir) break
dir = parent
}
} catch {}
return null
}
export function getLocalDevVersion(directory: string): string | null {
const localPath = getLocalDevPath(directory)
if (!localPath) return null
try {
const pkgPath = findPackageJsonUp(localPath)
if (!pkgPath) return null
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
return pkg.version ?? null
} catch {
return null
}
}
export interface PluginEntryInfo {
entry: string
isPinned: boolean
pinnedVersion: string | null
configPath: string
}
export function findPluginEntry(directory: string): PluginEntryInfo | null {
for (const configPath of getConfigPaths(directory)) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry === PACKAGE_NAME) {
return { entry, isPinned: false, pinnedVersion: null, configPath }
}
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
const isPinned = pinnedVersion !== "latest"
return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath }
}
}
} catch {
continue
}
}
return null
}
export function getCachedVersion(): string | null {
try {
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
}
} catch {}
try {
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pkgPath = findPackageJsonUp(currentDir)
if (pkgPath) {
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
}
} catch (err) {
log("[auto-update-checker] Failed to resolve version from current directory:", err)
}
// Fallback for compiled binaries (npm global install)
// process.execPath points to the actual binary location
try {
const execDir = path.dirname(fs.realpathSync(process.execPath))
const pkgPath = findPackageJsonUp(execDir)
if (pkgPath) {
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
}
} catch (err) {
log("[auto-update-checker] Failed to resolve version from execPath:", err)
}
return null
}
/**
* Updates a pinned version entry in the config file.
* Only replaces within the "plugin" array to avoid unintended edits.
* Preserves JSONC comments and formatting via string replacement.
*/
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
try {
const content = fs.readFileSync(configPath, "utf-8")
const newEntry = `${PACKAGE_NAME}@${newVersion}`
// Find the "plugin" array region to scope replacement
const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
if (!pluginMatch || pluginMatch.index === undefined) {
log(`[auto-update-checker] No "plugin" array found in ${configPath}`)
return false
}
// Find the closing bracket of the plugin array
const startIdx = pluginMatch.index + pluginMatch[0].length
let bracketCount = 1
let endIdx = startIdx
for (let i = startIdx; i < content.length && bracketCount > 0; i++) {
if (content[i] === "[") bracketCount++
else if (content[i] === "]") bracketCount--
endIdx = i
}
const before = content.slice(0, startIdx)
const pluginArrayContent = content.slice(startIdx, endIdx)
const after = content.slice(endIdx)
// Only replace first occurrence within plugin array
const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = new RegExp(`["']${escapedOldEntry}["']`)
if (!regex.test(pluginArrayContent)) {
log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`)
return false
}
const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`)
const updatedContent = before + updatedPluginArray + after
if (updatedContent === content) {
log(`[auto-update-checker] No changes made to ${configPath}`)
return false
}
fs.writeFileSync(configPath, updatedContent, "utf-8")
log(`[auto-update-checker] Updated ${configPath}: ${oldEntry}${newEntry}`)
return true
} catch (err) {
log(`[auto-update-checker] Failed to update config file ${configPath}:`, err)
return false
}
}
export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
try {
const response = await fetch(NPM_REGISTRY_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
if (!response.ok) return null
const data = (await response.json()) as NpmDistTags
return data[channel] ?? data.latest ?? null
} catch {
return null
} finally {
clearTimeout(timeoutId)
}
}
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
if (isLocalDevMode(directory)) {
log("[auto-update-checker] Local dev mode detected, skipping update check")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false }
}
const pluginInfo = findPluginEntry(directory)
if (!pluginInfo) {
log("[auto-update-checker] Plugin not found in config")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
}
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion
if (!currentVersion) {
log("[auto-update-checker] No cached version found")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
}
const { extractChannel } = await import("./index")
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned }
}
const needsUpdate = currentVersion !== latestVersion
log(`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned }
}
export { isLocalDevMode, getLocalDevPath } from "./checker/local-dev-path"
export { getLocalDevVersion } from "./checker/local-dev-version"
export { findPluginEntry } from "./checker/plugin-entry"
export type { PluginEntryInfo } from "./checker/plugin-entry"
export { getCachedVersion } from "./checker/cached-version"
export { updatePinnedVersion } from "./checker/pinned-version-updater"
export { getLatestVersion } from "./checker/latest-version"
export { checkForUpdate } from "./checker/check-for-update"
@@ -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
}
@@ -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,
}
}
@@ -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
}
@@ -0,0 +1,7 @@
export function stripJsonComments(json: string): string {
return json
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match, group) =>
group ? "" : match
)
.replace(/,(\s*[}\]])/g, "$1")
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
}
@@ -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
View 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)")
}
}
@@ -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")
}
}
@@ -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")
}
@@ -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))
}
}
@@ -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}`)
}
@@ -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}`)
}
+8 -300
View File
@@ -1,304 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker"
import { invalidatePackage } from "./cache"
import { PACKAGE_NAME } from "./constants"
import { log } from "../../shared/logger"
import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors"
import { runBunInstall } from "../../cli/config-manager"
import { isModelCacheAvailable } from "../../shared/model-availability"
import { hasConnectedProvidersCache, updateConnectedProvidersCache } from "../../shared/connected-providers-cache"
import type { AutoUpdateCheckerOptions } from "./types"
export { createAutoUpdateCheckerHook } from "./hook"
const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "]
export {
isPrereleaseVersion,
isDistTag,
isPrereleaseOrDistTag,
extractChannel,
} from "./version-channel"
export function isPrereleaseVersion(version: string): boolean {
return version.includes("-")
}
export function isDistTag(version: string): boolean {
const startsWithDigit = /^\d/.test(version)
return !startsWithDigit
}
export function isPrereleaseOrDistTag(pinnedVersion: string | null): boolean {
if (!pinnedVersion) return false
return isPrereleaseVersion(pinnedVersion) || isDistTag(pinnedVersion)
}
export function extractChannel(version: string | null): string {
if (!version) return "latest"
if (isDistTag(version)) {
return version
}
if (isPrereleaseVersion(version)) {
const prereleasePart = version.split("-")[1]
if (prereleasePart) {
const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/)
if (channelMatch) {
return channelMatch[1]
}
}
}
return "latest"
}
export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options
const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => {
if (isSisyphusEnabled) {
return isUpdate
? `Sisyphus on steroids is steering OpenCode.\nv${latestVersion} available. Restart to apply.`
: `Sisyphus on steroids is steering OpenCode.`
}
return isUpdate
? `OpenCode is now on Steroids. oMoMoMoMo...\nv${latestVersion} available. Restart OpenCode to apply.`
: `OpenCode is now on Steroids. oMoMoMoMo...`
}
let hasChecked = false
return {
event: ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.created") return
if (hasChecked) return
const props = event.properties as { info?: { parentID?: string } } | undefined
if (props?.info?.parentID) return
hasChecked = true
setTimeout(async () => {
const cachedVersion = getCachedVersion()
const localDevVersion = getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
await showConfigErrorsIfAny(ctx)
await showModelCacheWarningIfNeeded(ctx)
await updateAndShowConnectedProvidersCacheStatus(ctx)
if (localDevVersion) {
if (showStartupToast) {
showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
}
log("[auto-update-checker] Local development mode")
return
}
if (showStartupToast) {
showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
}
runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch(err => {
log("[auto-update-checker] Background update check failed:", err)
})
}, 0)
},
}
}
async function runBackgroundUpdateCheck(
ctx: PluginInput,
autoUpdate: boolean,
getToastMessage: (isUpdate: boolean, latestVersion?: string) => string
): Promise<void> {
const pluginInfo = findPluginEntry(ctx.directory)
if (!pluginInfo) {
log("[auto-update-checker] Plugin not found in config")
return
}
const cachedVersion = getCachedVersion()
const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion
if (!currentVersion) {
log("[auto-update-checker] No version found (cached or pinned)")
return
}
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
return
}
if (currentVersion === latestVersion) {
log("[auto-update-checker] Already on latest version for channel:", channel)
return
}
log(`[auto-update-checker] Update available (${channel}): ${currentVersion}${latestVersion}`)
if (!autoUpdate) {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] Auto-update disabled, notification only")
return
}
if (pluginInfo.isPinned) {
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
if (!updated) {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] Failed to update pinned version in config")
return
}
log(`[auto-update-checker] Config updated: ${pluginInfo.entry}${PACKAGE_NAME}@${latestVersion}`)
}
invalidatePackage(PACKAGE_NAME)
const installSuccess = await runBunInstallSafe()
if (installSuccess) {
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
log(`[auto-update-checker] Update installed: ${currentVersion}${latestVersion}`)
} else {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
}
}
async function runBunInstallSafe(): Promise<boolean> {
try {
return await runBunInstall()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
log("[auto-update-checker] bun install error:", errorMessage)
return false
}
}
async function showModelCacheWarningIfNeeded(ctx: PluginInput): Promise<void> {
if (isModelCacheAvailable()) return
await ctx.client.tui
.showToast({
body: {
title: "Model Cache Not Found",
message: "Run 'opencode models --refresh' or restart OpenCode to populate the models cache for optimal agent model selection.",
variant: "warning" as const,
duration: 10000,
},
})
.catch(() => {})
log("[auto-update-checker] Model cache warning shown")
}
async function updateAndShowConnectedProvidersCacheStatus(ctx: PluginInput): Promise<void> {
const hadCache = hasConnectedProvidersCache()
updateConnectedProvidersCache(ctx.client).catch(() => {})
if (!hadCache) {
await ctx.client.tui
.showToast({
body: {
title: "Connected Providers Cache",
message: "Building provider cache for first time. Restart OpenCode for full model filtering.",
variant: "info" as const,
duration: 8000,
},
})
.catch(() => {})
log("[auto-update-checker] Connected providers cache toast shown (first run)")
} else {
log("[auto-update-checker] Connected providers cache exists, updating in background")
}
}
async function showConfigErrorsIfAny(ctx: PluginInput): Promise<void> {
const errors = getConfigLoadErrors()
if (errors.length === 0) return
const errorMessages = errors.map(e => `${e.path}: ${e.error}`).join("\n")
await ctx.client.tui
.showToast({
body: {
title: "Config Load Error",
message: `Failed to load config:\n${errorMessages}`,
variant: "error" as const,
duration: 10000,
},
})
.catch(() => {})
log(`[auto-update-checker] Config load errors shown: ${errors.length} error(s)`)
clearConfigLoadErrors()
}
async function showVersionToast(ctx: PluginInput, version: string | null, message: string): Promise<void> {
const displayVersion = version ?? "unknown"
await showSpinnerToast(ctx, displayVersion, message)
log(`[auto-update-checker] Startup toast shown: v${displayVersion}`)
}
async function showSpinnerToast(ctx: PluginInput, version: string, message: string): Promise<void> {
const totalDuration = 5000
const frameInterval = 100
const totalFrames = Math.floor(totalDuration / frameInterval)
for (let i = 0; i < totalFrames; i++) {
const spinner = SISYPHUS_SPINNER[i % SISYPHUS_SPINNER.length]
await ctx.client.tui
.showToast({
body: {
title: `${spinner} OhMyOpenCode ${version}`,
message,
variant: "info" as const,
duration: frameInterval + 50,
},
})
.catch(() => { })
await new Promise(resolve => setTimeout(resolve, frameInterval))
}
}
async function showUpdateAvailableToast(
ctx: PluginInput,
latestVersion: string,
getToastMessage: (isUpdate: boolean, latestVersion?: string) => string
): Promise<void> {
await ctx.client.tui
.showToast({
body: {
title: `OhMyOpenCode ${latestVersion}`,
message: getToastMessage(true, latestVersion),
variant: "info" as const,
duration: 8000,
},
})
.catch(() => {})
log(`[auto-update-checker] Update available toast shown: v${latestVersion}`)
}
async function showAutoUpdatedToast(ctx: PluginInput, oldVersion: string, newVersion: string): Promise<void> {
await ctx.client.tui
.showToast({
body: {
title: `OhMyOpenCode Updated!`,
message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`,
variant: "success" as const,
duration: 8000,
},
})
.catch(() => {})
log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`)
}
async function showLocalDevToast(ctx: PluginInput, version: string | null, isSisyphusEnabled: boolean): Promise<void> {
const displayVersion = version ?? "dev"
const message = isSisyphusEnabled
? "Sisyphus running in local development mode."
: "Running in local development mode. oMoMoMo..."
await showSpinnerToast(ctx, `${displayVersion} (dev)`, message)
log(`[auto-update-checker] Local dev toast shown: v${displayVersion}`)
}
export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types"
export { checkForUpdate } from "./checker"
export { invalidatePackage, invalidateCache } from "./cache"
export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types"
@@ -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"
}
@@ -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
View File
@@ -1,421 +1 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { loadClaudeHooksConfig } from "./config"
import { loadPluginExtendedConfig } from "./config-loader"
import {
executePreToolUseHooks,
type PreToolUseContext,
} from "./pre-tool-use"
import {
executePostToolUseHooks,
type PostToolUseContext,
type PostToolUseClient,
} from "./post-tool-use"
import {
executeUserPromptSubmitHooks,
type UserPromptSubmitContext,
type MessagePart,
} from "./user-prompt-submit"
import {
executeStopHooks,
type StopContext,
} from "./stop"
import {
executePreCompactHooks,
type PreCompactContext,
} from "./pre-compact"
import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { appendTranscriptEntry, getTranscriptPath } from "./transcript"
import type { PluginConfig } from "./types"
import { log, isHookDisabled } from "../../shared"
import type { ContextCollector } from "../../features/context-injector"
const sessionFirstMessageProcessed = new Set<string>()
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function createClaudeCodeHooksHook(
ctx: PluginInput,
config: PluginConfig = {},
contextCollector?: ContextCollector
) {
return {
"experimental.session.compacting": async (
input: { sessionID: string },
output: { context: string[] }
): Promise<void> => {
if (isHookDisabled(config, "PreCompact")) {
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const preCompactCtx: PreCompactContext = {
sessionId: input.sessionID,
cwd: ctx.directory,
}
const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig)
if (result.context.length > 0) {
log("PreCompact hooks injecting context", {
sessionID: input.sessionID,
contextCount: result.context.length,
hookName: result.hookName,
elapsedMs: result.elapsedMs,
})
output.context.push(...result.context)
}
},
"chat.message": async (
input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
},
output: {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
const interruptState = sessionInterruptState.get(input.sessionID)
if (interruptState?.interrupted) {
log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID })
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const textParts = output.parts.filter((p) => p.type === "text" && p.text)
const prompt = textParts.map((p) => p.text ?? "").join("\n")
appendTranscriptEntry(input.sessionID, {
type: "user",
timestamp: new Date().toISOString(),
content: prompt,
})
const messageParts: MessagePart[] = textParts.map((p) => ({
type: p.type as "text",
text: p.text,
}))
const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateBeforeHooks?.interrupted) {
log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID })
return
}
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: input.sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
sessionFirstMessageProcessed.add(input.sessionID)
if (!isHookDisabled(config, "UserPromptSubmit")) {
const userPromptCtx: UserPromptSubmitContext = {
sessionId: input.sessionID,
parentSessionId,
prompt,
parts: messageParts,
cwd: ctx.directory,
}
const result = await executeUserPromptSubmitHooks(
userPromptCtx,
claudeConfig,
extendedConfig
)
if (result.block) {
throw new Error(result.reason ?? "Hook blocked the prompt")
}
const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateAfterHooks?.interrupted) {
log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID })
return
}
if (result.messages.length > 0) {
const hookContent = result.messages.join("\n\n")
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
if (contextCollector) {
log("[DEBUG] Registering hook content to contextCollector", {
sessionID: input.sessionID,
contentLength: hookContent.length,
contentPreview: hookContent.slice(0, 100),
})
contextCollector.register(input.sessionID, {
id: "hook-context",
source: "custom",
content: hookContent,
priority: "high",
})
log("Hook content registered for synthetic message injection", {
sessionID: input.sessionID,
contentLength: hookContent.length,
})
}
}
}
},
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> }
): Promise<void> => {
if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") {
let parsed: unknown
try {
parsed = JSON.parse(output.args.todos)
} catch (e) {
throw new Error(
`[todowrite ERROR] Failed to parse todos string as JSON. ` +
`Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` +
`Expected: Valid JSON array. Pass todos as an array, not a string.`
)
}
if (!Array.isArray(parsed)) {
throw new Error(
`[todowrite ERROR] Parsed JSON is not an array. ` +
`Received type: ${typeof parsed}. ` +
`Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].`
)
}
output.args.todos = parsed
log("todowrite: parsed todos string to array", { sessionID: input.sessionID })
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
appendTranscriptEntry(input.sessionID, {
type: "tool_use",
timestamp: new Date().toISOString(),
tool_name: input.tool,
tool_input: output.args as Record<string, unknown>,
})
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
if (!isHookDisabled(config, "PreToolUse")) {
const preCtx: PreToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: output.args as Record<string, unknown>,
cwd: ctx.directory,
toolUseId: input.callID,
}
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
if (result.decision === "deny") {
ctx.client.tui
.showToast({
body: {
title: "PreToolUse Hook Executed",
message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
variant: "error",
duration: 4000,
},
})
.catch(() => {})
throw new Error(result.reason ?? "Hook blocked the operation")
}
if (result.modifiedInput) {
Object.assign(output.args as Record<string, unknown>, result.modifiedInput)
}
}
},
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!output) {
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
// Use metadata if available and non-empty, otherwise wrap output.output in a structured object
// This ensures plugin tools (call_omo_agent, task) that return strings
// get their results properly recorded in transcripts instead of empty {}
const metadata = output.metadata as Record<string, unknown> | undefined
const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0
const toolOutput = hasMetadata ? metadata : { output: output.output }
appendTranscriptEntry(input.sessionID, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: input.tool,
tool_input: cachedInput,
tool_output: toolOutput,
})
if (!isHookDisabled(config, "PostToolUse")) {
const postClient: PostToolUseClient = {
session: {
messages: (opts) => ctx.client.session.messages(opts),
},
}
const postCtx: PostToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: cachedInput,
toolOutput: {
title: input.tool,
output: output.output,
metadata: output.metadata as Record<string, unknown>,
},
cwd: ctx.directory,
transcriptPath: getTranscriptPath(input.sessionID),
toolUseId: input.callID,
client: postClient,
permissionMode: "bypassPermissions",
}
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
if (result.block) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Warning",
message: result.reason ?? "Hook returned warning",
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
}
if (result.warnings && result.warnings.length > 0) {
output.output = `${output.output}\n\n${result.warnings.join("\n")}`
}
if (result.message) {
output.output = `${output.output}\n\n${result.message}`
}
if (result.hookName) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Executed",
message: `${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`,
variant: "success",
duration: 2000,
},
})
.catch(() => {})
}
}
},
event: async (input: { event: { type: string; properties?: unknown } }) => {
const { event } = input
if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
sessionErrorState.set(sessionID, {
hasError: true,
errorMessage: String(props?.error ?? "Unknown error"),
})
}
return
}
if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessionErrorState.delete(sessionInfo.id)
sessionInterruptState.delete(sessionInfo.id)
sessionFirstMessageProcessed.delete(sessionInfo.id)
}
return
}
if (event.type === "session.idle") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const errorStateBefore = sessionErrorState.get(sessionID)
const endedWithErrorBefore = errorStateBefore?.hasError === true
const interruptStateBefore = sessionInterruptState.get(sessionID)
const interruptedBefore = interruptStateBefore?.interrupted === true
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
if (!isHookDisabled(config, "Stop")) {
const stopCtx: StopContext = {
sessionId: sessionID,
parentSessionId,
cwd: ctx.directory,
}
const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
const errorStateAfter = sessionErrorState.get(sessionID)
const endedWithErrorAfter = errorStateAfter?.hasError === true
const interruptStateAfter = sessionInterruptState.get(sessionID)
const interruptedAfter = interruptStateAfter?.interrupted === true
const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter
if (shouldBypass && stopResult.block) {
const interrupted = interruptedBefore || interruptedAfter
const endedWithError = endedWithErrorBefore || endedWithErrorAfter
log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError })
} else if (stopResult.block && stopResult.injectPrompt) {
log("Stop hook returned block with inject_prompt", { sessionID })
ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: stopResult.injectPrompt }] },
query: { directory: ctx.directory },
})
.catch((err: unknown) => log("Failed to inject prompt from Stop hook", err))
} else if (stopResult.block) {
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
}
}
sessionErrorState.delete(sessionID)
sessionInterruptState.delete(sessionID)
}
},
}
}
export { createClaudeCodeHooksHook } from "./claude-code-hooks-hook"
+1 -267
View File
@@ -1,267 +1 @@
import type { PluginInput } from "@opencode-ai/plugin";
import {
loadInteractiveBashSessionState,
saveInteractiveBashSessionState,
clearInteractiveBashSessionState,
} from "./storage";
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
import type { InteractiveBashSessionState } from "./types";
import { subagentSessions } from "../../features/claude-code-session-state";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
args?: Record<string, unknown>;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
/**
* Quote-aware command tokenizer with escape handling
* Handles single/double quotes and backslash escapes
*/
function tokenizeCommand(cmd: string): string[] {
const tokens: string[] = []
let current = ""
let inQuote = false
let quoteChar = ""
let escaped = false
for (let i = 0; i < cmd.length; i++) {
const char = cmd[i]
if (escaped) {
current += char
escaped = false
continue
}
if (char === "\\") {
escaped = true
continue
}
if ((char === "'" || char === '"') && !inQuote) {
inQuote = true
quoteChar = char
} else if (char === quoteChar && inQuote) {
inQuote = false
quoteChar = ""
} else if (char === " " && !inQuote) {
if (current) {
tokens.push(current)
current = ""
}
} else {
current += char
}
}
if (current) tokens.push(current)
return tokens
}
/**
* Normalize session name by stripping :window and .pane suffixes
* e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x"
*/
function normalizeSessionName(name: string): string {
return name.split(":")[0].split(".")[0]
}
function findFlagValue(tokens: string[], flag: string): string | null {
for (let i = 0; i < tokens.length - 1; i++) {
if (tokens[i] === flag) return tokens[i + 1]
}
return null
}
/**
* Extract session name from tokens, considering the subCommand
* For new-session: prioritize -s over -t
* For other commands: use -t
*/
function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null {
if (subCommand === "new-session") {
const sFlag = findFlagValue(tokens, "-s")
if (sFlag) return normalizeSessionName(sFlag)
const tFlag = findFlagValue(tokens, "-t")
if (tFlag) return normalizeSessionName(tFlag)
} else {
const tFlag = findFlagValue(tokens, "-t")
if (tFlag) return normalizeSessionName(tFlag)
}
return null
}
/**
* Find the tmux subcommand from tokens, skipping global options.
* tmux allows global options before the subcommand:
* e.g., `tmux -L socket-name new-session -s omo-x`
* Global options with args: -L, -S, -f, -c, -T
* Standalone flags: -C, -v, -V, etc.
* Special: -- (end of options marker)
*/
function findSubcommand(tokens: string[]): string {
// Options that require an argument: -L, -S, -f, -c, -T
const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"])
let i = 0
while (i < tokens.length) {
const token = tokens[i]
// Handle end of options marker
if (token === "--") {
// Next token is the subcommand
return tokens[i + 1] ?? ""
}
if (globalOptionsWithArgs.has(token)) {
// Skip the option and its argument
i += 2
continue
}
if (token.startsWith("-")) {
// Skip standalone flags like -C, -v, -V
i++
continue
}
// Found the subcommand
return token
}
return ""
}
export function createInteractiveBashSessionHook(ctx: PluginInput) {
const sessionStates = new Map<string, InteractiveBashSessionState>();
function getOrCreateState(sessionID: string): InteractiveBashSessionState {
if (!sessionStates.has(sessionID)) {
const persisted = loadInteractiveBashSessionState(sessionID);
const state: InteractiveBashSessionState = persisted ?? {
sessionID,
tmuxSessions: new Set<string>(),
updatedAt: Date.now(),
};
sessionStates.set(sessionID, state);
}
return sessionStates.get(sessionID)!;
}
function isOmoSession(sessionName: string | null): boolean {
return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX);
}
async function killAllTrackedSessions(
state: InteractiveBashSessionState,
): Promise<void> {
for (const sessionName of state.tmuxSessions) {
try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
});
await proc.exited;
} catch {}
}
for (const sessionId of subagentSessions) {
ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {})
}
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
const { tool, sessionID, args } = input;
const toolLower = tool.toLowerCase();
if (toolLower !== "interactive_bash") {
return;
}
if (typeof args?.tmux_command !== "string") {
return;
}
const tmuxCommand = args.tmux_command;
const tokens = tokenizeCommand(tmuxCommand);
const subCommand = findSubcommand(tokens);
const state = getOrCreateState(sessionID);
let stateChanged = false;
const toolOutput = output?.output ?? ""
if (toolOutput.startsWith("Error:")) {
return
}
const isNewSession = subCommand === "new-session";
const isKillSession = subCommand === "kill-session";
const isKillServer = subCommand === "kill-server";
const sessionName = extractSessionNameFromTokens(tokens, subCommand);
if (isNewSession && isOmoSession(sessionName)) {
state.tmuxSessions.add(sessionName!);
stateChanged = true;
} else if (isKillSession && isOmoSession(sessionName)) {
state.tmuxSessions.delete(sessionName!);
stateChanged = true;
} else if (isKillServer) {
state.tmuxSessions.clear();
stateChanged = true;
}
if (stateChanged) {
state.updatedAt = Date.now();
saveInteractiveBashSessionState(state);
}
const isSessionOperation = isNewSession || isKillSession || isKillServer;
if (isSessionOperation) {
const reminder = buildSessionReminderMessage(
Array.from(state.tmuxSessions),
);
if (reminder) {
output.output += reminder;
}
}
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
const sessionID = sessionInfo?.id;
if (sessionID) {
const state = getOrCreateState(sessionID);
await killAllTrackedSessions(state);
sessionStates.delete(sessionID);
clearInteractiveBashSessionState(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}
export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook"
@@ -0,0 +1,267 @@
import type { PluginInput } from "@opencode-ai/plugin";
import {
loadInteractiveBashSessionState,
saveInteractiveBashSessionState,
clearInteractiveBashSessionState,
} from "./storage";
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
import type { InteractiveBashSessionState } from "./types";
import { subagentSessions } from "../../features/claude-code-session-state";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
args?: Record<string, unknown>;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
/**
* Quote-aware command tokenizer with escape handling
* Handles single/double quotes and backslash escapes
*/
function tokenizeCommand(cmd: string): string[] {
const tokens: string[] = []
let current = ""
let inQuote = false
let quoteChar = ""
let escaped = false
for (let i = 0; i < cmd.length; i++) {
const char = cmd[i]
if (escaped) {
current += char
escaped = false
continue
}
if (char === "\\") {
escaped = true
continue
}
if ((char === "'" || char === '"') && !inQuote) {
inQuote = true
quoteChar = char
} else if (char === quoteChar && inQuote) {
inQuote = false
quoteChar = ""
} else if (char === " " && !inQuote) {
if (current) {
tokens.push(current)
current = ""
}
} else {
current += char
}
}
if (current) tokens.push(current)
return tokens
}
/**
* Normalize session name by stripping :window and .pane suffixes
* e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x"
*/
function normalizeSessionName(name: string): string {
return name.split(":")[0].split(".")[0]
}
function findFlagValue(tokens: string[], flag: string): string | null {
for (let i = 0; i < tokens.length - 1; i++) {
if (tokens[i] === flag) return tokens[i + 1]
}
return null
}
/**
* Extract session name from tokens, considering the subCommand
* For new-session: prioritize -s over -t
* For other commands: use -t
*/
function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null {
if (subCommand === "new-session") {
const sFlag = findFlagValue(tokens, "-s")
if (sFlag) return normalizeSessionName(sFlag)
const tFlag = findFlagValue(tokens, "-t")
if (tFlag) return normalizeSessionName(tFlag)
} else {
const tFlag = findFlagValue(tokens, "-t")
if (tFlag) return normalizeSessionName(tFlag)
}
return null
}
/**
* Find the tmux subcommand from tokens, skipping global options.
* tmux allows global options before the subcommand:
* e.g., `tmux -L socket-name new-session -s omo-x`
* Global options with args: -L, -S, -f, -c, -T
* Standalone flags: -C, -v, -V, etc.
* Special: -- (end of options marker)
*/
function findSubcommand(tokens: string[]): string {
// Options that require an argument: -L, -S, -f, -c, -T
const globalOptionsWithArgs = new Set<string>(["-L", "-S", "-f", "-c", "-T"])
let i = 0
while (i < tokens.length) {
const token = tokens[i]
// Handle end of options marker
if (token === "--") {
// Next token is the subcommand
return tokens[i + 1] ?? ""
}
if (globalOptionsWithArgs.has(token)) {
// Skip the option and its argument
i += 2
continue
}
if (token.startsWith("-")) {
// Skip standalone flags like -C, -v, -V
i++
continue
}
// Found the subcommand
return token
}
return ""
}
export function createInteractiveBashSessionHook(ctx: PluginInput) {
const sessionStates = new Map<string, InteractiveBashSessionState>();
function getOrCreateState(sessionID: string): InteractiveBashSessionState {
if (!sessionStates.has(sessionID)) {
const persisted = loadInteractiveBashSessionState(sessionID);
const state: InteractiveBashSessionState = persisted ?? {
sessionID,
tmuxSessions: new Set<string>(),
updatedAt: Date.now(),
};
sessionStates.set(sessionID, state);
}
return sessionStates.get(sessionID)!;
}
function isOmoSession(sessionName: string | null): boolean {
return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX);
}
async function killAllTrackedSessions(
state: InteractiveBashSessionState,
): Promise<void> {
for (const sessionName of state.tmuxSessions) {
try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
});
await proc.exited;
} catch {}
}
for (const sessionId of subagentSessions) {
ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {})
}
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
const { tool, sessionID, args } = input;
const toolLower = tool.toLowerCase();
if (toolLower !== "interactive_bash") {
return;
}
if (typeof args?.tmux_command !== "string") {
return;
}
const tmuxCommand = args.tmux_command;
const tokens = tokenizeCommand(tmuxCommand);
const subCommand = findSubcommand(tokens);
const state = getOrCreateState(sessionID);
let stateChanged = false;
const toolOutput = output?.output ?? ""
if (toolOutput.startsWith("Error:")) {
return
}
const isNewSession = subCommand === "new-session";
const isKillSession = subCommand === "kill-session";
const isKillServer = subCommand === "kill-server";
const sessionName = extractSessionNameFromTokens(tokens, subCommand);
if (isNewSession && isOmoSession(sessionName)) {
state.tmuxSessions.add(sessionName!);
stateChanged = true;
} else if (isKillSession && isOmoSession(sessionName)) {
state.tmuxSessions.delete(sessionName!);
stateChanged = true;
} else if (isKillServer) {
state.tmuxSessions.clear();
stateChanged = true;
}
if (stateChanged) {
state.updatedAt = Date.now();
saveInteractiveBashSessionState(state);
}
const isSessionOperation = isNewSession || isKillSession || isKillServer;
if (isSessionOperation) {
const reminder = buildSessionReminderMessage(
Array.from(state.tmuxSessions),
);
if (reminder) {
output.output += reminder;
}
}
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
const sessionID = sessionInfo?.id;
if (sessionID) {
const state = getOrCreateState(sessionID);
await killAllTrackedSessions(state);
sessionStates.delete(sessionID);
clearInteractiveBashSessionState(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}
+1 -62
View File
@@ -1,66 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
import { log, buildEnvPrefix } from "../../shared"
export * from "./constants"
export * from "./detector"
export * from "./types"
const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned
.filter((cmd) => !cmd.includes("("))
.map((cmd) => new RegExp(`\\b${cmd}\\b`))
function detectBannedCommand(command: string): string | undefined {
for (let i = 0; i < BANNED_COMMAND_PATTERNS.length; i++) {
if (BANNED_COMMAND_PATTERNS[i].test(command)) {
return SHELL_COMMAND_PATTERNS.banned[i]
}
}
return undefined
}
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown>; message?: string }
): Promise<void> => {
if (input.tool.toLowerCase() !== "bash") {
return
}
const command = output.args.command as string | undefined
if (!command) {
return
}
const bannedCmd = detectBannedCommand(command)
if (bannedCmd) {
output.message = `Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
}
// Only prepend env vars for git commands (editor blocking, pager, etc.)
const isGitCommand = /\bgit\b/.test(command)
if (!isGitCommand) {
return
}
// NOTE: We intentionally removed the isNonInteractive() check here.
// Even when OpenCode runs in a TTY, the agent cannot interact with
// spawned bash processes. Git commands like `git rebase --continue`
// would open editors (vim/nvim) that hang forever.
// The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected
// for git commands to prevent interactive prompts.
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
// (via Git Bash, WSL, etc.), so always use unix export syntax.
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix")
output.args.command = `${envPrefix} ${command}`
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
sessionID: input.sessionID,
envPrefix,
})
},
}
}
export { createNonInteractiveEnvHook } from "./non-interactive-env-hook"
@@ -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,
})
},
}
}
+2 -424
View File
@@ -1,428 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { readState, writeState, clearState, incrementIteration } from "./storage"
import {
HOOK_NAME,
DEFAULT_MAX_ITERATIONS,
DEFAULT_COMPLETION_PROMISE,
} from "./constants"
import type { RalphLoopState, RalphLoopOptions } from "./types"
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
export * from "./types"
export * from "./constants"
export { readState, writeState, clearState, incrementIteration } from "./storage"
interface SessionState {
isRecovering?: boolean
}
interface OpenCodeSessionMessage {
info?: {
role?: string
}
parts?: Array<{
type: string
text?: string
[key: string]: unknown
}>
}
const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}]
Your previous attempt did not output the completion promise. Continue working on the task.
IMPORTANT:
- Review your progress so far
- Continue from where you left off
- When FULLY complete, output: <promise>{{PROMISE}}</promise>
- Do not stop until the task is truly done
Original task:
{{PROMPT}}`
export interface RalphLoopHook {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
startLoop: (
sessionID: string,
prompt: string,
options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
) => boolean
cancelLoop: (sessionID: string) => boolean
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 3000
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
): RalphLoopHook {
const sessions = new Map<string, SessionState>()
const config = options?.config
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const checkSessionExists = options?.checkSessionExists
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = {}
sessions.set(sessionID, state)
}
return state
}
function detectCompletionPromise(
transcriptPath: string | undefined,
promise: string
): boolean {
if (!transcriptPath) return false
try {
if (!existsSync(transcriptPath)) return false
const content = readFileSync(transcriptPath, "utf-8")
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
const lines = content.split("\n").filter(l => l.trim())
for (const line of lines) {
try {
const entry = JSON.parse(line)
if (entry.type === "user") continue
if (pattern.test(line)) return true
} catch {
continue
}
}
return false
} catch {
return false
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
async function detectCompletionInSessionMessages(
sessionID: string,
promise: string
): Promise<boolean> {
try {
const response = await Promise.race([
ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
),
])
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
(msg) => msg.info?.role === "assistant"
)
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (!lastAssistant?.parts) return false
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
const responseText = lastAssistant.parts
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("\n")
return pattern.test(responseText)
} catch (err) {
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
return false
}
}
const startLoop = (
sessionID: string,
prompt: string,
loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
): boolean => {
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations:
loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS,
completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
ultrawork: loopOptions?.ultrawork,
started_at: new Date().toISOString(),
prompt,
session_id: sessionID,
}
const success = writeState(ctx.directory, state, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop started`, {
sessionID,
maxIterations: state.max_iterations,
completionPromise: state.completion_promise,
})
}
return success
}
const cancelLoop = (sessionID: string): boolean => {
const state = readState(ctx.directory, stateDir)
if (!state || state.session_id !== sessionID) {
return false
}
const success = clearState(ctx.directory, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
}
return success
}
const getState = (): RalphLoopState | null => {
return readState(ctx.directory, stateDir)
}
const event = async ({
event,
}: {
event: { type: string; properties?: unknown }
}): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const sessionState = getSessionState(sessionID)
if (sessionState.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
const state = readState(ctx.directory, stateDir)
if (!state || !state.active) {
return
}
if (state.session_id && state.session_id !== sessionID) {
if (checkSessionExists) {
try {
const originalSessionExists = await checkSessionExists(state.session_id)
if (!originalSessionExists) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return
}
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
const completionDetectedViaApi = completionDetectedViaTranscript
? false
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
if (completionDetectedViaTranscript || completionDetectedViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
})
clearState(ctx.directory, stateDir)
const title = state.ultrawork
? "ULTRAWORK LOOP COMPLETE!"
: "Ralph Loop Complete!"
const message = state.ultrawork
? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)`
: `Task completed after ${state.iteration} iteration(s)`
await ctx.client.tui
.showToast({
body: {
title,
message,
variant: "success",
duration: 5000,
},
})
.catch(() => {})
return
}
if (state.iteration >= state.max_iterations) {
log(`[${HOOK_NAME}] Max iterations reached`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
clearState(ctx.directory, stateDir)
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop Stopped",
message: `Max iterations (${state.max_iterations}) reached without completion`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
return
}
const newState = incrementIteration(ctx.directory, stateDir)
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
iteration: newState.iteration,
max: newState.max_iterations,
})
const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration))
.replace("{{MAX}}", String(newState.max_iterations))
.replace("{{PROMISE}}", newState.completion_promise)
.replace("{{PROMPT}}", newState.prompt)
const finalPrompt = newState.ultrawork
? `ultrawork ${continuationPrompt}`
: continuationPrompt
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
})
.catch(() => {})
try {
let agent: string | undefined
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
agent = info.agent
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
break
}
}
} catch {
const messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: finalPrompt }],
},
query: { directory: ctx.directory },
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
sessionID,
error: String(err),
})
}
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionInfo.id) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
}
sessions.delete(sessionInfo.id)
}
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError") {
if (sessionID) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionID) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
}
sessions.delete(sessionID)
}
return
}
if (sessionID) {
const sessionState = getSessionState(sessionID)
sessionState.isRecovering = true
setTimeout(() => {
sessionState.isRecovering = false
}, 5000)
}
}
}
return {
event,
startLoop,
cancelLoop,
getState,
}
}
export { createRalphLoopHook } from "./ralph-loop-hook"
export type { RalphLoopHook } from "./ralph-loop-hook"
+428
View 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,
}
}
@@ -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
View 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,
}
}
+5 -434
View File
@@ -1,436 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { ExperimentalConfig } from "../../config"
import {
findEmptyMessages,
findEmptyMessageByIndex,
findMessageByIndexNeedingThinking,
findMessagesWithEmptyTextParts,
findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks,
findMessagesWithThinkingOnly,
injectTextPart,
prependThinkingPart,
readParts,
replaceEmptyTextParts,
stripThinkingParts,
} from "./storage"
import type { MessageData, ResumeConfig } from "./types"
import { log } from "../../shared/logger"
export { createSessionRecoveryHook } from "./hook"
export type { SessionRecoveryHook, SessionRecoveryOptions } from "./hook"
export interface SessionRecoveryOptions {
experimental?: ExperimentalConfig
}
export { detectErrorType } from "./detect-error-type"
export type { RecoveryErrorType } from "./detect-error-type"
type Client = ReturnType<typeof createOpencodeClient>
type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
| null
interface MessageInfo {
id?: string
role?: string
sessionID?: string
parentID?: string
error?: unknown
}
interface ToolUsePart {
type: "tool_use"
id: string
name: string
input: Record<string, unknown>
}
interface MessagePart {
type: string
id?: string
text?: string
thinking?: string
name?: string
input?: Record<string, unknown>
}
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
function findLastUserMessage(messages: MessageData[]): MessageData | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info?.role === "user") {
return messages[i]
}
}
return undefined
}
function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig {
return {
sessionID,
agent: userMessage?.info?.agent,
model: userMessage?.info?.model,
}
}
async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
try {
await client.session.promptAsync({
path: { id: config.sessionID },
body: {
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
agent: config.agent,
model: config.model,
},
})
return true
} catch {
return false
}
}
function getErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error.toLowerCase()
const errorObj = error as Record<string, unknown>
const paths = [
errorObj.data,
errorObj.error,
errorObj,
(errorObj.data as Record<string, unknown>)?.error,
]
for (const obj of paths) {
if (obj && typeof obj === "object") {
const msg = (obj as Record<string, unknown>).message
if (typeof msg === "string" && msg.length > 0) {
return msg.toLowerCase()
}
}
}
try {
return JSON.stringify(error).toLowerCase()
} catch {
return ""
}
}
function extractMessageIndex(error: unknown): number | null {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
}
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
// IMPORTANT: Check thinking_block_order BEFORE tool_result_missing
// because Anthropic's extended thinking error messages contain "tool_use" and "tool_result"
// in the documentation URL, which would incorrectly match tool_result_missing
if (
message.includes("thinking") &&
(message.includes("first block") ||
message.includes("must start with") ||
message.includes("preceeding") ||
message.includes("final block") ||
message.includes("cannot be thinking") ||
(message.includes("expected") && message.includes("found")))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
return null
}
function extractToolUseIds(parts: MessagePart[]): string[] {
return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id)
}
async function recoverToolResultMissing(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
// Try API parts first, fallback to filesystem if empty
let parts = failedAssistantMsg.parts || []
if (parts.length === 0 && failedAssistantMsg.info?.id) {
const storedParts = readParts(failedAssistantMsg.info.id)
parts = storedParts.map((p) => ({
type: p.type === "tool" ? "tool_use" : p.type,
id: "callID" in p ? (p as { callID?: string }).callID : p.id,
name: "tool" in p ? (p as { tool?: string }).tool : undefined,
input: "state" in p ? (p as { state?: { input?: Record<string, unknown> } }).state?.input : undefined,
}))
}
const toolUseIds = extractToolUseIds(parts)
if (toolUseIds.length === 0) {
return false
}
const toolResultParts = toolUseIds.map((id) => ({
type: "tool_result" as const,
tool_use_id: id,
content: "Operation cancelled by user (ESC pressed)",
}))
try {
await client.session.promptAsync({
path: { id: sessionID },
// @ts-expect-error - SDK types may not include tool_result parts
body: { parts: toolResultParts },
})
return true
} catch {
return false
}
}
async function recoverThinkingBlockOrder(
_client: Client,
sessionID: string,
_failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
if (targetIndex !== null) {
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
if (targetMessageID) {
return prependThinkingPart(sessionID, targetMessageID)
}
}
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
if (orphanMessages.length === 0) {
return false
}
let anySuccess = false
for (const messageID of orphanMessages) {
if (prependThinkingPart(sessionID, messageID)) {
anySuccess = true
}
}
return anySuccess
}
async function recoverThinkingDisabledViolation(
_client: Client,
sessionID: string,
_failedAssistantMsg: MessageData
): Promise<boolean> {
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
if (messagesWithThinking.length === 0) {
return false
}
let anySuccess = false
for (const messageID of messagesWithThinking) {
if (stripThinkingParts(messageID)) {
anySuccess = true
}
}
return anySuccess
}
const PLACEHOLDER_TEXT = "[user interrupted]"
async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false
const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID)
for (const messageID of messagesWithEmptyText) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) {
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
}
}
if (failedID) {
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
for (const messageID of emptyMessageIDs) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
return anySuccess
}
// NOTE: fallbackRevertStrategy was removed (2025-12-08)
// Reason: Function was defined but never called - no error recovery paths used it.
// All error types have dedicated recovery functions (recoverToolResultMissing,
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
export interface SessionRecoveryHook {
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
isRecoverableError: (error: unknown) => boolean
setOnAbortCallback: (callback: (sessionID: string) => void) => void
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
}
export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook {
const processingErrors = new Set<string>()
const experimental = options?.experimental
let onAbortCallback: ((sessionID: string) => void) | null = null
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = callback
}
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
onRecoveryCompleteCallback = callback
}
const isRecoverableError = (error: unknown): boolean => {
return detectErrorType(error) !== null
}
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
if (!info || info.role !== "assistant" || !info.error) return false
const errorType = detectErrorType(info.error)
if (!errorType) return false
const sessionID = info.sessionID
const assistantMsgID = info.id
if (!sessionID || !assistantMsgID) return false
if (processingErrors.has(assistantMsgID)) return false
processingErrors.add(assistantMsgID)
try {
if (onAbortCallback) {
onAbortCallback(sessionID) // Mark recovering BEFORE abort
}
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
if (!failedMsg) {
return false
}
const toastTitles: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Tool Crash Recovery",
thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery",
}
const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...",
thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...",
}
await ctx.client.tui
.showToast({
body: {
title: toastTitles[errorType],
message: toastMessages[errorType],
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
let success = false
if (errorType === "tool_result_missing") {
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
if (success && experimental?.auto_resume) {
const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
} else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
if (success && experimental?.auto_resume) {
const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
}
return success
} catch (err) {
log("[session-recovery] Recovery failed:", err)
return false
} finally {
processingErrors.delete(assistantMsgID)
// Always notify recovery complete, regardless of success or failure
if (sessionID && onRecoveryCompleteCallback) {
onRecoveryCompleteCallback(sessionID)
}
}
}
return {
handleSessionRecovery,
isRecoverableError,
setOnAbortCallback,
setOnRecoveryCompleteCallback,
}
}
export type { MessageData, ResumeConfig } from "./types"
@@ -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
}
@@ -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
}
@@ -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
View 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
}
}
+26 -390
View File
@@ -1,390 +1,26 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
export function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
export function getMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) return ""
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) {
return directPath
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
return ""
}
export function readMessages(sessionID: string): StoredMessageMeta[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: StoredMessageMeta[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(messageDir, file), "utf-8")
messages.push(JSON.parse(content))
} catch {
continue
}
}
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
export function readParts(messageID: string): StoredPart[] {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: StoredPart[] = []
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
return parts
}
export function hasContent(part: StoredPart): boolean {
if (THINKING_TYPES.has(part.type)) return false
if (META_TYPES.has(part.type)) return false
if (part.type === "text") {
const textPart = part as StoredTextPart
return !!(textPart.text?.trim())
}
if (part.type === "tool" || part.type === "tool_use") {
return true
}
if (part.type === "tool_result") {
return true
}
return false
}
export function messageHasContent(messageID: string): boolean {
const parts = readParts(messageID)
return parts.some(hasContent)
}
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const partId = generatePartId()
const part: StoredTextPart = {
id: partId,
sessionID,
messageID,
type: "text",
text,
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
export function findEmptyMessages(sessionID: string): string[] {
const messages = readMessages(sessionID)
const emptyIds: string[] = []
for (const msg of messages) {
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
}
return emptyIds
}
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
// API index may differ from storage index due to system messages
const indicesToTry = [
targetIndex,
targetIndex - 1,
targetIndex + 1,
targetIndex - 2,
targetIndex + 2,
targetIndex - 3,
targetIndex - 4,
targetIndex - 5,
]
for (const idx of indicesToTry) {
if (idx < 0 || idx >= messages.length) continue
const targetMsg = messages[idx]
if (!messageHasContent(targetMsg.id)) {
return targetMsg.id
}
}
return null
}
export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null
}
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
if (hasThinking) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
const hasTextContent = parts.some(hasContent)
// Has thinking but no text content = orphan thinking
if (hasThinking && !hasTextContent) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== "assistant") continue
// NOTE: Removed isLastMessage skip - recovery needs to fix last message too
// when "thinking must start with" errors occur on final assistant message
const parts = readParts(msg.id)
if (parts.length === 0) continue
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
// NOTE: Changed condition - if first part is not thinking, it's orphan
// regardless of whether thinking blocks exist elsewhere in the message
if (!firstIsThinking) {
result.push(msg.id)
}
}
return result
}
/**
* Find the most recent thinking content from previous assistant messages
* Following Anthropic's recommendation to include thinking blocks from previous turns
*/
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
const messages = readMessages(sessionID)
// Find the index of the current message
const currentIndex = messages.findIndex(m => m.id === beforeMessageID)
if (currentIndex === -1) return ""
// Search backwards through previous assistant messages
for (let i = currentIndex - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
// Look for thinking parts in this message
const parts = readParts(msg.id)
for (const part of parts) {
if (THINKING_TYPES.has(part.type)) {
// Found thinking content - return it
// Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property
const thinking = (part as { thinking?: string; text?: string }).thinking
const reasoning = (part as { thinking?: string; text?: string }).text
const content = thinking || reasoning
if (content && content.trim().length > 0) {
return content
}
}
}
}
return ""
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
// Try to get thinking content from previous turns (Anthropic's recommendation)
const previousThinking = findLastThinkingContent(sessionID, messageID)
const partId = `prt_0000000000_thinking`
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: previousThinking || "[Continuing from previous reasoning]",
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
export function stripThinkingParts(messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyRemoved = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (THINKING_TYPES.has(part.type)) {
unlinkSync(filePath)
anyRemoved = true
}
} catch {
continue
}
}
return anyRemoved
}
export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyReplaced = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (part.type === "text") {
const textPart = part as StoredTextPart
if (!textPart.text?.trim()) {
textPart.text = replacementText
textPart.synthetic = true
writeFileSync(filePath, JSON.stringify(textPart, null, 2))
anyReplaced = true
}
}
} catch {
continue
}
}
return anyReplaced
}
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
const parts = readParts(msg.id)
const hasEmptyTextPart = parts.some((p) => {
if (p.type !== "text") return false
const textPart = p as StoredTextPart
return !textPart.text?.trim()
})
if (hasEmptyTextPart) {
result.push(msg.id)
}
}
return result
}
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
if (targetIndex < 0 || targetIndex >= messages.length) return null
const targetMsg = messages[targetIndex]
if (targetMsg.role !== "assistant") return null
const parts = readParts(targetMsg.id)
if (parts.length === 0) return null
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
if (!firstIsThinking) {
return targetMsg.id
}
return null
}
export { generatePartId } from "./storage/part-id"
export { getMessageDir } from "./storage/message-dir"
export { readMessages } from "./storage/messages-reader"
export { readParts } from "./storage/parts-reader"
export { hasContent, messageHasContent } from "./storage/part-content"
export { injectTextPart } from "./storage/text-part-injector"
export {
findEmptyMessages,
findEmptyMessageByIndex,
findFirstEmptyMessage,
} from "./storage/empty-messages"
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
export {
findMessagesWithThinkingBlocks,
findMessagesWithThinkingOnly,
} from "./storage/thinking-block-search"
export {
findMessagesWithOrphanThinking,
findMessageByIndexNeedingThinking,
} from "./storage/orphan-thinking-search"
export { prependThinkingPart } from "./storage/thinking-prepend"
export { stripThinkingParts } from "./storage/thinking-strip"
export { replaceEmptyTextParts } from "./storage/empty-text"
@@ -0,0 +1,47 @@
import { messageHasContent } from "./part-content"
import { readMessages } from "./messages-reader"
export function findEmptyMessages(sessionID: string): string[] {
const messages = readMessages(sessionID)
const emptyIds: string[] = []
for (const msg of messages) {
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
}
return emptyIds
}
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
const indicesToTry = [
targetIndex,
targetIndex - 1,
targetIndex + 1,
targetIndex - 2,
targetIndex + 2,
targetIndex - 3,
targetIndex - 4,
targetIndex - 5,
]
for (const index of indicesToTry) {
if (index < 0 || index >= messages.length) continue
const targetMessage = messages[index]
if (!messageHasContent(targetMessage.id)) {
return targetMessage.id
}
}
return null
}
export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null
}
@@ -0,0 +1,55 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE } from "../constants"
import type { StoredPart, StoredTextPart } from "../types"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyReplaced = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (part.type === "text") {
const textPart = part as StoredTextPart
if (!textPart.text?.trim()) {
textPart.text = replacementText
textPart.synthetic = true
writeFileSync(filePath, JSON.stringify(textPart, null, 2))
anyReplaced = true
}
}
} catch {
continue
}
}
return anyReplaced
}
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
const parts = readParts(msg.id)
const hasEmptyTextPart = parts.some((part) => {
if (part.type !== "text") return false
const textPart = part as StoredTextPart
return !textPart.text?.trim()
})
if (hasEmptyTextPart) {
result.push(msg.id)
}
}
return result
}
@@ -0,0 +1,21 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE } from "../constants"
export function getMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) return ""
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) {
return directPath
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
return ""
}
@@ -0,0 +1,27 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { StoredMessageMeta } from "../types"
import { getMessageDir } from "./message-dir"
export function readMessages(sessionID: string): StoredMessageMeta[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: StoredMessageMeta[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(messageDir, file), "utf-8")
messages.push(JSON.parse(content))
} catch {
continue
}
}
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
@@ -0,0 +1,43 @@
import { THINKING_TYPES } from "../constants"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
if (!firstIsThinking) {
result.push(msg.id)
}
}
return result
}
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
if (targetIndex < 0 || targetIndex >= messages.length) return null
const targetMessage = messages[targetIndex]
if (targetMessage.role !== "assistant") return null
const parts = readParts(targetMessage.id)
if (parts.length === 0) return null
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
return firstIsThinking ? null : targetMessage.id
}
@@ -0,0 +1,28 @@
import { THINKING_TYPES, META_TYPES } from "../constants"
import type { StoredPart, StoredTextPart } from "../types"
import { readParts } from "./parts-reader"
export function hasContent(part: StoredPart): boolean {
if (THINKING_TYPES.has(part.type)) return false
if (META_TYPES.has(part.type)) return false
if (part.type === "text") {
const textPart = part as StoredTextPart
return !!textPart.text?.trim()
}
if (part.type === "tool" || part.type === "tool_use") {
return true
}
if (part.type === "tool_result") {
return true
}
return false
}
export function messageHasContent(messageID: string): boolean {
const parts = readParts(messageID)
return parts.some(hasContent)
}
@@ -0,0 +1,5 @@
export function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
@@ -0,0 +1,22 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE } from "../constants"
import type { StoredPart } from "../types"
export function readParts(messageID: string): StoredPart[] {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: StoredPart[] = []
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
return parts
}
@@ -0,0 +1,30 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE } from "../constants"
import type { StoredTextPart } from "../types"
import { generatePartId } from "./part-id"
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const partId = generatePartId()
const part: StoredTextPart = {
id: partId,
sessionID,
messageID,
type: "text",
text,
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
@@ -0,0 +1,42 @@
import { THINKING_TYPES } from "../constants"
import { hasContent } from "./part-content"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type))
if (hasThinking) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type))
const hasTextContent = parts.some(hasContent)
if (hasThinking && !hasTextContent) {
result.push(msg.id)
}
}
return result
}
@@ -0,0 +1,58 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE, THINKING_TYPES } from "../constants"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
const messages = readMessages(sessionID)
const currentIndex = messages.findIndex((message) => message.id === beforeMessageID)
if (currentIndex === -1) return ""
for (let i = currentIndex - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== "assistant") continue
const parts = readParts(message.id)
for (const part of parts) {
if (THINKING_TYPES.has(part.type)) {
const thinking = (part as { thinking?: string; text?: string }).thinking
const reasoning = (part as { thinking?: string; text?: string }).text
const content = thinking || reasoning
if (content && content.trim().length > 0) {
return content
}
}
}
}
return ""
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const previousThinking = findLastThinkingContent(sessionID, messageID)
const partId = "prt_0000000000_thinking"
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: previousThinking || "[Continuing from previous reasoning]",
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
@@ -0,0 +1,27 @@
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE, THINKING_TYPES } from "../constants"
import type { StoredPart } from "../types"
export function stripThinkingParts(messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyRemoved = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (THINKING_TYPES.has(part.type)) {
unlinkSync(filePath)
anyRemoved = true
}
} catch {
continue
}
}
return anyRemoved
}
+1 -242
View File
@@ -1,242 +1 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
readBoulderState,
writeBoulderState,
appendSessionId,
findPrometheusPlans,
getPlanProgress,
createBoulderState,
getPlanName,
clearBoulderState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state"
export const HOOK_NAME = "start-work"
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
interface StartWorkHookInput {
sessionID: string
messageID?: string
}
interface StartWorkHookOutput {
parts: Array<{ type: string; text?: string }>
}
function extractUserRequestPlanName(promptText: string): string | null {
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
if (!userRequestMatch) return null
const rawArg = userRequestMatch[1].trim()
if (!rawArg) return null
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
return cleanedArg || null
}
function findPlanByName(plans: string[], requestedName: string): string | null {
const lowerName = requestedName.toLowerCase()
const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName)
if (exactMatch) return exactMatch
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
return partialMatch || null
}
export function createStartWorkHook(ctx: PluginInput) {
return {
"chat.message": async (
input: StartWorkHookInput,
output: StartWorkHookOutput
): Promise<void> => {
const parts = output.parts
const promptText = parts
?.filter((p) => p.type === "text" && p.text)
.map((p) => p.text)
.join("\n")
.trim() || ""
// Only trigger on actual command execution (contains <session-context> tag)
// NOT on description text like "Start Sisyphus work session from Prometheus plan"
const isStartWorkCommand = promptText.includes("<session-context>")
if (!isStartWorkCommand) {
return
}
log(`[${HOOK_NAME}] Processing start-work command`, {
sessionID: input.sessionID,
})
updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298
const existingState = readBoulderState(ctx.directory)
const sessionId = input.sessionID
const timestamp = new Date().toISOString()
let contextInfo = ""
const explicitPlanName = extractUserRequestPlanName(promptText)
if (explicitPlanName) {
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, {
sessionID: input.sessionID,
})
const allPlans = findPrometheusPlans(ctx.directory)
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
if (matchedPlan) {
const progress = getPlanProgress(matchedPlan)
if (progress.isComplete) {
contextInfo = `
## Plan Already Complete
The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
} else {
if (existingState) {
clearBoulderState(ctx.directory)
}
const newState = createBoulderState(matchedPlan, sessionId, "atlas")
writeBoulderState(ctx.directory, newState)
contextInfo = `
## Auto-Selected Plan
**Plan**: ${getPlanName(matchedPlan)}
**Path**: ${matchedPlan}
**Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId}
**Started**: ${timestamp}
boulder.json has been created. Read the plan and begin execution.`
}
} else {
const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete)
if (incompletePlans.length > 0) {
const planList = incompletePlans.map((p, i) => {
const prog = getPlanProgress(p)
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
}).join("\n")
contextInfo = `
## Plan Not Found
Could not find a plan matching "${explicitPlanName}".
Available incomplete plans:
${planList}
Ask the user which plan to work on.`
} else {
contextInfo = `
## Plan Not Found
Could not find a plan matching "${explicitPlanName}".
No incomplete plans available. Create a new plan with: /plan "your task"`
}
}
} else if (existingState) {
const progress = getPlanProgress(existingState.active_plan)
if (!progress.isComplete) {
appendSessionId(ctx.directory, sessionId)
contextInfo = `
## Active Work Session Found
**Status**: RESUMING existing work
**Plan**: ${existingState.plan_name}
**Path**: ${existingState.active_plan}
**Progress**: ${progress.completed}/${progress.total} tasks completed
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
**Started**: ${existingState.started_at}
The current session (${sessionId}) has been added to session_ids.
Read the plan file and continue from the first unchecked task.`
} else {
contextInfo = `
## Previous Work Complete
The previous plan (${existingState.plan_name}) has been completed.
Looking for new plans...`
}
}
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) {
const plans = findPrometheusPlans(ctx.directory)
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)
if (plans.length === 0) {
contextInfo += `
## No Plans Found
No Prometheus plan files found at .sisyphus/plans/
Use Prometheus to create a work plan first: /plan "your task"`
} else if (incompletePlans.length === 0) {
contextInfo += `
## All Plans Complete
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
} else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, "atlas")
writeBoulderState(ctx.directory, newState)
contextInfo += `
## Auto-Selected Plan
**Plan**: ${getPlanName(planPath)}
**Path**: ${planPath}
**Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId}
**Started**: ${timestamp}
boulder.json has been created. Read the plan and begin execution.`
} else {
const planList = incompletePlans.map((p, i) => {
const progress = getPlanProgress(p)
const stat = require("node:fs").statSync(p)
const modified = new Date(stat.mtimeMs).toISOString()
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
}).join("\n")
contextInfo += `
<system-reminder>
## Multiple Plans Found
Current Time: ${timestamp}
Session ID: ${sessionId}
${planList}
Ask the user which plan to work on. Present the options above and wait for their response.
</system-reminder>`
}
}
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx >= 0 && output.parts[idx].text) {
output.parts[idx].text = output.parts[idx].text
.replace(/\$SESSION_ID/g, sessionId)
.replace(/\$TIMESTAMP/g, timestamp)
output.parts[idx].text += `\n\n---\n${contextInfo}`
}
log(`[${HOOK_NAME}] Context injected`, {
sessionID: input.sessionID,
hasExistingState: !!existingState,
})
},
}
}
export { HOOK_NAME, createStartWorkHook } from "./start-work-hook"
+242
View File
@@ -0,0 +1,242 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
readBoulderState,
writeBoulderState,
appendSessionId,
findPrometheusPlans,
getPlanProgress,
createBoulderState,
getPlanName,
clearBoulderState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state"
export const HOOK_NAME = "start-work" as const
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
interface StartWorkHookInput {
sessionID: string
messageID?: string
}
interface StartWorkHookOutput {
parts: Array<{ type: string; text?: string }>
}
function extractUserRequestPlanName(promptText: string): string | null {
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
if (!userRequestMatch) return null
const rawArg = userRequestMatch[1].trim()
if (!rawArg) return null
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
return cleanedArg || null
}
function findPlanByName(plans: string[], requestedName: string): string | null {
const lowerName = requestedName.toLowerCase()
const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName)
if (exactMatch) return exactMatch
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
return partialMatch || null
}
export function createStartWorkHook(ctx: PluginInput) {
return {
"chat.message": async (
input: StartWorkHookInput,
output: StartWorkHookOutput
): Promise<void> => {
const parts = output.parts
const promptText = parts
?.filter((p) => p.type === "text" && p.text)
.map((p) => p.text)
.join("\n")
.trim() || ""
// Only trigger on actual command execution (contains <session-context> tag)
// NOT on description text like "Start Sisyphus work session from Prometheus plan"
const isStartWorkCommand = promptText.includes("<session-context>")
if (!isStartWorkCommand) {
return
}
log(`[${HOOK_NAME}] Processing start-work command`, {
sessionID: input.sessionID,
})
updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298
const existingState = readBoulderState(ctx.directory)
const sessionId = input.sessionID
const timestamp = new Date().toISOString()
let contextInfo = ""
const explicitPlanName = extractUserRequestPlanName(promptText)
if (explicitPlanName) {
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, {
sessionID: input.sessionID,
})
const allPlans = findPrometheusPlans(ctx.directory)
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
if (matchedPlan) {
const progress = getPlanProgress(matchedPlan)
if (progress.isComplete) {
contextInfo = `
## Plan Already Complete
The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
} else {
if (existingState) {
clearBoulderState(ctx.directory)
}
const newState = createBoulderState(matchedPlan, sessionId, "atlas")
writeBoulderState(ctx.directory, newState)
contextInfo = `
## Auto-Selected Plan
**Plan**: ${getPlanName(matchedPlan)}
**Path**: ${matchedPlan}
**Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId}
**Started**: ${timestamp}
boulder.json has been created. Read the plan and begin execution.`
}
} else {
const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete)
if (incompletePlans.length > 0) {
const planList = incompletePlans.map((p, i) => {
const prog = getPlanProgress(p)
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
}).join("\n")
contextInfo = `
## Plan Not Found
Could not find a plan matching "${explicitPlanName}".
Available incomplete plans:
${planList}
Ask the user which plan to work on.`
} else {
contextInfo = `
## Plan Not Found
Could not find a plan matching "${explicitPlanName}".
No incomplete plans available. Create a new plan with: /plan "your task"`
}
}
} else if (existingState) {
const progress = getPlanProgress(existingState.active_plan)
if (!progress.isComplete) {
appendSessionId(ctx.directory, sessionId)
contextInfo = `
## Active Work Session Found
**Status**: RESUMING existing work
**Plan**: ${existingState.plan_name}
**Path**: ${existingState.active_plan}
**Progress**: ${progress.completed}/${progress.total} tasks completed
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
**Started**: ${existingState.started_at}
The current session (${sessionId}) has been added to session_ids.
Read the plan file and continue from the first unchecked task.`
} else {
contextInfo = `
## Previous Work Complete
The previous plan (${existingState.plan_name}) has been completed.
Looking for new plans...`
}
}
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) {
const plans = findPrometheusPlans(ctx.directory)
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)
if (plans.length === 0) {
contextInfo += `
## No Plans Found
No Prometheus plan files found at .sisyphus/plans/
Use Prometheus to create a work plan first: /plan "your task"`
} else if (incompletePlans.length === 0) {
contextInfo += `
## All Plans Complete
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
} else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, "atlas")
writeBoulderState(ctx.directory, newState)
contextInfo += `
## Auto-Selected Plan
**Plan**: ${getPlanName(planPath)}
**Path**: ${planPath}
**Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId}
**Started**: ${timestamp}
boulder.json has been created. Read the plan and begin execution.`
} else {
const planList = incompletePlans.map((p, i) => {
const progress = getPlanProgress(p)
const stat = require("node:fs").statSync(p)
const modified = new Date(stat.mtimeMs).toISOString()
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
}).join("\n")
contextInfo += `
<system-reminder>
## Multiple Plans Found
Current Time: ${timestamp}
Session ID: ${sessionId}
${planList}
Ask the user which plan to work on. Present the options above and wait for their response.
</system-reminder>`
}
}
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx >= 0 && output.parts[idx].text) {
output.parts[idx].text = output.parts[idx].text
.replace(/\$SESSION_ID/g, sessionId)
.replace(/\$TIMESTAMP/g, timestamp)
output.parts[idx].text += `\n\n---\n${contextInfo}`
}
log(`[${HOOK_NAME}] Context injected`, {
sessionID: input.sessionID,
hasExistingState: !!existingState,
})
},
}
}
-517
View File
@@ -1,517 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { BackgroundManager } from "../features/background-agent"
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
import {
findNearestMessageWithFields,
MESSAGE_STORAGE,
type ToolPermission,
} from "../features/hook-message-injector"
import { log } from "../shared/logger"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const HOOK_NAME = "todo-continuation-enforcer"
const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
}
export interface TodoContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
cancelAllCountdowns: () => void
}
interface Todo {
content: string
status: string
priority: string
id: string
}
interface SessionState {
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
countdownStartedAt?: number
abortDetectedAt?: number
}
const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
Incomplete tasks remain in your todo list. Continue working on the next pending task.
- Proceed without asking for permission
- Mark each task complete when finished
- Do not stop until all tasks are done`
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const COUNTDOWN_GRACE_PERIOD_MS = 500
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
function getIncompleteCount(todos: Todo[]): number {
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
}
interface MessageInfo {
id?: string
role?: string
error?: { name?: string; data?: unknown }
}
function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean {
if (!messages || messages.length === 0) return false
const assistantMessages = messages.filter(m => m.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const lastAssistant = assistantMessages[assistantMessages.length - 1]
const errorName = lastAssistant.info?.error?.name
if (!errorName) return false
return errorName === "MessageAbortedError" || errorName === "AbortError"
}
export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer {
const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options
const sessions = new Map<string, SessionState>()
function getState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = {}
sessions.set(sessionID, state)
}
return state
}
function cancelCountdown(sessionID: string): void {
const state = sessions.get(sessionID)
if (!state) return
if (state.countdownTimer) {
clearTimeout(state.countdownTimer)
state.countdownTimer = undefined
}
if (state.countdownInterval) {
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
}
state.countdownStartedAt = undefined
}
function cleanup(sessionID: string): void {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
const markRecovering = (sessionID: string): void => {
const state = getState(sessionID)
state.isRecovering = true
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
}
const markRecoveryComplete = (sessionID: string): void => {
const state = sessions.get(sessionID)
if (state) {
state.isRecovering = false
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
}
}
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
await ctx.client.tui.showToast({
body: {
title: "Todo Continuation",
message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`,
variant: "warning" as const,
duration: TOAST_DURATION_MS,
},
}).catch(() => {})
}
interface ResolvedMessageInfo {
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, ToolPermission>
}
async function injectContinuation(
sessionID: string,
incompleteCount: number,
total: number,
resolvedInfo?: ResolvedMessageInfo
): Promise<void> {
const state = sessions.get(sessionID)
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(err) })
return
}
const freshIncompleteCount = getIncompleteCount(todos)
if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
return
}
let agentName = resolvedInfo?.agent
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
if (!agentName || !model) {
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agentName = agentName ?? prevMessage?.agent
model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID
? {
providerID: prevMessage.model.providerID,
modelID: prevMessage.model.modelID,
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {})
}
: undefined)
tools = tools ?? prevMessage?.tools
}
if (agentName && skipAgents.includes(agentName)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
return
}
const editPermission = tools?.edit
const writePermission = tools?.write
const hasWritePermission = !tools ||
((editPermission !== false && editPermission !== "deny") &&
(writePermission !== false && writePermission !== "deny"))
if (!hasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
return
}
const incompleteTodos = todos.filter(t => t.status !== "completed" && t.status !== "cancelled")
const todoList = incompleteTodos
.map(t => `- [${t.status}] ${t.content}`)
.join("\n")
const prompt = `${CONTINUATION_PROMPT}
[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]
Remaining tasks:
${todoList}`
try {
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: agentName,
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
}
}
function startCountdown(
sessionID: string,
incompleteCount: number,
total: number,
resolvedInfo?: ResolvedMessageInfo
): void {
const state = getState(sessionID)
cancelCountdown(sessionID)
let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(secondsRemaining, incompleteCount)
state.countdownStartedAt = Date.now()
state.countdownInterval = setInterval(() => {
secondsRemaining--
if (secondsRemaining > 0) {
showCountdownToast(secondsRemaining, incompleteCount)
}
}, 1000)
state.countdownTimer = setTimeout(() => {
cancelCountdown(sessionID)
injectContinuation(sessionID, incompleteCount, total, resolvedInfo)
}, COUNTDOWN_SECONDS * 1000)
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
}
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<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 error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
const state = getState(sessionID)
state.abortDetectedAt = Date.now()
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
}
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] session.error`, { sessionID })
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle`, { sessionID })
const mainSessionID = getMainSessionID()
const isMainSession = sessionID === mainSessionID
const isBackgroundTaskSession = subagentSessions.has(sessionID)
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
return
}
const state = getState(sessionID)
if (state.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
// Check 1: Event-based abort detection (primary, most reliable)
if (state.abortDetectedAt) {
const timeSinceAbort = Date.now() - state.abortDetectedAt
const ABORT_WINDOW_MS = 3000
if (timeSinceAbort < ABORT_WINDOW_MS) {
log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID })
state.abortDetectedAt = undefined
return
}
state.abortDetectedAt = undefined
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
// Check 2: API-based abort detection (fallback, for cases where event was missed)
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
if (isLastAssistantMessageAborted(messages)) {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
return
}
} catch (err) {
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) })
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (err) {
log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(err) })
return
}
if (!todos || todos.length === 0) {
log(`[${HOOK_NAME}] No todos`, { sessionID })
return
}
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
return
}
let resolvedInfo: ResolvedMessageInfo | undefined
let hasCompactionMessage = false
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = (messagesResp.data ?? []) as Array<{
info?: {
agent?: string
model?: { providerID: string; modelID: string }
modelID?: string
providerID?: string
tools?: Record<string, ToolPermission>
}
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
hasCompactionMessage = true
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resolvedInfo = {
agent: info.agent,
model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined),
tools: info.tools,
}
break
}
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) })
}
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
return
}
if (hasCompactionMessage && !resolvedInfo?.agent) {
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
return
}
if (isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (!sessionID) return
if (role === "user") {
const state = sessions.get(sessionID)
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
return
}
}
if (state) state.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
if (role === "assistant") {
const state = sessions.get(sessionID)
if (state) state.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
return
}
if (event.type === "message.part.updated") {
const info = props?.info as Record<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.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
const state = sessions.get(sessionID)
if (state) state.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
cleanup(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
return
}
}
const cancelAllCountdowns = (): void => {
for (const sessionID of sessions.keys()) {
cancelCountdown(sessionID)
}
log(`[${HOOK_NAME}] All countdowns cancelled`)
}
return {
handler,
markRecovering,
markRecoveryComplete,
cancelAllCountdowns,
}
}
@@ -0,0 +1,17 @@
import type { MessageInfo } from "./types"
export function isLastAssistantMessageAborted(
messages: Array<{ info?: MessageInfo }>
): boolean {
if (!messages || messages.length === 0) return false
const assistantMessages = messages.filter((message) => message.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const lastAssistant = assistantMessages[assistantMessages.length - 1]
const errorName = lastAssistant.info?.error?.name
if (!errorName) return false
return errorName === "MessageAbortedError" || errorName === "AbortError"
}
@@ -0,0 +1,19 @@
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
export const HOOK_NAME = "todo-continuation-enforcer"
export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
export const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
Incomplete tasks remain in your todo list. Continue working on the next pending task.
- Proceed without asking for permission
- Mark each task complete when finished
- Do not stop until all tasks are done`
export const COUNTDOWN_SECONDS = 2
export const TOAST_DURATION_MS = 900
export const COUNTDOWN_GRACE_PERIOD_MS = 500
export const ABORT_WINDOW_MS = 3000
@@ -0,0 +1,139 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
findNearestMessageWithFields,
type ToolPermission,
} from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import {
CONTINUATION_PROMPT,
DEFAULT_SKIP_AGENTS,
HOOK_NAME,
} from "./constants"
import { getMessageDir } from "./message-directory"
import { getIncompleteCount } from "./todo"
import type { ResolvedMessageInfo, Todo } from "./types"
import type { SessionStateStore } from "./session-state"
function hasWritePermission(tools: Record<string, ToolPermission> | undefined): boolean {
const editPermission = tools?.edit
const writePermission = tools?.write
return (
!tools ||
(editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny")
)
}
export async function injectContinuation(args: {
ctx: PluginInput
sessionID: string
backgroundManager?: BackgroundManager
skipAgents?: string[]
resolvedInfo?: ResolvedMessageInfo
sessionStateStore: SessionStateStore
}): Promise<void> {
const {
ctx,
sessionID,
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
resolvedInfo,
sessionStateStore,
} = args
const state = sessionStateStore.getExistingState(sessionID)
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) })
return
}
const freshIncompleteCount = getIncompleteCount(todos)
if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
return
}
let agentName = resolvedInfo?.agent
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
if (!agentName || !model) {
const messageDir = getMessageDir(sessionID)
const previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agentName = agentName ?? previousMessage?.agent
model =
model ??
(previousMessage?.model?.providerID && previousMessage?.model?.modelID
? {
providerID: previousMessage.model.providerID,
modelID: previousMessage.model.modelID,
...(previousMessage.model.variant
? { variant: previousMessage.model.variant }
: {}),
}
: undefined)
tools = tools ?? previousMessage?.tools
}
if (agentName && skipAgents.includes(agentName)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
return
}
if (!hasWritePermission(tools)) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
return
}
const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
const todoList = incompleteTodos.map((todo) => `- [${todo.status}] ${todo.content}`).join("\n")
const prompt = `${CONTINUATION_PROMPT}
[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]
Remaining tasks:
${todoList}`
try {
log(`[${HOOK_NAME}] Injecting continuation`, {
sessionID,
agent: agentName,
model,
incompleteCount: freshIncompleteCount,
})
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: agentName,
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
} catch (error) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
}
}
@@ -0,0 +1,83 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import { log } from "../../shared/logger"
import {
COUNTDOWN_SECONDS,
HOOK_NAME,
TOAST_DURATION_MS,
} from "./constants"
import type { ResolvedMessageInfo } from "./types"
import type { SessionStateStore } from "./session-state"
import { injectContinuation } from "./continuation-injection"
async function showCountdownToast(
ctx: PluginInput,
seconds: number,
incompleteCount: number
): Promise<void> {
await ctx.client.tui
.showToast({
body: {
title: "Todo Continuation",
message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`,
variant: "warning" as const,
duration: TOAST_DURATION_MS,
},
})
.catch(() => {})
}
export function startCountdown(args: {
ctx: PluginInput
sessionID: string
incompleteCount: number
total: number
resolvedInfo?: ResolvedMessageInfo
backgroundManager?: BackgroundManager
skipAgents: string[]
sessionStateStore: SessionStateStore
}): void {
const {
ctx,
sessionID,
incompleteCount,
resolvedInfo,
backgroundManager,
skipAgents,
sessionStateStore,
} = args
const state = sessionStateStore.getState(sessionID)
sessionStateStore.cancelCountdown(sessionID)
let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(ctx, secondsRemaining, incompleteCount)
state.countdownStartedAt = Date.now()
state.countdownInterval = setInterval(() => {
secondsRemaining--
if (secondsRemaining > 0) {
showCountdownToast(ctx, secondsRemaining, incompleteCount)
}
}, 1000)
state.countdownTimer = setTimeout(() => {
sessionStateStore.cancelCountdown(sessionID)
injectContinuation({
ctx,
sessionID,
backgroundManager,
skipAgents,
resolvedInfo,
sessionStateStore,
})
}, COUNTDOWN_SECONDS * 1000)
log(`[${HOOK_NAME}] Countdown started`, {
sessionID,
seconds: COUNTDOWN_SECONDS,
incompleteCount,
})
}
@@ -0,0 +1,65 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import { log } from "../../shared/logger"
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
import type { SessionStateStore } from "./session-state"
import { handleSessionIdle } from "./idle-event"
import { handleNonIdleEvent } from "./non-idle-events"
export function createTodoContinuationHandler(args: {
ctx: PluginInput
sessionStateStore: SessionStateStore
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
const {
ctx,
sessionStateStore,
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
isContinuationStopped,
} = args
return 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 error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
const state = sessionStateStore.getState(sessionID)
state.abortDetectedAt = Date.now()
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
}
sessionStateStore.cancelCountdown(sessionID)
log(`[${HOOK_NAME}] session.error`, { sessionID })
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
await handleSessionIdle({
ctx,
sessionID,
sessionStateStore,
backgroundManager,
skipAgents,
isContinuationStopped,
})
return
}
handleNonIdleEvent({
eventType: event.type,
properties: props,
sessionStateStore,
})
}
}
@@ -0,0 +1,158 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state"
import type { ToolPermission } from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import {
ABORT_WINDOW_MS,
DEFAULT_SKIP_AGENTS,
HOOK_NAME,
} from "./constants"
import { isLastAssistantMessageAborted } from "./abort-detection"
import { getIncompleteCount } from "./todo"
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
import type { SessionStateStore } from "./session-state"
import { startCountdown } from "./countdown"
export async function handleSessionIdle(args: {
ctx: PluginInput
sessionID: string
sessionStateStore: SessionStateStore
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
}): Promise<void> {
const {
ctx,
sessionID,
sessionStateStore,
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
isContinuationStopped,
} = args
log(`[${HOOK_NAME}] session.idle`, { sessionID })
const mainSessionID = getMainSessionID()
const isMainSession = sessionID === mainSessionID
const isBackgroundTaskSession = subagentSessions.has(sessionID)
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
return
}
const state = sessionStateStore.getState(sessionID)
if (state.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
if (state.abortDetectedAt) {
const timeSinceAbort = Date.now() - state.abortDetectedAt
if (timeSinceAbort < ABORT_WINDOW_MS) {
log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID })
state.abortDetectedAt = undefined
return
}
state.abortDetectedAt = undefined
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
if (isLastAssistantMessageAborted(messages)) {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
return
}
} catch (error) {
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) })
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (error) {
log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) })
return
}
if (!todos || todos.length === 0) {
log(`[${HOOK_NAME}] No todos`, { sessionID })
return
}
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
return
}
let resolvedInfo: ResolvedMessageInfo | undefined
let hasCompactionMessage = false
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
hasCompactionMessage = true
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resolvedInfo = {
agent: info.agent,
model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined),
tools: info.tools as Record<string, ToolPermission> | undefined,
}
break
}
}
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) })
}
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
return
}
if (hasCompactionMessage && !resolvedInfo?.agent) {
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
return
}
if (isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
startCountdown({
ctx,
sessionID,
incompleteCount,
total: todos.length,
resolvedInfo,
backgroundManager,
skipAgents,
sessionStateStore,
})
}
@@ -0,0 +1,58 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
import { createTodoContinuationHandler } from "./handler"
import { createSessionStateStore } from "./session-state"
import type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types"
export type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types"
export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer {
const {
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
isContinuationStopped,
} = options
const sessionStateStore = createSessionStateStore()
const markRecovering = (sessionID: string): void => {
const state = sessionStateStore.getState(sessionID)
state.isRecovering = true
sessionStateStore.cancelCountdown(sessionID)
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
}
const markRecoveryComplete = (sessionID: string): void => {
const state = sessionStateStore.getExistingState(sessionID)
if (state) {
state.isRecovering = false
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
}
}
const handler = createTodoContinuationHandler({
ctx,
sessionStateStore,
backgroundManager,
skipAgents,
isContinuationStopped,
})
const cancelAllCountdowns = (): void => {
sessionStateStore.cancelAllCountdowns()
log(`[${HOOK_NAME}] All countdowns cancelled`)
}
return {
handler,
markRecovering,
markRecoveryComplete,
cancelAllCountdowns,
}
}
@@ -0,0 +1,18 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
export function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
@@ -0,0 +1,74 @@
import { log } from "../../shared/logger"
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
import type { SessionStateStore } from "./session-state"
export function handleNonIdleEvent(args: {
eventType: string
properties: Record<string, unknown> | undefined
sessionStateStore: SessionStateStore
}): void {
const { eventType, properties, sessionStateStore } = args
if (eventType === "message.updated") {
const info = properties?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (!sessionID) return
if (role === "user") {
const state = sessionStateStore.getExistingState(sessionID)
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
return
}
}
if (state) state.abortDetectedAt = undefined
sessionStateStore.cancelCountdown(sessionID)
return
}
if (role === "assistant") {
const state = sessionStateStore.getExistingState(sessionID)
if (state) state.abortDetectedAt = undefined
sessionStateStore.cancelCountdown(sessionID)
return
}
return
}
if (eventType === "message.part.updated") {
const info = properties?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
const state = sessionStateStore.getExistingState(sessionID)
if (state) state.abortDetectedAt = undefined
sessionStateStore.cancelCountdown(sessionID)
}
return
}
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
const sessionID = properties?.sessionID as string | undefined
if (sessionID) {
const state = sessionStateStore.getExistingState(sessionID)
if (state) state.abortDetectedAt = undefined
sessionStateStore.cancelCountdown(sessionID)
}
return
}
if (eventType === "session.deleted") {
const sessionInfo = properties?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessionStateStore.cleanup(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
return
}
}
@@ -0,0 +1,62 @@
import type { SessionState } from "./types"
export interface SessionStateStore {
getState: (sessionID: string) => SessionState
getExistingState: (sessionID: string) => SessionState | undefined
cancelCountdown: (sessionID: string) => void
cleanup: (sessionID: string) => void
cancelAllCountdowns: () => void
}
export function createSessionStateStore(): SessionStateStore {
const sessions = new Map<string, SessionState>()
function getState(sessionID: string): SessionState {
const existingState = sessions.get(sessionID)
if (existingState) return existingState
const state: SessionState = {}
sessions.set(sessionID, state)
return state
}
function getExistingState(sessionID: string): SessionState | undefined {
return sessions.get(sessionID)
}
function cancelCountdown(sessionID: string): void {
const state = sessions.get(sessionID)
if (!state) return
if (state.countdownTimer) {
clearTimeout(state.countdownTimer)
state.countdownTimer = undefined
}
if (state.countdownInterval) {
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
}
state.countdownStartedAt = undefined
}
function cleanup(sessionID: string): void {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
function cancelAllCountdowns(): void {
for (const sessionID of sessions.keys()) {
cancelCountdown(sessionID)
}
}
return {
getState,
getExistingState,
cancelCountdown,
cleanup,
cancelAllCountdowns,
}
}
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { BackgroundManager } from "../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
import type { BackgroundManager } from "../../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "."
type TimerCallback = (...args: any[]) => void
@@ -0,0 +1,5 @@
import type { Todo } from "./types"
export function getIncompleteCount(todos: Todo[]): number {
return todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled").length
}
@@ -0,0 +1,47 @@
import type { BackgroundManager } from "../../features/background-agent"
import type { ToolPermission } from "../../features/hook-message-injector"
export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
}
export interface TodoContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
cancelAllCountdowns: () => void
}
export interface Todo {
content: string
status: string
priority: string
id: string
}
export interface SessionState {
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
countdownStartedAt?: number
abortDetectedAt?: number
}
export interface MessageInfo {
id?: string
role?: string
error?: { name?: string; data?: unknown }
agent?: string
model?: { providerID: string; modelID: string }
providerID?: string
modelID?: string
tools?: Record<string, ToolPermission>
}
export interface ResolvedMessageInfo {
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, ToolPermission>
}
+1 -250
View File
@@ -1,250 +1 @@
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
const HOOK_NAME = "unstable-agent-babysitter"
const DEFAULT_TIMEOUT_MS = 120000
const COOLDOWN_MS = 5 * 60 * 1000
const THINKING_SUMMARY_MAX_CHARS = 500
type BabysittingConfig = {
timeout_ms?: number
}
type BabysitterContext = {
directory: string
client: {
session: {
messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]>
prompt: (args: {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
}
query?: { directory?: string }
}) => Promise<unknown>
promptAsync: (args: {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
}
query?: { directory?: string }
}) => Promise<unknown>
}
}
}
type BabysitterOptions = {
backgroundManager: Pick<BackgroundManager, "getTasksByParentSession">
config?: BabysittingConfig
}
type MessageInfo = {
role?: string
agent?: string
model?: { providerID: string; modelID: string }
providerID?: string
modelID?: string
}
type MessagePart = {
type?: string
text?: string
thinking?: string
}
function hasData(value: unknown): value is { data?: unknown } {
return typeof value === "object" && value !== null && "data" in value
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getMessageInfo(value: unknown): MessageInfo | undefined {
if (!isRecord(value)) return undefined
if (!isRecord(value.info)) return undefined
const info = value.info
const modelValue = isRecord(info.model)
? info.model
: undefined
const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string"
? { providerID: modelValue.providerID, modelID: modelValue.modelID }
: undefined
return {
role: typeof info.role === "string" ? info.role : undefined,
agent: typeof info.agent === "string" ? info.agent : undefined,
model,
providerID: typeof info.providerID === "string" ? info.providerID : undefined,
modelID: typeof info.modelID === "string" ? info.modelID : undefined,
}
}
function getMessageParts(value: unknown): MessagePart[] {
if (!isRecord(value)) return []
if (!Array.isArray(value.parts)) return []
return value.parts.filter(isRecord).map((part) => ({
type: typeof part.type === "string" ? part.type : undefined,
text: typeof part.text === "string" ? part.text : undefined,
thinking: typeof part.thinking === "string" ? part.thinking : undefined,
}))
}
function extractMessages(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value
}
if (hasData(value) && Array.isArray(value.data)) {
return value.data
}
return []
}
function isUnstableTask(task: BackgroundTask): boolean {
if (task.isUnstableAgent === true) return true
const modelId = task.model?.modelID?.toLowerCase()
return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false
}
async function resolveMainSessionTarget(
ctx: BabysitterContext,
sessionID: string
): Promise<{ agent?: string; model?: { providerID: string; modelID: string } }> {
let agent = getSessionAgent(sessionID)
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = extractMessages(messagesResp)
for (let i = messages.length - 1; i >= 0; i--) {
const info = getMessageInfo(messages[i])
if (info?.agent || info?.model || (info?.providerID && info?.modelID)) {
agent = agent ?? info?.agent
model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
break
}
}
} catch (error) {
log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) })
}
return { agent, model }
}
async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise<string | null> {
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = extractMessages(messagesResp)
const chunks: string[] = []
for (const message of messages) {
const info = getMessageInfo(message)
if (info?.role !== "assistant") continue
const parts = getMessageParts(message)
for (const part of parts) {
if (part.type === "thinking" && part.thinking) {
chunks.push(part.thinking)
}
if (part.type === "reasoning" && part.text) {
chunks.push(part.text)
}
}
}
const combined = chunks.join("\n").trim()
if (!combined) return null
if (combined.length <= THINKING_SUMMARY_MAX_CHARS) return combined
return combined.slice(0, THINKING_SUMMARY_MAX_CHARS) + "..."
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch thinking summary`, { sessionID, error: String(error) })
return null
}
}
function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string {
const idleSeconds = Math.round(idleMs / 1000)
const summaryText = summary ?? "(No thinking trace available)"
return `Unstable background agent appears idle for ${idleSeconds}s.
Task ID: ${task.id}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
Session ID: ${task.sessionID ?? "N/A"}
Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars):
${summaryText}
Suggested actions:
- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50
- background_cancel taskId="${task.id}"
This is a reminder only. No automatic action was taken.`
}
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
const reminderCooldowns = new Map<string, number>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.idle") return
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const mainSessionID = getMainSessionID()
if (!mainSessionID || sessionID !== mainSessionID) return
const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID)
if (tasks.length === 0) return
const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS
const now = Date.now()
for (const task of tasks) {
if (task.status !== "running") continue
if (!isUnstableTask(task)) continue
const lastMessageAt = task.progress?.lastMessageAt
if (!lastMessageAt) continue
const idleMs = now - lastMessageAt.getTime()
if (idleMs < timeoutMs) continue
const lastReminderAt = reminderCooldowns.get(task.id)
if (lastReminderAt && now - lastReminderAt < COOLDOWN_MS) continue
const summary = task.sessionID ? await getThinkingSummary(ctx, task.sessionID) : null
const reminder = buildReminder(task, summary, idleMs)
const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID)
try {
await ctx.client.session.promptAsync({
path: { id: mainSessionID },
body: {
...(agent ? { agent } : {}),
...(model ? { model } : {}),
parts: [{ type: "text", text: reminder }],
},
query: { directory: ctx.directory },
})
reminderCooldowns.set(task.id, now)
log(`[${HOOK_NAME}] Reminder injected`, { taskId: task.id, sessionID: mainSessionID })
} catch (error) {
log(`[${HOOK_NAME}] Reminder injection failed`, { taskId: task.id, error: String(error) })
}
}
}
return {
event: eventHandler,
}
}
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter-hook"
@@ -0,0 +1,250 @@
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
const HOOK_NAME = "unstable-agent-babysitter"
const DEFAULT_TIMEOUT_MS = 120000
const COOLDOWN_MS = 5 * 60 * 1000
const THINKING_SUMMARY_MAX_CHARS = 500 as const
type BabysittingConfig = {
timeout_ms?: number
}
type BabysitterContext = {
directory: string
client: {
session: {
messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]>
prompt: (args: {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
}
query?: { directory?: string }
}) => Promise<unknown>
promptAsync: (args: {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
}
query?: { directory?: string }
}) => Promise<unknown>
}
}
}
type BabysitterOptions = {
backgroundManager: Pick<BackgroundManager, "getTasksByParentSession">
config?: BabysittingConfig
}
type MessageInfo = {
role?: string
agent?: string
model?: { providerID: string; modelID: string }
providerID?: string
modelID?: string
}
type MessagePart = {
type?: string
text?: string
thinking?: string
}
function hasData(value: unknown): value is { data?: unknown } {
return typeof value === "object" && value !== null && "data" in value
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getMessageInfo(value: unknown): MessageInfo | undefined {
if (!isRecord(value)) return undefined
if (!isRecord(value.info)) return undefined
const info = value.info
const modelValue = isRecord(info.model)
? info.model
: undefined
const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string"
? { providerID: modelValue.providerID, modelID: modelValue.modelID }
: undefined
return {
role: typeof info.role === "string" ? info.role : undefined,
agent: typeof info.agent === "string" ? info.agent : undefined,
model,
providerID: typeof info.providerID === "string" ? info.providerID : undefined,
modelID: typeof info.modelID === "string" ? info.modelID : undefined,
}
}
function getMessageParts(value: unknown): MessagePart[] {
if (!isRecord(value)) return []
if (!Array.isArray(value.parts)) return []
return value.parts.filter(isRecord).map((part) => ({
type: typeof part.type === "string" ? part.type : undefined,
text: typeof part.text === "string" ? part.text : undefined,
thinking: typeof part.thinking === "string" ? part.thinking : undefined,
}))
}
function extractMessages(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value
}
if (hasData(value) && Array.isArray(value.data)) {
return value.data
}
return []
}
function isUnstableTask(task: BackgroundTask): boolean {
if (task.isUnstableAgent === true) return true
const modelId = task.model?.modelID?.toLowerCase()
return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false
}
async function resolveMainSessionTarget(
ctx: BabysitterContext,
sessionID: string
): Promise<{ agent?: string; model?: { providerID: string; modelID: string } }> {
let agent = getSessionAgent(sessionID)
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = extractMessages(messagesResp)
for (let i = messages.length - 1; i >= 0; i--) {
const info = getMessageInfo(messages[i])
if (info?.agent || info?.model || (info?.providerID && info?.modelID)) {
agent = agent ?? info?.agent
model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
break
}
}
} catch (error) {
log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) })
}
return { agent, model }
}
async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise<string | null> {
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = extractMessages(messagesResp)
const chunks: string[] = []
for (const message of messages) {
const info = getMessageInfo(message)
if (info?.role !== "assistant") continue
const parts = getMessageParts(message)
for (const part of parts) {
if (part.type === "thinking" && part.thinking) {
chunks.push(part.thinking)
}
if (part.type === "reasoning" && part.text) {
chunks.push(part.text)
}
}
}
const combined = chunks.join("\n").trim()
if (!combined) return null
if (combined.length <= THINKING_SUMMARY_MAX_CHARS) return combined
return combined.slice(0, THINKING_SUMMARY_MAX_CHARS) + "..."
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch thinking summary`, { sessionID, error: String(error) })
return null
}
}
function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string {
const idleSeconds = Math.round(idleMs / 1000)
const summaryText = summary ?? "(No thinking trace available)"
return `Unstable background agent appears idle for ${idleSeconds}s.
Task ID: ${task.id}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
Session ID: ${task.sessionID ?? "N/A"}
Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars):
${summaryText}
Suggested actions:
- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50
- background_cancel taskId="${task.id}"
This is a reminder only. No automatic action was taken.`
}
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
const reminderCooldowns = new Map<string, number>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.idle") return
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const mainSessionID = getMainSessionID()
if (!mainSessionID || sessionID !== mainSessionID) return
const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID)
if (tasks.length === 0) return
const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS
const now = Date.now()
for (const task of tasks) {
if (task.status !== "running") continue
if (!isUnstableTask(task)) continue
const lastMessageAt = task.progress?.lastMessageAt
if (!lastMessageAt) continue
const idleMs = now - lastMessageAt.getTime()
if (idleMs < timeoutMs) continue
const lastReminderAt = reminderCooldowns.get(task.id)
if (lastReminderAt && now - lastReminderAt < COOLDOWN_MS) continue
const summary = task.sessionID ? await getThinkingSummary(ctx, task.sessionID) : null
const reminder = buildReminder(task, summary, idleMs)
const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID)
try {
await ctx.client.session.promptAsync({
path: { id: mainSessionID },
body: {
...(agent ? { agent } : {}),
...(model ? { model } : {}),
parts: [{ type: "text", text: reminder }],
},
query: { directory: ctx.directory },
})
reminderCooldowns.set(task.id, now)
log(`[${HOOK_NAME}] Reminder injected`, { taskId: task.id, sessionID: mainSessionID })
} catch (error) {
log(`[${HOOK_NAME}] Reminder injection failed`, { taskId: task.id, error: String(error) })
}
}
}
return {
event: eventHandler,
}
}