merge: integrate origin/dev into modular-enforcement branch

Resolves all merge conflicts, preserving our split module structure
while integrating all dev changes:
- Custom agent summaries support (parseRegisteredAgentSummaries)
- Background notification queue (enqueueNotificationForParent)
- Atlas shared git-worktree module (collectGitDiffStats, formatFileChanges)
- Ralph-loop withTimeout + DEFAULT_API_TIMEOUT=5000
- Session recovery assistant_prefill_unsupported error type
- Atlas agentOverrides forwarding
- Config handler plan model demotion (buildPlanDemoteConfig)
- Delegate-task agentOverrides, promptSyncWithModelSuggestionRetry, variant
- LSP init timeout + stale init detection
- isPlanFamily function + task-continuation-enforcer hook
- Handoff command
This commit is contained in:
YeonGyu-Kim
2026-02-08 17:34:47 +09:00
74 changed files with 4016 additions and 654 deletions

View File

@@ -1,133 +0,0 @@
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 statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!statusOutput) return []
const statusMap = new Map<string, "modified" | "added" | "deleted">()
const untrackedFiles: string[] = []
for (const line of statusOutput.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (status === "??") {
statusMap.set(filePath, "added")
untrackedFiles.push(filePath)
} else if (status === "A") {
statusMap.set(filePath, "added")
} else if (status === "D") {
statusMap.set(filePath, "deleted")
} else {
statusMap.set(filePath, "modified")
}
}
const output = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const stats: GitFileStat[] = []
const trackedPaths = new Set<string>()
if (output) {
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)
trackedPaths.add(path)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
}
for (const filePath of untrackedFiles) {
if (trackedPaths.has(filePath)) continue
try {
const content = execSync(`wc -l < "${filePath}"`, {
cwd: directory,
encoding: "utf-8",
timeout: 3000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const lineCount = parseInt(content, 10) || 0
stats.push({ path: filePath, added: lineCount, removed: 0, status: "added" })
} catch {
stats.push({ path: filePath, added: 0, removed: 0, status: "added" })
}
}
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")
}

View File

@@ -2,9 +2,9 @@ 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 { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
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"
@@ -57,7 +57,7 @@ export function createToolExecuteAfterHandler(input: {
}
if (toolOutput.output && typeof toolOutput.output === "string") {
const gitStats = getGitDiffStats(ctx.directory)
const gitStats = collectGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const subagentSessionId = extractSessionIdFromOutput(toolOutput.output)

View File

@@ -1,3 +1,4 @@
import type { AgentOverrides } from "../../config"
import type { BackgroundManager } from "../../features/background-agent"
export type ModelInfo = { providerID: string; modelID: string }
@@ -6,6 +7,7 @@ export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
isContinuationStopped?: (sessionID: string) => boolean
agentOverrides?: AgentOverrides
}
export interface ToolExecuteAfterInput {

View File

@@ -1,4 +1,5 @@
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createTaskContinuationEnforcer, type TaskContinuationEnforcer } from "./task-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification";
export { sendSessionNotification, playSessionNotificationSound, detectPlatform, getDefaultSoundPath } from "./session-notification-sender";

View File

@@ -104,7 +104,7 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
| Architecture decision needed | MUST call plan agent |
\`\`\`
task(subagent_type="plan", prompt="<gathered context + user request>")
task(subagent_type="plan", load_skills=[], prompt="<gathered context + user request>")
\`\`\`
**WHY PLAN AGENT IS MANDATORY:**
@@ -119,9 +119,9 @@ task(subagent_type="plan", prompt="<gathered context + user request>")
| Scenario | Action |
|----------|--------|
| Plan agent asks clarifying questions | \`task(session_id="{returned_session_id}", prompt="<your answer>")\` |
| Need to refine the plan | \`task(session_id="{returned_session_id}", prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(session_id="{returned_session_id}", prompt="Add more detail to Task N")\` |
| Plan agent asks clarifying questions | \`task(session_id="{returned_session_id}", load_skills=[], prompt="<your answer>")\` |
| Need to refine the plan | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Add more detail to Task N")\` |
**WHY SESSION_ID IS CRITICAL:**
- Plan agent retains FULL conversation context
@@ -131,10 +131,10 @@ task(subagent_type="plan", prompt="<gathered context + user request>")
\`\`\`
// WRONG: Starting fresh loses all context
task(subagent_type="plan", prompt="Here's more info...")
task(subagent_type="plan", load_skills=[], prompt="Here's more info...")
// CORRECT: Resume preserves everything
task(session_id="ses_abc123", prompt="Here's my answer to your question: ...")
task(session_id="ses_abc123", load_skills=[], prompt="Here's my answer to your question: ...")
\`\`\`
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
@@ -147,10 +147,10 @@ task(session_id="ses_abc123", prompt="Here's my answer to your question: ...")
| Task Type | Action | Why |
|-----------|--------|-----|
| Codebase exploration | task(subagent_type="explore", run_in_background=true) | Parallel, context-efficient |
| Documentation lookup | task(subagent_type="librarian", run_in_background=true) | Specialized knowledge |
| Planning | task(subagent_type="plan") | Parallel task graph + structured TODO list |
| Hard problem (conventional) | task(subagent_type="oracle") | Architecture, debugging, complex logic |
| Codebase exploration | task(subagent_type="explore", load_skills=[], run_in_background=true) | Parallel, context-efficient |
| Documentation lookup | task(subagent_type="librarian", load_skills=[], run_in_background=true) | Specialized knowledge |
| Planning | task(subagent_type="plan", load_skills=[]) | Parallel task graph + structured TODO list |
| Hard problem (conventional) | task(subagent_type="oracle", load_skills=[]) | Architecture, debugging, complex logic |
| Hard problem (non-conventional) | task(category="artistry", load_skills=[...]) | Different approach needed |
| Implementation | task(category="...", load_skills=[...]) | Domain-optimized models |

View File

@@ -73,10 +73,10 @@ Use these when they provide clear value based on the decision framework above:
| Resource | When to Use | How to Use |
|----------|-------------|------------|
| explore agent | Need codebase patterns you don't have | \`task(subagent_type="explore", run_in_background=true, ...)\` |
| librarian agent | External library docs, OSS examples | \`task(subagent_type="librarian", run_in_background=true, ...)\` |
| oracle agent | Stuck on architecture/debugging after 2+ attempts | \`task(subagent_type="oracle", ...)\` |
| plan agent | Complex multi-step with dependencies (5+ steps) | \`task(subagent_type="plan", ...)\` |
| explore agent | Need codebase patterns you don't have | \`task(subagent_type="explore", load_skills=[], run_in_background=true, ...)\` |
| librarian agent | External library docs, OSS examples | \`task(subagent_type="librarian", load_skills=[], run_in_background=true, ...)\` |
| oracle agent | Stuck on architecture/debugging after 2+ attempts | \`task(subagent_type="oracle", load_skills=[], ...)\` |
| plan agent | Complex multi-step with dependencies (5+ steps) | \`task(subagent_type="plan", load_skills=[], ...)\` |
| task category | Specialized work matching a category | \`task(category="...", load_skills=[...])\` |
<tool_usage_rules>

View File

@@ -38,9 +38,9 @@ You ARE the planner. Your job: create bulletproof work plans.
### Research Protocol
1. **Fire parallel background agents** for comprehensive context:
\`\`\`
task(agent="explore", prompt="Find existing patterns for [topic] in codebase", background=true)
task(agent="explore", prompt="Find test infrastructure and conventions", background=true)
task(agent="librarian", prompt="Find official docs and best practices for [technology]", background=true)
task(subagent_type="explore", load_skills=[], prompt="Find existing patterns for [topic] in codebase", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="Find test infrastructure and conventions", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="Find official docs and best practices for [technology]", run_in_background=true)
\`\`\`
2. **Wait for results** before planning - rushed plans fail
3. **Synthesize findings** into informed requirements

View File

@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readFileSync } from "node:fs"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./constants"
import { withTimeout } from "./with-timeout"
interface OpenCodeSessionMessage {
info?: { role?: string }
@@ -54,37 +55,43 @@ export async function detectCompletionInSessionMessages(
},
): Promise<boolean> {
try {
const response = await Promise.race([
const response = await withTimeout(
ctx.client.session.messages({
path: { id: options.sessionID },
query: { directory: options.directory },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("API timeout")), options.apiTimeoutMs),
),
])
options.apiTimeoutMs,
)
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 assistantMessages = (messages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const pattern = buildPromisePattern(options.promise)
const responseText = lastAssistant.parts
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("\n")
const recentAssistants = assistantMessages.slice(-3)
for (const assistant of recentAssistants) {
if (!assistant.parts) continue
return pattern.test(responseText)
const responseText = assistant.parts
.filter((p) => p.type === "text" || p.type === "reasoning")
.map((p) => p.text ?? "")
.join("\n")
if (pattern.test(responseText)) {
return true
}
}
return false
} catch (err) {
log(`[${HOOK_NAME}] Session messages check failed`, {
sessionID: options.sessionID,
error: String(err),
})
setTimeout(() => {
log(`[${HOOK_NAME}] Session messages check failed`, {
sessionID: options.sessionID,
error: String(err),
})
}, 0)
return false
}
}

View File

@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { getMessageDir } from "./message-storage-directory"
import { withTimeout } from "./with-timeout"
type MessageInfo = {
agent?: string
@@ -12,15 +13,18 @@ type MessageInfo = {
export async function injectContinuationPrompt(
ctx: PluginInput,
options: { sessionID: string; prompt: string; directory: string },
options: { sessionID: string; prompt: string; directory: string; apiTimeoutMs: number },
): Promise<void> {
let agent: string | undefined
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({
path: { id: options.sessionID },
})
const messagesResp = await withTimeout(
ctx.client.session.messages({
path: { id: options.sessionID },
}),
options.apiTimeoutMs,
)
const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i]?.info

View File

@@ -511,6 +511,38 @@ describe("ralph-loop", () => {
expect(messagesCalls[0].sessionID).toBe("session-123")
})
test("should detect completion promise in reasoning part via session messages API", async () => {
//#given - active loop with assistant reasoning containing completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
{
info: { role: "assistant" },
parts: [
{ type: "reasoning", text: "I am done now. <promise>REASONING_DONE</promise>" },
],
},
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", {
completionPromise: "REASONING_DONE",
})
//#when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
//#then - loop completed via API detection, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should handle multiple iterations correctly", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
@@ -596,13 +628,14 @@ describe("ralph-loop", () => {
expect(promptCalls.length).toBe(1)
})
test("should only check LAST assistant message for completion", async () => {
// given - multiple assistant messages, only first has completion promise
test("should check last 3 assistant messages for completion", async () => {
// given - multiple assistant messages, promise in recent (not last) assistant message
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on it." }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Nearly there... <promise>DONE</promise>" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "(extra output after promise)" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
@@ -614,35 +647,36 @@ describe("ralph-loop", () => {
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should continue (last message has no completion promise)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion only in LAST assistant message", async () => {
// given - last assistant message has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! <promise>DONE</promise>" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should complete (last message has completion promise)
// then - loop should complete (promise found within last 3 assistant messages)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
// given - promise appears in an assistant message older than last 3
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "More work 1" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "More work 2" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "More work 3" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should continue (promise is older than last 3 assistant messages)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should allow starting new loop while previous loop is active (different session)", async () => {
// given - active loop in session A
const hook = createRalphLoopHook(createMockPluginInput())
@@ -928,7 +962,7 @@ Original task: Build something`
const elapsed = Date.now() - startTime
// then - should complete quickly (not hang for 10s)
expect(elapsed).toBeLessThan(2000)
expect(elapsed).toBeLessThan(6000)
// then - loop should continue (API error = no completion detected)
expect(promptCalls.length).toBe(1)
expect(apiCallCount).toBeGreaterThan(0)

View File

@@ -132,6 +132,7 @@ export function createRalphLoopEventHandler(
sessionID,
prompt: buildContinuationPrompt(newState),
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {

View File

@@ -16,7 +16,7 @@ export interface RalphLoopHook {
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 3000 as const
const DEFAULT_API_TIMEOUT = 5000 as const
export function createRalphLoopHook(
ctx: PluginInput,

View File

@@ -0,0 +1,20 @@
export async function withTimeout<TData>(
promise: Promise<TData>,
timeoutMs: number,
): Promise<TData> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error("API timeout"))
}, timeoutMs)
})
try {
return await Promise.race([promise, timeoutPromise])
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
}

View File

@@ -2,6 +2,7 @@ export type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
| "assistant_prefill_unsupported"
| null
function getErrorMessage(error: unknown): string {
@@ -41,6 +42,13 @@ export function extractMessageIndex(error: unknown): number | null {
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (
message.includes("assistant message prefill") ||
message.includes("conversation must end with a user message")
) {
return "assistant_prefill_unsupported"
}
if (
message.includes("thinking") &&
(message.includes("first block") ||

View File

@@ -81,11 +81,13 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
tool_result_missing: "Tool Crash Recovery",
thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery",
"assistant_prefill_unsupported": "Prefill Unsupported",
}
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...",
"assistant_prefill_unsupported": "Prefill not supported; continuing without recovery.",
}
await ctx.client.tui
@@ -117,6 +119,8 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
} else if (errorType === "assistant_prefill_unsupported") {
success = true
}
return success

View File

@@ -129,6 +129,63 @@ describe("detectErrorType", () => {
})
})
describe("assistant_prefill_unsupported errors", () => {
it("should detect assistant message prefill error from direct message", () => {
//#given an error about assistant message prefill not being supported
const error = {
message: "This model does not support assistant message prefill. The conversation must end with a user message.",
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
it("should detect assistant message prefill error from nested error object", () => {
//#given an Anthropic API error with nested structure matching the real error format
const error = {
error: {
type: "invalid_request_error",
message: "This model does not support assistant message prefill. The conversation must end with a user message.",
},
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
it("should detect error with only 'conversation must end with a user message' fragment", () => {
//#given an error containing only the user message requirement
const error = {
message: "The conversation must end with a user message.",
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
it("should detect error with only 'assistant message prefill' fragment", () => {
//#given an error containing only the prefill mention
const error = {
message: "This model does not support assistant message prefill.",
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
})
describe("unrecognized errors", () => {
it("should return null for unrecognized error patterns", () => {
// given an unrelated error

View File

@@ -0,0 +1,763 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { BackgroundManager } from "../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import type { OhMyOpenCodeConfig } from "../config/schema"
import { TaskObjectSchema } from "../tools/task/types"
import type { TaskObject } from "../tools/task/types"
import { createTaskContinuationEnforcer } from "./task-continuation-enforcer"
type TimerCallback = (...args: any[]) => void
interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
restore: () => void
}
function createFakeTimers(): FakeTimers {
const originalNow = Date.now()
let clockNow = originalNow
let timerNow = 0
let nextId = 1
const timers = new Map<number, { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] }>()
const cleared = new Set<number>()
const original = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
dateNow: Date.now,
}
const normalizeDelay = (delay?: number) => {
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
return delay < 0 ? 0 : delay
}
const schedule = (callback: TimerCallback, delay: number | undefined, interval: number | null, args: any[]) => {
const id = nextId++
timers.set(id, {
id,
time: timerNow + normalizeDelay(delay),
interval,
callback,
args,
})
return id
}
const clear = (id: number | undefined) => {
if (typeof id !== "number") return
cleared.add(id)
timers.delete(id)
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval
globalThis.clearTimeout = ((id?: number) => {
clear(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: number) => {
clear(id)
}) as typeof clearInterval
Date.now = () => clockNow
const advanceBy = async (ms: number, advanceClock: boolean = false) => {
const clamped = Math.max(0, ms)
const target = timerNow + clamped
if (advanceClock) {
clockNow += clamped
}
while (true) {
let next: { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] } | undefined
for (const timer of timers.values()) {
if (timer.time <= target && (!next || timer.time < next.time)) {
next = timer
}
}
if (!next) break
timerNow = next.time
timers.delete(next.id)
next.callback(...next.args)
if (next.interval !== null && !cleared.has(next.id)) {
timers.set(next.id, {
id: next.id,
time: timerNow + next.interval,
interval: next.interval,
callback: next.callback,
args: next.args,
})
} else {
cleared.delete(next.id)
}
await Promise.resolve()
}
timerNow = target
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
globalThis.setInterval = original.setInterval
globalThis.clearInterval = original.clearInterval
Date.now = original.dateNow
}
return { advanceBy, restore }
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
describe("task-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
let toastCalls: Array<{ title: string; message: string }>
let fakeTimers: FakeTimers
let taskDir: string
interface MockMessage {
info: {
id: string
role: "user" | "assistant"
error?: { name: string; data?: { message: string } }
}
}
let mockMessages: MockMessage[] = []
function createMockPluginInput() {
return {
client: {
session: {
messages: async () => ({ data: mockMessages }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: {
showToast: async (opts: any) => {
toastCalls.push({
title: opts.body.title,
message: opts.body.message,
})
return {}
},
},
},
directory: "/tmp/test",
} as any
}
function createTempTaskDir(): string {
return mkdtempSync(join(tmpdir(), "omo-task-continuation-"))
}
function writeTaskFile(dir: string, task: TaskObject): void {
const parsed = TaskObjectSchema.safeParse(task)
expect(parsed.success).toBe(true)
if (!parsed.success) return
writeFileSync(join(dir, `${parsed.data.id}.json`), JSON.stringify(parsed.data), "utf-8")
}
function writeCorruptedTaskFile(dir: string, taskId: string): void {
writeFileSync(join(dir, `${taskId}.json`), "{ this is not valid json", "utf-8")
}
function createConfig(dir: string): Partial<OhMyOpenCodeConfig> {
return {
sisyphus: {
tasks: {
claude_code_compat: true,
storage_path: dir,
},
},
}
}
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
return {
getTasksByParentSession: () => (runningTasks ? [{ status: "running" }] : []),
} as any
}
beforeEach(() => {
fakeTimers = createFakeTimers()
_resetForTesting()
promptCalls = []
toastCalls = []
mockMessages = []
taskDir = createTempTaskDir()
})
afterEach(() => {
fakeTimers.restore()
_resetForTesting()
rmSync(taskDir, { recursive: true, force: true })
})
test("should inject continuation when idle with incomplete tasks on disk", async () => {
fakeTimers.restore()
// given - main session with incomplete tasks
const sessionID = "main-123"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
writeTaskFile(taskDir, {
id: "T-2",
subject: "Task 2",
description: "",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {
backgroundManager: new BackgroundManager(createMockPluginInput()),
})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// then - countdown toast shown
await wait(50)
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Task Continuation")
// then - after countdown, continuation injected
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TASK CONTINUATION")
}, { timeout: 15000 })
test("should NOT inject when all tasks are completed", async () => {
// given - session with all tasks completed
const sessionID = "main-456"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject when all tasks are deleted", async () => {
// given - session with all tasks deleted
const sessionID = "main-deleted"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "deleted",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject when no task files exist", async () => {
// given - empty task directory
const sessionID = "main-none"
setMainSession(sessionID)
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject when background tasks are running", async () => {
// given - session with incomplete tasks and running background tasks
const sessionID = "main-bg-running"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {
backgroundManager: createMockBackgroundManager(true),
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject for non-main session", async () => {
// given - main session set, different session goes idle
setMainSession("main-session")
const otherSession = "other-session"
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: otherSession } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should inject for background task session (subagent)", async () => {
fakeTimers.restore()
// given - main session set, background task session registered
setMainSession("main-session")
const bgTaskSession = "bg-task-session"
subagentSessions.add(bgTaskSession)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: bgTaskSession } } })
// then
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
}, { timeout: 15000 })
test("should cancel countdown on user message after grace period", async () => {
// given
const sessionID = "main-cancel"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// when - wait past grace period (500ms), then user sends message
await fakeTimers.advanceBy(600, true)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } },
},
})
// then
await fakeTimers.advanceBy(2500)
expect(promptCalls).toHaveLength(0)
})
test("should ignore user message within grace period", async () => {
fakeTimers.restore()
// given
const sessionID = "main-grace"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } },
},
})
// then - countdown should continue
await wait(2500)
expect(promptCalls).toHaveLength(1)
}, { timeout: 15000 })
test("should cancel countdown on assistant activity", async () => {
// given
const sessionID = "main-assistant"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
await hook.handler({
event: {
type: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } },
},
})
// then
await fakeTimers.advanceBy(3000)
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on tool execution", async () => {
// given
const sessionID = "main-tool"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
await hook.handler({ event: { type: "tool.execute.before", properties: { sessionID } } })
// then
await fakeTimers.advanceBy(3000)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection during recovery mode", async () => {
// given
const sessionID = "main-recovery"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
hook.markRecovering(sessionID)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should inject after recovery complete", async () => {
fakeTimers.restore()
// given
const sessionID = "main-recovery-done"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
hook.markRecovering(sessionID)
hook.markRecoveryComplete(sessionID)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// then
await wait(3000)
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
test("should cleanup on session deleted", async () => {
// given
const sessionID = "main-delete"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
await hook.handler({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should skip when last assistant message was aborted (API fallback)", async () => {
// given
const sessionID = "main-api-abort"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "aborted" } } } },
]
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should skip when abort detected via session.error event", async () => {
// given
const sessionID = "main-event-abort"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when - abort error event fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - session goes idle immediately after
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should handle corrupted task files gracefully (readJsonSafe returns null)", async () => {
fakeTimers.restore()
// given
const sessionID = "main-corrupt"
setMainSession(sessionID)
writeCorruptedTaskFile(taskDir, "T-corrupt")
writeTaskFile(taskDir, {
id: "T-ok",
subject: "Task OK",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await wait(2500)
// then
expect(promptCalls).toHaveLength(1)
}, { timeout: 15000 })
test("should NOT inject when isContinuationStopped returns true", async () => {
// given
const sessionID = "main-stopped"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {
isContinuationStopped: (id) => id === sessionID,
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should cancel all countdowns via cancelAllCountdowns", async () => {
// given
const sessionID = "main-cancel-all"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
hook.cancelAllCountdowns()
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
})

View File

@@ -0,0 +1,530 @@
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 { listTaskFiles, readJsonSafe, getTaskDir } from "../features/claude-tasks/storage"
import type { OhMyOpenCodeConfig } from "../config/schema"
import { TaskObjectSchema } from "../tools/task/types"
import type { TaskObject } from "../tools/task/types"
import { log } from "../shared/logger"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const HOOK_NAME = "task-continuation-enforcer"
const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
export interface TaskContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
}
export interface TaskContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
cancelAllCountdowns: () => void
}
interface SessionState {
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
countdownStartedAt?: number
abortDetectedAt?: number
}
const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TASK_CONTINUATION)}
Incomplete tasks remain in your task 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(tasks: TaskObject[]): number {
return tasks.filter(t => t.status !== "completed" && t.status !== "deleted").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"
}
function loadTasksFromDisk(config: Partial<OhMyOpenCodeConfig>): TaskObject[] {
const taskIds = listTaskFiles(config)
const taskDirectory = getTaskDir(config)
const tasks: TaskObject[] = []
for (const id of taskIds) {
const task = readJsonSafe<TaskObject>(join(taskDirectory, `${id}.json`), TaskObjectSchema)
if (task) tasks.push(task)
}
return tasks
}
export function createTaskContinuationEnforcer(
ctx: PluginInput,
config: Partial<OhMyOpenCodeConfig>,
options: TaskContinuationEnforcerOptions = {}
): TaskContinuationEnforcer {
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: "Task 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
}
const tasks = loadTasksFromDisk(config)
const freshIncompleteCount = getIncompleteCount(tasks)
if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Skipped injection: no incomplete tasks`, { 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 incompleteTasks = tasks.filter(t => t.status !== "completed" && t.status !== "deleted")
const taskList = incompleteTasks.map(t => `- [${t.status}] ${t.subject}`).join("\n")
const prompt = `${CONTINUATION_PROMPT}
[Status: ${tasks.length - freshIncompleteCount}/${tasks.length} completed, ${freshIncompleteCount} remaining]
Remaining tasks:
${taskList}`
try {
log(`[${HOOK_NAME}] Injecting continuation`, {
sessionID,
agent: agentName,
model,
incompleteCount: freshIncompleteCount,
})
await ctx.client.session.prompt({
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) })
}
const tasks = loadTasksFromDisk(config)
if (!tasks || tasks.length === 0) {
log(`[${HOOK_NAME}] No tasks`, { sessionID })
return
}
const incompleteCount = getIncompleteCount(tasks)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All tasks complete`, { sessionID, total: tasks.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, tasks.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,
}
}

View File

@@ -1,8 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import type { BackgroundTask } from "../../features/background-agent"
import { createUnstableAgentBabysitterHook } from "./index"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
const projectDir = process.cwd()
type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]