Merge branch 'dev' into fix/analyze-mode-load-skills-hint

This commit is contained in:
Taegeon Alan Go
2026-03-20 16:29:13 +09:00
committed by GitHub
1576 changed files with 175624 additions and 39345 deletions
+138 -50
View File
@@ -1,79 +1,167 @@
# HOOKS KNOWLEDGE BASE
# src/hooks/ — 48 Lifecycle Hooks
**Generated:** 2026-03-06
## OVERVIEW
31 lifecycle hooks intercepting/modifying agent behavior. Events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, onSummarize.
48 hooks across dedicated modules and standalone files. Three-tier composition: Core(39) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS
### Tier 1: Session Hooks (23) — `create-session-hooks.ts`
## STRUCTURE
```
hooks/
├── atlas/ # Main orchestration (773 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize
├── todo-continuation-enforcer.ts # Force TODO completion (489 lines)
├── ralph-loop/ # Self-referential dev loop
├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md
├── comment-checker/ # Prevents AI slop
├── atlas/ # Main orchestration (757 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize
├── anthropic-effort/ # Reasoning effort level adjustment
├── anthropic-image-context/ # Image context handling for Anthropic
├── auto-slash-command/ # Detects /command patterns
├── rules-injector/ # Conditional rules
├── auto-update-checker/ # Plugin update check
├── background-notification/ # OS notification
├── beast-mode-system/ # Beast mode system prompt injection
├── category-skill-reminder/ # Reminds of category skills
├── claude-code-hooks/ # settings.json compat layer
├── comment-checker/ # Prevents AI slop
├── compaction-context-injector/ # Injects context on compaction
├── compaction-todo-preserver/ # Preserves todos through compaction
├── delegate-task-retry/ # Retries failed delegations
├── directory-agents-injector/ # Auto-injects AGENTS.md
├── directory-readme-injector/ # Auto-injects README.md
├── edit-error-recovery/ # Recovers from failures
├── thinking-block-validator/ # Ensures valid <thinking>
├── context-window-monitor.ts # Reminds of headroom
├── session-recovery/ # Auto-recovers from crashes
├── think-mode/ # Dynamic thinking budget
├── keyword-detector/ # ultrawork/search/analyze modes
├── background-notification/ # OS notification
├── prometheus-md-only/ # Planner read-only mode
├── agent-usage-reminder/ # Specialized agent hints
├── auto-update-checker/ # Plugin update check
├── tool-output-truncator.ts # Prevents context bloat
├── compaction-context-injector/ # Injects context on compaction
├── delegate-task-retry/ # Retries failed delegations
├── hashline-edit-diff-enhancer/ # Enhanced diff output for hashline edits
├── hashline-read-enhancer/ # Adds LINE#ID hashes to Read output
├── interactive-bash-session/ # Tmux session management
├── json-error-recovery/ # JSON parse error correction
├── keyword-detector/ # ultrawork/search/analyze modes
├── model-fallback/ # Provider-level model fallback
├── no-hephaestus-non-gpt/ # Block Hephaestus from non-GPT
├── no-sisyphus-gpt/ # Block Sisyphus from GPT
├── non-interactive-env/ # Non-TTY environment handling
├── prometheus-md-only/ # Planner read-only mode
├── question-label-truncator/ # Auto-truncates question labels
├── ralph-loop/ # Self-referential dev loop
├── read-image-resizer/ # Resize images for context efficiency
├── rules-injector/ # Conditional rules
├── runtime-fallback/ # Auto-switch models on API errors
├── session-recovery/ # Auto-recovers from crashes
├── sisyphus-junior-notepad/ # Sisyphus Junior notepad
├── start-work/ # Sisyphus work session starter
├── stop-continuation-guard/ # Guards stop continuation
├── task-reminder/ # Task system usage reminders
├── task-resume-info/ # Resume info for cancelled tasks
├── question-label-truncator/ # Auto-truncates question labels >30 chars
├── tasks-todowrite-disabler/ # Disable TodoWrite when task system active
├── think-mode/ # Dynamic thinking budget
├── thinking-block-validator/ # Ensures valid <thinking>
├── todo-continuation-enforcer/ # Force TODO completion
├── unstable-agent-babysitter/ # Monitor unstable agent behavior
├── write-existing-file-guard/ # Require Read before Write
└── index.ts # Hook aggregation + registration
```
## HOOK EVENTS
| Hook | Event | Purpose |
|------|-------|---------|
| contextWindowMonitor | session.idle | Track context window usage |
| preemptiveCompaction | session.idle | Trigger compaction before limit |
| sessionRecovery | session.error | Auto-retry on recoverable errors |
| sessionNotification | session.idle | OS notifications on completion |
| thinkMode | chat.params | Model variant switching (extended thinking) |
| anthropicContextWindowLimitRecovery | session.error | Multi-strategy context recovery (truncation, compaction) |
| autoUpdateChecker | session.created | Check npm for plugin updates |
| agentUsageReminder | chat.message | Remind about available agents |
| nonInteractiveEnv | chat.message | Adjust behavior for `run` command |
| interactiveBashSession | tool.execute | Tmux session for interactive tools |
| ralphLoop | event | Self-referential dev loop (boulder continuation) |
| editErrorRecovery | tool.execute.after | Retry failed file edits |
| delegateTaskRetry | tool.execute.after | Retry failed task delegations |
| startWork | chat.message | `/start-work` command handler |
| prometheusMdOnly | tool.execute.before | Enforce .md-only writes for Prometheus |
| sisyphusJuniorNotepad | chat.message | Notepad injection for subagents |
| questionLabelTruncator | tool.execute.before | Truncate long question labels |
| taskResumeInfo | chat.message | Inject task context on resume |
| anthropicEffort | chat.params | Adjust reasoning effort level |
| modelFallback | chat.params | Provider-level model fallback on errors |
| noSisyphusGpt | chat.message | Block Sisyphus from using GPT models (toast warning) |
| noHephaestusNonGpt | chat.message | Block Hephaestus from using non-GPT models |
| runtimeFallback | event | Auto-switch models on API provider errors |
| Event | Timing | Can Block | Use Case |
|-------|--------|-----------|----------|
| PreToolUse | Before tool | Yes | Validate/modify inputs |
| PostToolUse | After tool | No | Append warnings, truncate |
| UserPromptSubmit | On prompt | Yes | Keyword detection |
| Stop | Session idle | No | Auto-continue |
| onSummarize | Compaction | No | Preserve state |
### Tier 2: Tool Guard Hooks (12) — `create-tool-guard-hooks.ts`
## EXECUTION ORDER
| Hook | Event | Purpose |
|------|-------|---------|
| commentChecker | tool.execute.after | Block AI-generated comment patterns |
| toolOutputTruncator | tool.execute.after | Truncate oversized tool output |
| directoryAgentsInjector | tool.execute.before | Inject dir AGENTS.md into context |
| directoryReadmeInjector | tool.execute.before | Inject dir README.md into context |
| emptyTaskResponseDetector | tool.execute.after | Detect empty task responses |
| rulesInjector | tool.execute.before | Conditional rules injection (AGENTS.md, config) |
| tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active |
| writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files |
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
**chat.message**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork → ralphLoop
### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts`
**tool.execute.before**: claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → rulesInjector
| Hook | Event | Purpose |
|------|-------|---------|
| claudeCodeHooks | messages.transform | Claude Code settings.json compatibility |
| keywordDetector | messages.transform | Detect ultrawork/search/analyze modes |
| contextInjectorMessagesTransform | messages.transform | Inject AGENTS.md/README.md into context |
| thinkingBlockValidator | messages.transform | Validate thinking block structure |
**tool.execute.after**: editErrorRecovery → delegateTaskRetry → commentChecker → toolOutputTruncator → claudeCodeHooks
### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts`
## HOW TO ADD
| Hook | Event | Purpose |
|------|-------|---------|
| stopContinuationGuard | chat.message | `/stop-continuation` command handler |
| compactionContextInjector | session.compacted | Re-inject context after compaction |
| compactionTodoPreserver | session.compacted | Preserve todos through compaction |
| todoContinuationEnforcer | session.idle | **Boulder**: force continuation on incomplete todos |
| unstableAgentBabysitter | session.idle | Monitor unstable agent behavior |
| backgroundNotificationHook | event | Background task completion notifications |
| atlasHook | event | Master orchestrator for boulder/background sessions |
1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)`
2. Add hook name to `HookNameSchema` in `src/config/schema.ts`
3. Register in `src/index.ts`:
```typescript
const myHook = isHookEnabled("my-hook") ? createMyHook(ctx) : null
```
### Tier 5: Skill Hooks (2) — `create-skill-hooks.ts`
## PATTERNS
| Hook | Event | Purpose |
|------|-------|---------|
| categorySkillReminder | chat.message | Remind about category+skill delegation |
| autoSlashCommand | chat.message | Auto-detect `/command` in user input |
- **Session-scoped state**: `Map<sessionID, Set<string>>`
- **Conditional execution**: Check `input.tool` before processing
- **Output modification**: `output.output += "\n${REMINDER}"`
## KEY HOOKS (COMPLEX)
## ANTI-PATTERNS
### anthropic-context-window-limit-recovery (31 files, ~2232 LOC)
Multi-strategy recovery when hitting context limits. Strategies: truncation, compaction, summarization.
- **Blocking non-critical**: Use PostToolUse warnings instead
- **Heavy computation**: Keep PreToolUse light
- **Redundant injection**: Track injected files
### atlas (17 files, ~1976 LOC)
Master orchestrator for boulder sessions. Decision gates: session type → abort check → failure count → background tasks → agent match → plan completeness → cooldown (5s). Injects continuation prompts on session.idle.
### ralph-loop (14 files, ~1687 LOC)
Self-referential dev loop via `/ralph-loop` command. State persisted in `.sisyphus/ralph-loop.local.md`. Detects `<promise>DONE</promise>` in AI output. Max 100 iterations default.
### todo-continuation-enforcer (13 files, ~2061 LOC)
"Boulder" mechanism. Forces agent to continue when todos remain incomplete. 2s countdown toast → continuation injection. Exponential backoff: 30s base, ×2 per failure, max 5 consecutive failures then 5min pause.
### keyword-detector (~1665 LOC)
Detects modes from user input: ultrawork, search, analyze, prove-yourself. Injects mode-specific system prompts.
### rules-injector (19 files, ~1604 LOC)
Conditional rules injection from AGENTS.md, config, skill rules. Evaluates conditions to determine which rules apply.
## STANDALONE HOOKS (in src/hooks/ root)
| File | Purpose |
|------|---------|
| context-window-monitor.ts | Track context window percentage |
| preemptive-compaction.ts | Trigger compaction before hard limit |
| tool-output-truncator.ts | Truncate tool output by token count |
| session-notification.ts + 4 helpers | OS notification on session completion |
| empty-task-response-detector.ts | Detect empty/failed task responses |
| session-todo-status.ts | Todo completion status tracking |
## HOW TO ADD A HOOK
1. Create `src/hooks/{name}/index.ts` with `createXXXHook(deps)` factory
2. Register in appropriate tier file (`src/plugin/hooks/create-{tier}-hooks.ts`)
3. Add hook name to `src/config/schema/hooks.ts` HookNameSchema
4. Hook receives `(event, ctx)` — return value depends on event type
+7 -9
View File
@@ -1,7 +1,5 @@
import { join } from "node:path";
import { getOpenCodeStorageDir } from "../../shared/data-path";
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
import { OPENCODE_STORAGE } from "../../shared";
export const AGENT_USAGE_REMINDER_STORAGE = join(
OPENCODE_STORAGE,
"agent-usage-reminder",
@@ -24,7 +22,7 @@ export const TARGET_TOOLS = new Set([
export const AGENT_TOOLS = new Set([
"task",
"call_omo_agent",
"delegate_task",
"task",
]);
export const REMINDER_MESSAGE = `
@@ -32,13 +30,13 @@ export const REMINDER_MESSAGE = `
You called a search/fetch tool directly without leveraging specialized agents.
RECOMMENDED: Use delegate_task with explore/librarian agents for better results:
RECOMMENDED: Use task with explore/librarian agents for better results:
\`\`\`
// Parallel exploration - fire multiple agents simultaneously
delegate_task(agent="explore", prompt="Find all files matching pattern X")
delegate_task(agent="explore", prompt="Search for implementation of Y")
delegate_task(agent="librarian", prompt="Lookup documentation for Z")
task(agent="explore", prompt="Find all files matching pattern X")
task(agent="explore", prompt="Search for implementation of Y")
task(agent="librarian", prompt="Lookup documentation for Z")
// Then continue your work while they run in background
// System will notify you when each completes
@@ -50,5 +48,5 @@ WHY:
- Specialized agents have domain expertise
- Reduces context window usage in main session
ALWAYS prefer: Multiple parallel delegate_task calls > Direct tool calls
ALWAYS prefer: Multiple parallel task calls > Direct tool calls
`;
+134
View File
@@ -0,0 +1,134 @@
import type { PluginInput } from "@opencode-ai/plugin";
import {
loadAgentUsageState,
saveAgentUsageState,
clearAgentUsageState,
} from "./storage";
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types";
import { getSessionAgent } from "../../features/claude-code-session-state";
import { getAgentConfigKey } from "../../shared/agent-display-names";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
/**
* Only orchestrator agents should receive usage reminders.
* Subagents (explore, librarian, oracle, etc.) are the targets of delegation,
* so reminding them to delegate to themselves is counterproductive.
*/
const ORCHESTRATOR_AGENTS = new Set([
"sisyphus",
"sisyphus-junior",
"atlas",
"hephaestus",
"prometheus",
]);
function isOrchestratorAgent(agentName: string): boolean {
return ORCHESTRATOR_AGENTS.has(getAgentConfigKey(agentName));
}
export function createAgentUsageReminderHook(_ctx: PluginInput) {
const sessionStates = new Map<string, AgentUsageState>();
function getOrCreateState(sessionID: string): AgentUsageState {
if (!sessionStates.has(sessionID)) {
const persisted = loadAgentUsageState(sessionID);
const state: AgentUsageState = persisted ?? {
sessionID,
agentUsed: false,
reminderCount: 0,
updatedAt: Date.now(),
};
sessionStates.set(sessionID, state);
}
return sessionStates.get(sessionID)!;
}
function markAgentUsed(sessionID: string): void {
const state = getOrCreateState(sessionID);
state.agentUsed = true;
state.updatedAt = Date.now();
saveAgentUsageState(state);
}
function resetState(sessionID: string): void {
sessionStates.delete(sessionID);
clearAgentUsageState(sessionID);
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
const { tool, sessionID } = input;
const agent = getSessionAgent(sessionID);
if (agent && !isOrchestratorAgent(agent)) {
return;
}
const toolLower = tool.toLowerCase();
if (AGENT_TOOLS.has(toolLower)) {
markAgentUsed(sessionID);
return;
}
if (!TARGET_TOOLS.has(toolLower)) {
return;
}
const state = getOrCreateState(sessionID);
if (state.agentUsed) {
return;
}
output.output += REMINDER_MESSAGE;
state.reminderCount++;
state.updatedAt = Date.now();
saveAgentUsageState(state);
};
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;
if (sessionInfo?.id) {
resetState(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
resetState(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}
+1 -109
View File
@@ -1,109 +1 @@
import type { PluginInput } from "@opencode-ai/plugin";
import {
loadAgentUsageState,
saveAgentUsageState,
clearAgentUsageState,
} from "./storage";
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
export function createAgentUsageReminderHook(_ctx: PluginInput) {
const sessionStates = new Map<string, AgentUsageState>();
function getOrCreateState(sessionID: string): AgentUsageState {
if (!sessionStates.has(sessionID)) {
const persisted = loadAgentUsageState(sessionID);
const state: AgentUsageState = persisted ?? {
sessionID,
agentUsed: false,
reminderCount: 0,
updatedAt: Date.now(),
};
sessionStates.set(sessionID, state);
}
return sessionStates.get(sessionID)!;
}
function markAgentUsed(sessionID: string): void {
const state = getOrCreateState(sessionID);
state.agentUsed = true;
state.updatedAt = Date.now();
saveAgentUsageState(state);
}
function resetState(sessionID: string): void {
sessionStates.delete(sessionID);
clearAgentUsageState(sessionID);
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
const { tool, sessionID } = input;
const toolLower = tool.toLowerCase();
if (AGENT_TOOLS.has(toolLower)) {
markAgentUsed(sessionID);
return;
}
if (!TARGET_TOOLS.has(toolLower)) {
return;
}
const state = getOrCreateState(sessionID);
if (state.agentUsed) {
return;
}
output.output += REMINDER_MESSAGE;
state.reminderCount++;
state.updatedAt = Date.now();
saveAgentUsageState(state);
};
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;
if (sessionInfo?.id) {
resetState(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
resetState(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}
export { createAgentUsageReminderHook } from "./hook";
@@ -0,0 +1,49 @@
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
**Generated:** 2026-03-06
## OVERVIEW
31 files (~2232 LOC). Most complex hook. Recovers from context window limit errors via multiple strategies applied in sequence.
## RECOVERY STRATEGIES (in priority order)
| Strategy | File | Mechanism |
|----------|------|-----------|
| **Empty content recovery** | `empty-content-recovery.ts` | Handle empty/null content blocks in messages |
| **Deduplication** | `deduplication-recovery.ts` | Remove duplicate tool results from context |
| **Target-token truncation** | `target-token-truncation.ts` | Truncate largest tool outputs to fit target ratio |
| **Aggressive truncation** | `aggressive-truncation-strategy.ts` | Last-resort truncation with minimal output preservation |
| **Summarize retry** | `summarize-retry-strategy.ts` | Compaction + summarization then retry |
## KEY FILES
| File | Purpose |
|------|---------|
| `recovery-hook.ts` | Main hook entry — `session.error` handler, strategy orchestration |
| `executor.ts` | Execute recovery strategies in sequence |
| `parser.ts` | Parse Anthropic token limit error messages |
| `state.ts` | `AutoCompactState` — per-session retry/truncation tracking |
| `types.ts` | `ParsedTokenLimitError`, `RetryState`, `TruncateState`, config constants |
| `storage.ts` | Persist tool results for later truncation |
| `tool-result-storage.ts` | Store/retrieve individual tool call results |
| `message-builder.ts` | Build retry messages after recovery |
## RETRY CONFIG
- Max attempts: 2
- Initial delay: 2s, backoff ×2, max 30s
- Max truncation attempts: 20
- Target token ratio: 0.5 (truncate to 50% of limit)
- Chars per token estimate: 4
## PRUNING SYSTEM
`pruning-*.ts` files handle intelligent output pruning:
- `pruning-deduplication.ts` — Remove duplicate content across tool results
- `pruning-tool-output-truncation.ts` — Truncate oversized tool outputs
- `pruning-types.ts` — Pruning-specific type definitions
## SDK VARIANTS
`empty-content-recovery-sdk.ts` and `tool-result-storage-sdk.ts` provide SDK-based implementations for OpenCode client interactions.
@@ -0,0 +1,87 @@
import type { AutoCompactState } from "./types"
import { TRUNCATE_CONFIG } from "./types"
import { truncateUntilTargetTokens } from "./storage"
import type { Client } from "./client"
import { clearSessionState } from "./state"
import { formatBytes } from "./message-builder"
import { log } from "../../shared/logger"
import { resolveInheritedPromptTools } from "../../shared"
export async function runAggressiveTruncationStrategy(params: {
sessionID: string
autoCompactState: AutoCompactState
client: Client
directory: string
truncateAttempt: number
currentTokens: number
maxTokens: number
}): Promise<{ handled: boolean; nextTruncateAttempt: number }> {
if (params.truncateAttempt >= TRUNCATE_CONFIG.maxTruncateAttempts) {
return { handled: false, nextTruncateAttempt: params.truncateAttempt }
}
log("[auto-compact] PHASE 2: aggressive truncation triggered", {
currentTokens: params.currentTokens,
maxTokens: params.maxTokens,
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
})
const aggressiveResult = await truncateUntilTargetTokens(
params.sessionID,
params.currentTokens,
params.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken,
params.client,
)
if (aggressiveResult.truncatedCount <= 0) {
return { handled: false, nextTruncateAttempt: params.truncateAttempt }
}
const nextTruncateAttempt = params.truncateAttempt + aggressiveResult.truncatedCount
const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ")
const statusMsg = aggressiveResult.sufficient
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`
await params.client.tui
.showToast({
body: {
title: aggressiveResult.sufficient ? "Truncation Complete" : "Partial Truncation",
message: `${statusMsg}: ${toolNames}`,
variant: aggressiveResult.sufficient ? "success" : "warning",
duration: 4000,
},
})
.catch(() => {})
log("[auto-compact] aggressive truncation completed", aggressiveResult)
if (aggressiveResult.sufficient) {
clearSessionState(params.autoCompactState, params.sessionID)
setTimeout(async () => {
try {
const inheritedTools = resolveInheritedPromptTools(params.sessionID)
await params.client.session.promptAsync({
path: { id: params.sessionID },
body: {
auto: true,
...(inheritedTools ? { tools: inheritedTools } : {}),
} as never,
query: { directory: params.directory },
})
} catch {}
}, 500)
return { handled: true, nextTruncateAttempt }
}
log("[auto-compact] truncation insufficient, falling through to summarize", {
sessionID: params.sessionID,
truncatedCount: aggressiveResult.truncatedCount,
sufficient: aggressiveResult.sufficient,
})
return { handled: false, nextTruncateAttempt }
}
@@ -0,0 +1,21 @@
import type { PluginInput } from "@opencode-ai/plugin"
export type Client = PluginInput["client"] & {
session: {
promptAsync: (opts: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
query: { directory: string }
}) => Promise<unknown>
}
tui: {
showToast: (opts: {
body: {
title: string
message: string
variant: string
duration: number
}
}) => Promise<unknown>
}
}
@@ -0,0 +1,77 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig } from "../../config"
import type { DeduplicationConfig } from "./pruning-deduplication"
import type { PruningState } from "./pruning-types"
import { executeDeduplication } from "./pruning-deduplication"
import { truncateToolOutputsByCallId } from "./pruning-tool-output-truncation"
import { log } from "../../shared/logger"
type OpencodeClient = PluginInput["client"]
function createPruningState(): PruningState {
return {
toolIdsToPrune: new Set<string>(),
currentTurn: 0,
fileOperations: new Map(),
toolSignatures: new Map(),
erroredTools: new Map(),
}
}
function isPromptTooLongError(parsed: ParsedTokenLimitError): boolean {
return !parsed.errorType.toLowerCase().includes("non-empty content")
}
function getDeduplicationPlan(
experimental?: ExperimentalConfig,
): { config: DeduplicationConfig; protectedTools: Set<string> } | null {
const pruningConfig = experimental?.dynamic_context_pruning
if (!pruningConfig?.enabled) return null
const deduplicationEnabled = pruningConfig.strategies?.deduplication?.enabled
if (deduplicationEnabled === false) return null
const protectedTools = new Set(pruningConfig.protected_tools ?? [])
return {
config: {
enabled: true,
protectedTools: pruningConfig.protected_tools ?? [],
},
protectedTools,
}
}
export async function attemptDeduplicationRecovery(
sessionID: string,
parsed: ParsedTokenLimitError,
experimental: ExperimentalConfig | undefined,
client?: OpencodeClient,
): Promise<void> {
if (!isPromptTooLongError(parsed)) return
const plan = getDeduplicationPlan(experimental)
if (!plan) return
const pruningState = createPruningState()
const prunedCount = await executeDeduplication(
sessionID,
pruningState,
plan.config,
plan.protectedTools,
client,
)
const { truncatedCount } = await truncateToolOutputsByCallId(
sessionID,
pruningState.toolIdsToPrune,
client,
)
if (prunedCount > 0 || truncatedCount > 0) {
log("[auto-compact] deduplication recovery applied", {
sessionID,
prunedCount,
truncatedCount,
})
}
}
@@ -0,0 +1,166 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false))
const mockInjectTextPart = mock(() => Promise.resolve(false))
mock.module("../session-recovery/storage/empty-text", () => ({
replaceEmptyTextPartsAsync: mockReplaceEmptyTextParts,
}))
mock.module("../session-recovery/storage/text-part-injector", () => ({
injectTextPartAsync: mockInjectTextPart,
}))
function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) {
return {
session: {
messages: mock(() => Promise.resolve({ data: messages })),
},
} as never
}
describe("fixEmptyMessagesWithSDK", () => {
beforeEach(() => {
mockReplaceEmptyTextParts.mockReset()
mockInjectTextPart.mockReset()
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
mockInjectTextPart.mockReturnValue(Promise.resolve(false))
})
it("returns fixed=false when no empty messages exist", async () => {
//#given
const client = createMockClient([
{ info: { id: "msg_1" }, parts: [{ type: "text", text: "Hello" }] },
])
//#when
const result = await fixEmptyMessagesWithSDK({
sessionID: "ses_1",
client,
placeholderText: "[recovered]",
})
//#then
expect(result.fixed).toBe(false)
expect(result.fixedMessageIds).toEqual([])
expect(result.scannedEmptyCount).toBe(0)
})
it("fixes empty message via replace when scanning all", async () => {
//#given
const client = createMockClient([
{ info: { id: "msg_1" }, parts: [{ type: "text", text: "" }] },
])
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
//#when
const result = await fixEmptyMessagesWithSDK({
sessionID: "ses_1",
client,
placeholderText: "[recovered]",
})
//#then
expect(result.fixed).toBe(true)
expect(result.fixedMessageIds).toContain("msg_1")
expect(result.scannedEmptyCount).toBe(1)
})
it("falls back to inject when replace fails", async () => {
//#given
const client = createMockClient([
{ info: { id: "msg_1" }, parts: [] },
])
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
mockInjectTextPart.mockReturnValue(Promise.resolve(true))
//#when
const result = await fixEmptyMessagesWithSDK({
sessionID: "ses_1",
client,
placeholderText: "[recovered]",
})
//#then
expect(result.fixed).toBe(true)
expect(result.fixedMessageIds).toContain("msg_1")
})
it("fixes target message by index when provided", async () => {
//#given
const client = createMockClient([
{ info: { id: "msg_0" }, parts: [{ type: "text", text: "ok" }] },
{ info: { id: "msg_1" }, parts: [] },
])
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
//#when
const result = await fixEmptyMessagesWithSDK({
sessionID: "ses_1",
client,
placeholderText: "[recovered]",
messageIndex: 1,
})
//#then
expect(result.fixed).toBe(true)
expect(result.fixedMessageIds).toContain("msg_1")
expect(result.scannedEmptyCount).toBe(0)
})
it("skips messages without info.id", async () => {
//#given
const client = createMockClient([
{ parts: [] },
{ info: {}, parts: [] },
])
//#when
const result = await fixEmptyMessagesWithSDK({
sessionID: "ses_1",
client,
placeholderText: "[recovered]",
})
//#then
expect(result.fixed).toBe(false)
expect(result.scannedEmptyCount).toBe(0)
})
it("treats thinking-only messages as empty", async () => {
//#given
const client = createMockClient([
{ info: { id: "msg_1" }, parts: [{ type: "thinking", text: "hmm" }] },
])
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
//#when
const result = await fixEmptyMessagesWithSDK({
sessionID: "ses_1",
client,
placeholderText: "[recovered]",
})
//#then
expect(result.fixed).toBe(true)
expect(result.fixedMessageIds).toContain("msg_1")
})
it("treats tool_use messages as non-empty", async () => {
//#given
const client = createMockClient([
{ info: { id: "msg_1" }, parts: [{ type: "tool_use" }] },
])
//#when
const result = await fixEmptyMessagesWithSDK({
sessionID: "ses_1",
client,
placeholderText: "[recovered]",
})
//#then
expect(result.fixed).toBe(false)
expect(result.scannedEmptyCount).toBe(0)
})
})
@@ -0,0 +1,191 @@
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
import type { Client } from "./client"
interface SDKPart {
id?: string
type?: string
text?: string
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKPart[]
}
const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
function messageHasContentFromSDK(message: SDKMessage): boolean {
const parts = message.parts
if (!parts || parts.length === 0) return false
for (const part of parts) {
const type = part.type
if (!type) continue
if (IGNORE_TYPES.has(type)) {
continue
}
if (type === "text") {
if (part.text?.trim()) return true
continue
}
if (TOOL_TYPES.has(type)) return true
return true
}
// Messages with only thinking/meta parts are treated as empty
// to align with file-based logic (messageHasContent)
return false
}
function getSdkMessages(response: unknown): SDKMessage[] {
if (typeof response !== "object" || response === null) return []
if (Array.isArray(response)) return response as SDKMessage[]
const record = response as Record<string, unknown>
const data = record["data"]
if (Array.isArray(data)) return data as SDKMessage[]
return Array.isArray(record) ? (record as SDKMessage[]) : []
}
async function findEmptyMessagesFromSDK(client: Client, sessionID: string): Promise<string[]> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = getSdkMessages(response)
const emptyIds: string[] = []
for (const message of messages) {
const messageID = message.info?.id
if (!messageID) continue
if (!messageHasContentFromSDK(message)) {
emptyIds.push(messageID)
}
}
return emptyIds
} catch {
return []
}
}
async function findEmptyMessageByIndexFromSDK(
client: Client,
sessionID: string,
targetIndex: number,
): Promise<string | null> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = getSdkMessages(response)
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]
const targetMessageId = targetMessage?.info?.id
if (!targetMessageId) continue
if (!messageHasContentFromSDK(targetMessage)) {
return targetMessageId
}
}
return null
} catch {
return null
}
}
export async function fixEmptyMessagesWithSDK(params: {
sessionID: string
client: Client
placeholderText: string
messageIndex?: number
}): Promise<{ fixed: boolean; fixedMessageIds: string[]; scannedEmptyCount: number }> {
let fixed = false
const fixedMessageIds: string[] = []
if (params.messageIndex !== undefined) {
const targetMessageId = await findEmptyMessageByIndexFromSDK(
params.client,
params.sessionID,
params.messageIndex,
)
if (targetMessageId) {
const replaced = await replaceEmptyTextPartsAsync(
params.client,
params.sessionID,
targetMessageId,
params.placeholderText,
)
if (replaced) {
fixed = true
fixedMessageIds.push(targetMessageId)
} else {
const injected = await injectTextPartAsync(
params.client,
params.sessionID,
targetMessageId,
params.placeholderText,
)
if (injected) {
fixed = true
fixedMessageIds.push(targetMessageId)
}
}
}
}
if (fixed) {
return { fixed, fixedMessageIds, scannedEmptyCount: 0 }
}
const emptyMessageIds = await findEmptyMessagesFromSDK(params.client, params.sessionID)
if (emptyMessageIds.length === 0) {
return { fixed: false, fixedMessageIds: [], scannedEmptyCount: 0 }
}
for (const messageID of emptyMessageIds) {
const replaced = await replaceEmptyTextPartsAsync(
params.client,
params.sessionID,
messageID,
params.placeholderText,
)
if (replaced) {
fixed = true
fixedMessageIds.push(messageID)
} else {
const injected = await injectTextPartAsync(
params.client,
params.sessionID,
messageID,
params.placeholderText,
)
if (injected) {
fixed = true
fixedMessageIds.push(messageID)
}
}
}
return { fixed, fixedMessageIds, scannedEmptyCount: emptyMessageIds.length }
}
@@ -0,0 +1,125 @@
import {
findEmptyMessages,
findEmptyMessageByIndex,
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import type { AutoCompactState } from "./types"
import type { Client } from "./client"
import { PLACEHOLDER_TEXT } from "./message-builder"
import { incrementEmptyContentAttempt } from "./state"
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
export async function fixEmptyMessages(params: {
sessionID: string
autoCompactState: AutoCompactState
client: Client
messageIndex?: number
}): Promise<boolean> {
incrementEmptyContentAttempt(params.autoCompactState, params.sessionID)
let fixed = false
const fixedMessageIds: string[] = []
if (isSqliteBackend()) {
const result = await fixEmptyMessagesWithSDK({
sessionID: params.sessionID,
client: params.client,
placeholderText: PLACEHOLDER_TEXT,
messageIndex: params.messageIndex,
})
if (!result.fixed && result.scannedEmptyCount === 0) {
await params.client.tui
.showToast({
body: {
title: "Empty Content Error",
message: "No empty messages found in storage. Cannot auto-recover.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return false
}
if (result.fixed) {
await params.client.tui
.showToast({
body: {
title: "Session Recovery",
message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
}
return result.fixed
}
if (params.messageIndex !== undefined) {
const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex)
if (targetMessageId) {
const replaced = replaceEmptyTextParts(targetMessageId, PLACEHOLDER_TEXT)
if (replaced) {
fixed = true
fixedMessageIds.push(targetMessageId)
} else {
const injected = injectTextPart(params.sessionID, targetMessageId, PLACEHOLDER_TEXT)
if (injected) {
fixed = true
fixedMessageIds.push(targetMessageId)
}
}
}
}
if (!fixed) {
const emptyMessageIds = findEmptyMessages(params.sessionID)
if (emptyMessageIds.length === 0) {
await params.client.tui
.showToast({
body: {
title: "Empty Content Error",
message: "No empty messages found in storage. Cannot auto-recover.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return false
}
for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)
if (replaced) {
fixed = true
fixedMessageIds.push(messageID)
} else {
const injected = injectTextPart(params.sessionID, messageID, PLACEHOLDER_TEXT)
if (injected) {
fixed = true
fixedMessageIds.push(messageID)
}
}
}
}
if (fixed) {
await params.client.tui
.showToast({
body: {
title: "Session Recovery",
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
}
return fixed
}
@@ -1,17 +1,91 @@
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types"
import * as storage from "./storage"
import * as recoveryStrategy from "./recovery-strategy"
import * as messagesReader from "../session-recovery/storage/messages-reader"
type TimerCallback = (...args: any[]) => void
interface FakeTimeouts {
advanceBy: (ms: number) => Promise<void>
restore: () => void
}
// Capture the real implementations at module load time, before any test can patch them.
// This ensures restore() always returns to the true originals regardless of test execution order.
const TRUE_ORIGINAL_SET_TIMEOUT = globalThis.setTimeout
const TRUE_ORIGINAL_CLEAR_TIMEOUT = globalThis.clearTimeout
function createFakeTimeouts(): FakeTimeouts {
let now = 0
let nextId = 1
const timers = new Map<number, { id: number; time: number; callback: TimerCallback; args: any[] }>()
const cleared = new Set<number>()
const normalizeDelay = (delay?: number) => {
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
return delay < 0 ? 0 : delay
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const id = nextId++
timers.set(id, {
id,
time: now + normalizeDelay(delay),
callback,
args,
})
return id as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: number) => {
if (typeof id !== "number") return
cleared.add(id)
timers.delete(id)
}) as typeof clearTimeout
const advanceBy = async (ms: number) => {
const target = now + Math.max(0, ms)
while (true) {
let next: { id: number; time: number; 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
now = next.time
timers.delete(next.id)
if (!cleared.has(next.id)) {
next.callback(...next.args)
}
cleared.delete(next.id)
await Promise.resolve()
}
now = target
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = TRUE_ORIGINAL_SET_TIMEOUT
globalThis.clearTimeout = TRUE_ORIGINAL_CLEAR_TIMEOUT
}
return { advanceBy, restore }
}
describe("executeCompact lock management", () => {
let autoCompactState: AutoCompactState
let mockClient: any
let fakeTimeouts: FakeTimeouts
const sessionID = "test-session-123"
const directory = "/test/dir"
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
const msg = { providerID: "anthropic", modelID: "claude-opus-4-6" }
beforeEach(() => {
// #given: Fresh state for each test
// given: Fresh state for each test
autoCompactState = {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
@@ -26,31 +100,37 @@ describe("executeCompact lock management", () => {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
prompt_async: mock(() => Promise.resolve()),
promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
}
fakeTimeouts = createFakeTimeouts()
})
afterEach(() => {
fakeTimeouts.restore()
})
test("clears lock on successful summarize completion", async () => {
// #given: Valid session with providerID/modelID
// given: Valid session with providerID/modelID
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction successfully
// when: Execute compaction successfully
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when summarize throws exception", async () => {
// #given: Summarize will fail
// given: Summarize will fail
mockClient.session.summarize = mock(() =>
Promise.reject(new Error("Network timeout")),
)
@@ -60,21 +140,21 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should still be cleared despite exception
// then: Lock should still be cleared despite exception
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("shows toast when lock already held", async () => {
// #given: Lock already held
// given: Lock already held
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction
// when: Try to execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown with warning message
// then: Toast should be shown with warning message
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
@@ -85,12 +165,13 @@ describe("executeCompact lock management", () => {
}),
)
// #then: compactionInProgress should still have the lock
// then: compactionInProgress should still have the lock
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock when fixEmptyMessages path executes", async () => {
// #given: Empty content error scenario
//#given - Empty content error scenario with no messages in storage
const readMessagesSpy = spyOn(messagesReader, "readMessages").mockReturnValue([])
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "non-empty content required",
messageIndex: 0,
@@ -98,16 +179,17 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction (fixEmptyMessages will be called)
//#when - Execute compaction (fixEmptyMessages will be called)
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
//#then - Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
readMessagesSpy.mockRestore()
})
test("clears lock when truncation is sufficient", async () => {
// #given: Aggressive truncation scenario with sufficient truncation
// This test verifies the early return path in aggressive truncation
//#given - Aggressive truncation scenario with no messages in storage
const readMessagesSpy = spyOn(messagesReader, "readMessages").mockReturnValue([])
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
@@ -119,7 +201,7 @@ describe("executeCompact lock management", () => {
aggressive_truncation: true,
}
// #when: Execute compaction with experimental flag
//#when - Execute compaction with experimental flag
await executeCompact(
sessionID,
msg,
@@ -129,30 +211,31 @@ describe("executeCompact lock management", () => {
experimental,
)
// #then: Lock should be cleared even on early return
//#then - Lock should be cleared even on early return
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
readMessagesSpy.mockRestore()
})
test("prevents concurrent compaction attempts", async () => {
// #given: Lock already held (simpler test)
// given: Lock already held (simpler test)
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction while lock is held
// when: Try to execute compaction while lock is held
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown
// then: Toast should be shown
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const blockedToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Compact In Progress",
)
expect(blockedToast).toBeDefined()
// #then: Lock should still be held (not cleared by blocked attempt)
// then: Lock should still be held (not cleared by blocked attempt)
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock after max recovery attempts exhausted", async () => {
// #given: All retry/revert attempts exhausted
// given: All retry/revert attempts exhausted
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
// Max out all attempts
@@ -169,22 +252,22 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Should show failure toast
// then: Should show failure toast
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const failureToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
)
expect(failureToast).toBeDefined()
// #then: Lock should still be cleared
// then: Lock should still be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when client.tui.showToast throws", async () => {
// #given: Toast will fail (this should never happen but testing robustness)
// given: Toast will fail (this should never happen but testing robustness)
mockClient.tui.showToast = mock(() =>
Promise.reject(new Error("Toast failed")),
)
@@ -194,16 +277,16 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared even if toast fails
// then: Lock should be cleared even if toast fails
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when prompt_async in continuation throws", async () => {
// #given: prompt_async will fail during continuation
mockClient.session.prompt_async = mock(() =>
test("clears lock when promptAsync in continuation throws", async () => {
// given: promptAsync will fail during continuation
mockClient.session.promptAsync = mock(() =>
Promise.reject(new Error("Prompt failed")),
)
autoCompactState.errorDataBySession.set(sessionID, {
@@ -212,94 +295,97 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600))
await fakeTimeouts.advanceBy(600)
// #then: Lock should be cleared
// then: Lock should be cleared
// The continuation happens in setTimeout, but lock is cleared in finally before that
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("falls through to summarize when truncation is insufficient", async () => {
// #given: Over token limit with truncation returning insufficient
// given: Over token limit with truncation returning insufficient
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
maxTokens: 200000,
})
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
success: true,
sufficient: false,
truncatedCount: 3,
totalBytesRemoved: 10000,
targetBytesToRemove: 50000,
truncatedTools: [
{ toolName: "Grep", originalSize: 5000 },
{ toolName: "Read", originalSize: 3000 },
{ toolName: "Bash", originalSize: 2000 },
],
})
const truncateSpy = spyOn(
recoveryStrategy,
"runAggressiveTruncationStrategy",
).mockImplementation(async (params) => ({
handled: false,
nextTruncateAttempt: params.truncateAttempt + 1,
}))
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Truncation was attempted
// then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled()
// #then: Summarize should be called (fall through from insufficient truncation)
// then: Summarize should be called (fall through from insufficient truncation)
expect(mockClient.session.summarize).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: sessionID },
body: { providerID: "anthropic", modelID: "claude-opus-4-5", auto: true },
body: { providerID: "anthropic", modelID: "claude-opus-4-6", auto: true },
}),
)
// #then: Lock should be cleared
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
truncateSpy.mockRestore()
})
test("does NOT call summarize when truncation is sufficient", async () => {
// #given: Over token limit with truncation returning sufficient
// given: Over token limit with truncation returning sufficient
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
maxTokens: 200000,
})
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
success: true,
sufficient: true,
truncatedCount: 5,
totalBytesRemoved: 60000,
targetBytesToRemove: 50000,
truncatedTools: [
{ toolName: "Grep", originalSize: 30000 },
{ toolName: "Read", originalSize: 30000 },
],
const truncateSpy = spyOn(
recoveryStrategy,
"runAggressiveTruncationStrategy",
).mockImplementation(async (params) => {
setTimeout(() => {
void params.client.session
.promptAsync({
path: { id: params.sessionID },
body: { auto: true } as never,
query: { directory: params.directory },
})
.catch(() => {})
}, 500)
return {
handled: true,
nextTruncateAttempt: params.truncateAttempt + 1,
}
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600))
await fakeTimeouts.advanceBy(600)
// #then: Truncation was attempted
// then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled()
// #then: Summarize should NOT be called (early return from sufficient truncation)
// then: Summarize should NOT be called (early return from sufficient truncation)
expect(mockClient.session.summarize).not.toHaveBeenCalled()
// #then: prompt_async should be called (Continue after successful truncation)
expect(mockClient.session.prompt_async).toHaveBeenCalled()
// then: promptAsync should be called (Continue after successful truncation)
expect(mockClient.session.promptAsync).toHaveBeenCalled()
// #then: Lock should be cleared
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
truncateSpy.mockRestore()
@@ -1,271 +1,30 @@
import type {
AutoCompactState,
RetryState,
TruncateState,
} from "./types";
import type { AutoCompactState } from "./types";
import type { OhMyOpenCodeConfig } from "../../config";
import type { ExperimentalConfig } from "../../config";
import { RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
import { TRUNCATE_CONFIG } from "./types";
import type { Client } from "./client";
import { getOrCreateTruncateState } from "./state";
import {
findLargestToolResult,
truncateToolResult,
truncateUntilTargetTokens,
} from "./storage";
import {
findEmptyMessages,
findEmptyMessageByIndex,
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage";
import { log } from "../../shared/logger";
runAggressiveTruncationStrategy,
runSummarizeRetryStrategy,
} from "./recovery-strategy";
const PLACEHOLDER_TEXT = "[user interrupted]";
type Client = {
session: {
messages: (opts: {
path: { id: string };
query?: { directory?: string };
}) => Promise<unknown>;
summarize: (opts: {
path: { id: string };
body: { providerID: string; modelID: string };
query: { directory: string };
}) => Promise<unknown>;
revert: (opts: {
path: { id: string };
body: { messageID: string; partID?: string };
query: { directory: string };
}) => Promise<unknown>;
prompt_async: (opts: {
path: { id: string };
body: { parts: Array<{ type: string; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
};
tui: {
showToast: (opts: {
body: {
title: string;
message: string;
variant: string;
duration: number;
};
}) => Promise<unknown>;
};
};
function getOrCreateRetryState(
autoCompactState: AutoCompactState,
sessionID: string,
): RetryState {
let state = autoCompactState.retryStateBySession.get(sessionID);
if (!state) {
state = { attempt: 0, lastAttemptTime: 0 };
autoCompactState.retryStateBySession.set(sessionID, state);
}
return state;
}
function getOrCreateTruncateState(
autoCompactState: AutoCompactState,
sessionID: string,
): TruncateState {
let state = autoCompactState.truncateStateBySession.get(sessionID);
if (!state) {
state = { truncateAttempt: 0 };
autoCompactState.truncateStateBySession.set(sessionID, state);
}
return state;
}
function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
const emptyMessageIds = findEmptyMessages(sessionID);
if (emptyMessageIds.length === 0) {
return 0;
}
let fixedCount = 0;
for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT);
if (replaced) {
fixedCount++;
} else {
const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT);
if (injected) {
fixedCount++;
}
}
}
if (fixedCount > 0) {
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
sessionID,
fixedCount,
totalEmpty: emptyMessageIds.length,
});
}
return fixedCount;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
export async function getLastAssistant(
sessionID: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string,
): Promise<Record<string, unknown> | null> {
try {
const resp = await (client as Client).session.messages({
path: { id: sessionID },
query: { directory },
});
const data = (resp as { data?: unknown[] }).data;
if (!Array.isArray(data)) return null;
const reversed = [...data].reverse();
const last = reversed.find((m) => {
const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "assistant";
});
if (!last) return null;
return (last as { info?: Record<string, unknown> }).info ?? null;
} catch {
return null;
}
}
function clearSessionState(
autoCompactState: AutoCompactState,
sessionID: string,
): void {
autoCompactState.pendingCompact.delete(sessionID);
autoCompactState.errorDataBySession.delete(sessionID);
autoCompactState.retryStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID);
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
autoCompactState.compactionInProgress.delete(sessionID);
}
function getOrCreateEmptyContentAttempt(
autoCompactState: AutoCompactState,
sessionID: string,
): number {
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0;
}
async function fixEmptyMessages(
sessionID: string,
autoCompactState: AutoCompactState,
client: Client,
messageIndex?: number,
): Promise<boolean> {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID);
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1);
let fixed = false;
const fixedMessageIds: string[] = [];
if (messageIndex !== undefined) {
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex);
if (targetMessageId) {
const replaced = replaceEmptyTextParts(
targetMessageId,
"[user interrupted]",
);
if (replaced) {
fixed = true;
fixedMessageIds.push(targetMessageId);
} else {
const injected = injectTextPart(
sessionID,
targetMessageId,
"[user interrupted]",
);
if (injected) {
fixed = true;
fixedMessageIds.push(targetMessageId);
}
}
}
}
if (!fixed) {
const emptyMessageIds = findEmptyMessages(sessionID);
if (emptyMessageIds.length === 0) {
await client.tui
.showToast({
body: {
title: "Empty Content Error",
message: "No empty messages found in storage. Cannot auto-recover.",
variant: "error",
duration: 5000,
},
})
.catch(() => {});
return false;
}
for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]");
if (replaced) {
fixed = true;
fixedMessageIds.push(messageID);
} else {
const injected = injectTextPart(
sessionID,
messageID,
"[user interrupted]",
);
if (injected) {
fixed = true;
fixedMessageIds.push(messageID);
}
}
}
}
if (fixed) {
await client.tui
.showToast({
body: {
title: "Session Recovery",
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
}
return fixed;
}
export { getLastAssistant } from "./message-builder";
export async function executeCompact(
sessionID: string,
msg: Record<string, unknown>,
autoCompactState: AutoCompactState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
client: Client,
directory: string,
experimental?: ExperimentalConfig,
pluginConfig: OhMyOpenCodeConfig,
_experimental?: ExperimentalConfig
): Promise<void> {
void _experimental
if (autoCompactState.compactionInProgress.has(sessionID)) {
await (client as Client).tui
await client.tui
.showToast({
body: {
title: "Compact In Progress",
@@ -294,191 +53,30 @@ export async function executeCompact(
isOverLimit &&
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
) {
log("[auto-compact] PHASE 2: aggressive truncation triggered", {
const result = await runAggressiveTruncationStrategy({
sessionID,
autoCompactState,
client: client,
directory,
truncateAttempt: truncateState.truncateAttempt,
currentTokens: errorData.currentTokens,
maxTokens: errorData.maxTokens,
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
});
const aggressiveResult = truncateUntilTargetTokens(
sessionID,
errorData.currentTokens,
errorData.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken,
);
if (aggressiveResult.truncatedCount > 0) {
truncateState.truncateAttempt += aggressiveResult.truncatedCount;
const toolNames = aggressiveResult.truncatedTools
.map((t) => t.toolName)
.join(", ");
const statusMsg = aggressiveResult.sufficient
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`;
await (client as Client).tui
.showToast({
body: {
title: aggressiveResult.sufficient
? "Truncation Complete"
: "Partial Truncation",
message: `${statusMsg}: ${toolNames}`,
variant: aggressiveResult.sufficient ? "success" : "warning",
duration: 4000,
},
})
.catch(() => {});
log("[auto-compact] aggressive truncation completed", aggressiveResult);
// Only return early if truncation was sufficient to get under token limit
// Otherwise fall through to PHASE 3 (Summarize)
if (aggressiveResult.sufficient) {
clearSessionState(autoCompactState, sessionID);
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { id: sessionID },
body: { auto: true } as never,
query: { directory },
});
} catch {}
}, 500);
return;
}
// Truncation was insufficient - fall through to Summarize
log("[auto-compact] truncation insufficient, falling through to summarize", {
sessionID,
truncatedCount: aggressiveResult.truncatedCount,
sufficient: aggressiveResult.sufficient,
});
}
truncateState.truncateAttempt = result.nextTruncateAttempt;
if (result.handled) return;
}
// PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
if (errorData?.errorType?.includes("non-empty content")) {
const attempt = getOrCreateEmptyContentAttempt(
autoCompactState,
sessionID,
);
if (attempt < 3) {
const fixed = await fixEmptyMessages(
sessionID,
autoCompactState,
client as Client,
errorData.messageIndex,
);
if (fixed) {
setTimeout(() => {
executeCompact(
sessionID,
msg,
autoCompactState,
client,
directory,
experimental,
);
}, 500);
return;
}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Recovery Failed",
message:
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
variant: "error",
duration: 10000,
},
})
.catch(() => {});
return;
}
}
if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0;
autoCompactState.truncateStateBySession.delete(sessionID);
}
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
retryState.attempt++;
retryState.lastAttemptTime = Date.now();
const providerID = msg.providerID as string | undefined;
const modelID = msg.modelID as string | undefined;
if (providerID && modelID) {
try {
sanitizeEmptyMessagesBeforeSummarize(sessionID);
await (client as Client).tui
.showToast({
body: {
title: "Auto Compact",
message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
const summarizeBody = { providerID, modelID, auto: true }
await (client as Client).session.summarize({
path: { id: sessionID },
body: summarizeBody as never,
query: { directory },
});
return;
} catch {
const delay =
RETRY_CONFIG.initialDelayMs *
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1);
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs);
setTimeout(() => {
executeCompact(
sessionID,
msg,
autoCompactState,
client,
directory,
experimental,
);
}, cappedDelay);
return;
}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Summarize Skipped",
message: "Missing providerID or modelID.",
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
}
}
clearSessionState(autoCompactState, sessionID);
await (client as Client).tui
.showToast({
body: {
title: "Auto Compact Failed",
message: "All recovery attempts failed. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {});
await runSummarizeRetryStrategy({
sessionID,
msg,
autoCompactState,
client: client,
directory,
pluginConfig,
errorType: errorData?.errorType,
messageIndex: errorData?.messageIndex,
})
} finally {
autoCompactState.compactionInProgress.delete(sessionID);
}
@@ -1,151 +1,8 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
experimental?: ExperimentalConfig
}
function createRecoveryState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
}
export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput, options?: AnthropicContextWindowLimitRecoveryOptions) {
const autoCompactState = createRecoveryState()
const experimental = options?.experimental
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id)
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
autoCompactState.compactionInProgress.delete(sessionInfo.id)
}
return
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
log("[auto-compact] session.error received", { sessionID, error: props?.error })
if (!sessionID) return
const parsed = parseAnthropicTokenLimitError(props?.error)
log("[auto-compact] parsed result", { parsed, hasError: !!props?.error })
if (parsed) {
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
if (autoCompactState.compactionInProgress.has(sessionID)) {
return
}
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
const providerID = parsed.providerID ?? (lastAssistant?.providerID as string | undefined)
const modelID = parsed.modelID ?? (lastAssistant?.modelID as string | undefined)
await ctx.client.tui
.showToast({
body: {
title: "Context Limit Hit",
message: "Truncating large tool outputs and recovering...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
setTimeout(() => {
executeCompact(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client,
ctx.directory,
experimental
)
}, 300)
}
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "assistant" && info.error) {
log("[auto-compact] message.updated with error", { sessionID, error: info.error })
const parsed = parseAnthropicTokenLimitError(info.error)
log("[auto-compact] message.updated parsed result", { parsed })
if (parsed) {
parsed.providerID = info.providerID as string | undefined
parsed.modelID = info.modelID as string | undefined
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
}
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (!autoCompactState.pendingCompact.has(sessionID)) return
const errorData = autoCompactState.errorDataBySession.get(sessionID)
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
if (lastAssistant?.summary === true) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
const providerID = errorData?.providerID ?? (lastAssistant?.providerID as string | undefined)
const modelID = errorData?.modelID ?? (lastAssistant?.modelID as string | undefined)
await ctx.client.tui
.showToast({
body: {
title: "Auto Compact",
message: "Token limit exceeded. Attempting recovery...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
await executeCompact(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client,
ctx.directory,
experimental
)
}
}
return {
event: eventHandler,
}
}
export { createAnthropicContextWindowLimitRecoveryHook } from "./recovery-hook"
export type { AnthropicContextWindowLimitRecoveryOptions } from "./recovery-hook"
export type { AutoCompactState, ParsedTokenLimitError, TruncateState } from "./types"
export { parseAnthropicTokenLimitError } from "./parser"
export { executeCompact, getLastAssistant } from "./executor"
export * from "./state"
export * from "./message-builder"
export * from "./recovery-strategy"
@@ -0,0 +1,177 @@
import { log } from "../../shared/logger"
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import {
findEmptyMessages,
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
import type { Client } from "./client"
export const PLACEHOLDER_TEXT = "[user interrupted]"
type OpencodeClient = PluginInput["client"]
interface SDKPart {
type?: string
text?: string
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKPart[]
}
const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
function messageHasContentFromSDK(message: SDKMessage): boolean {
const parts = message.parts
if (!parts || parts.length === 0) return false
for (const part of parts) {
const type = part.type
if (!type) continue
if (IGNORE_TYPES.has(type)) {
continue
}
if (type === "text") {
if (part.text?.trim()) return true
continue
}
if (TOOL_TYPES.has(type)) return true
return true
}
// Messages with only thinking/meta parts are treated as empty
// to align with file-based logic (messageHasContent)
return false
}
async function findEmptyMessageIdsFromSDK(
client: OpencodeClient,
sessionID: string,
): Promise<string[]> {
try {
const response = (await client.session.messages({
path: { id: sessionID },
})) as { data?: SDKMessage[] }
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
const emptyIds: string[] = []
for (const message of messages) {
const messageID = message.info?.id
if (!messageID) continue
if (!messageHasContentFromSDK(message)) {
emptyIds.push(messageID)
}
}
return emptyIds
} catch {
return []
}
}
export async function sanitizeEmptyMessagesBeforeSummarize(
sessionID: string,
client?: OpencodeClient,
): Promise<number> {
if (client && isSqliteBackend()) {
const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID)
if (emptyMessageIds.length === 0) {
return 0
}
let fixedCount = 0
for (const messageID of emptyMessageIds) {
const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
if (replaced) {
fixedCount++
} else {
const injected = await injectTextPartAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
if (injected) {
fixedCount++
}
}
}
if (fixedCount > 0) {
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
sessionID,
fixedCount,
totalEmpty: emptyMessageIds.length,
})
}
return fixedCount
}
const emptyMessageIds = findEmptyMessages(sessionID)
if (emptyMessageIds.length === 0) {
return 0
}
let fixedCount = 0
for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)
if (replaced) {
fixedCount++
} else {
const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)
if (injected) {
fixedCount++
}
}
}
if (fixedCount > 0) {
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
sessionID,
fixedCount,
totalEmpty: emptyMessageIds.length,
})
}
return fixedCount
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
export async function getLastAssistant(
sessionID: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string,
): Promise<Record<string, unknown> | null> {
try {
const resp = await (client as Client).session.messages({
path: { id: sessionID },
query: { directory },
})
const data = (resp as { data?: unknown[] }).data
if (!Array.isArray(data)) return null
const reversed = [...data].reverse()
const last = reversed.find((m) => {
const msg = m as Record<string, unknown>
const info = msg.info as Record<string, unknown> | undefined
return info?.role === "assistant"
})
if (!last) return null
return (last as { info?: Record<string, unknown> }).info ?? null
} catch {
return null
}
}
@@ -0,0 +1,40 @@
import { existsSync, readdirSync } from "node:fs"
import type { PluginInput } from "@opencode-ai/plugin"
import { getMessageDir } from "../../shared/opencode-message-dir"
import { normalizeSDKResponse } from "../../shared"
export { getMessageDir }
type OpencodeClient = PluginInput["client"]
interface SDKMessage {
info: { id: string }
parts: unknown[]
}
export async function getMessageIdsFromSDK(
client: OpencodeClient,
sessionID: string
): Promise<string[]> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
return messages.map(msg => msg.info.id)
} catch {
return []
}
}
export function getMessageIds(sessionID: string): string[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messageIds: string[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
const messageId = file.replace(".json", "")
messageIds.push(messageId)
}
return messageIds
}
@@ -0,0 +1,97 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { parseAnthropicTokenLimitError } from "./parser"
describe("parseAnthropicTokenLimitError", () => {
it("#given a standard token limit error string #when parsing #then extracts tokens", () => {
//#given
const error = "prompt is too long: 250000 tokens > 200000 maximum"
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).not.toBeNull()
expect(result!.currentTokens).toBe(250000)
expect(result!.maxTokens).toBe(200000)
})
it("#given a non-token-limit error #when parsing #then returns null", () => {
//#given
const error = { message: "internal server error" }
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).toBeNull()
})
it("#given null input #when parsing #then returns null", () => {
//#given
const error = null
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).toBeNull()
})
it("#given a proxy error with non-standard structure #when parsing #then returns null without crashing", () => {
//#given
const proxyError = {
data: [1, 2, 3],
error: "string-not-object",
message: "Failed to process error response",
}
//#when
const result = parseAnthropicTokenLimitError(proxyError)
//#then
expect(result).toBeNull()
})
it("#given a circular reference error #when parsing #then returns null without crashing", () => {
//#given
const circular: Record<string, unknown> = { message: "prompt is too long" }
circular.self = circular
//#when
const result = parseAnthropicTokenLimitError(circular)
//#then
expect(result).not.toBeNull()
})
it("#given an error where data.responseBody has invalid JSON #when parsing #then handles gracefully", () => {
//#given
const error = {
data: { responseBody: "not valid json {{{" },
message: "prompt is too long with 300000 tokens exceeds 200000",
}
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).not.toBeNull()
expect(result!.currentTokens).toBe(300000)
expect(result!.maxTokens).toBe(200000)
})
it("#given an error with data as a string (not object) #when parsing #then does not crash", () => {
//#given
const error = {
data: "some-string-data",
message: "token limit exceeded",
}
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).not.toBeNull()
})
})
@@ -70,10 +70,18 @@ function isTokenLimitError(text: string): boolean {
return false
}
const lower = text.toLowerCase()
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw))
}
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
try {
return parseAnthropicTokenLimitErrorUnsafe(err)
} catch {
return null
}
}
function parseAnthropicTokenLimitErrorUnsafe(err: unknown): ParsedTokenLimitError | null {
if (typeof err === "string") {
if (err.toLowerCase().includes("non-empty content")) {
return {
@@ -1,9 +1,14 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { PruningState, ToolCallSignature } from "./pruning-types"
import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { getMessageDir } from "../../shared/opencode-message-dir"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"]
export interface DeduplicationConfig {
enabled: boolean
@@ -43,20 +48,6 @@ function sortObject(obj: unknown): unknown {
return sorted
}
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 readMessages(sessionID: string): MessagePart[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return []
@@ -64,7 +55,7 @@ function readMessages(sessionID: string): MessagePart[] {
const messages: MessagePart[] = []
try {
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
const files = readdirSync(messageDir).filter((f: string) => f.endsWith(".json"))
for (const file of files) {
const content = readFileSync(join(messageDir, file), "utf-8")
const data = JSON.parse(content)
@@ -79,15 +70,29 @@ function readMessages(sessionID: string): MessagePart[] {
return messages
}
export function executeDeduplication(
async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise<MessagePart[]> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const rawMessages = normalizeSDKResponse(response, [] as Array<{ parts?: ToolPart[] }>, { preferResponseOnMissingData: true })
return rawMessages.filter((m) => m.parts) as MessagePart[]
} catch {
return []
}
}
export async function executeDeduplication(
sessionID: string,
state: PruningState,
config: DeduplicationConfig,
protectedTools: Set<string>
): number {
protectedTools: Set<string>,
client?: OpencodeClient,
): Promise<number> {
if (!config.enabled) return 0
const messages = readMessages(sessionID)
const messages = (client && isSqliteBackend())
? await readMessagesFromSDK(client, sessionID)
: readMessages(sessionID)
const signatures = new Map<string, ToolCallSignature[]>()
let currentTurn = 0
@@ -0,0 +1,142 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { getOpenCodeStorageDir } from "../../shared/data-path"
import { truncateToolResult } from "./storage"
import { truncateToolResultAsync } from "./tool-result-storage-sdk"
import { log } from "../../shared/logger"
import { getMessageDir } from "../../shared/opencode-message-dir"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"]
interface StoredToolPart {
type?: string
callID?: string
truncated?: boolean
state?: {
output?: string
}
}
interface SDKToolPart {
id: string
type: string
callID?: string
tool?: string
state?: { output?: string; time?: { compacted?: number } }
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKToolPart[]
}
function getPartStorage(): string {
return join(getOpenCodeStorageDir(), "part")
}
function getMessageIds(sessionID: string): string[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return []
const messageIds: string[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
messageIds.push(file.replace(".json", ""))
}
return messageIds
}
export async function truncateToolOutputsByCallId(
sessionID: string,
callIds: Set<string>,
client?: OpencodeClient,
): Promise<{ truncatedCount: number }> {
if (callIds.size === 0) return { truncatedCount: 0 }
if (client && isSqliteBackend()) {
return truncateToolOutputsByCallIdFromSDK(client, sessionID, callIds)
}
const messageIds = getMessageIds(sessionID)
if (messageIds.length === 0) return { truncatedCount: 0 }
let truncatedCount = 0
for (const messageID of messageIds) {
const partDir = join(getPartStorage(), messageID)
if (!existsSync(partDir)) continue
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
const partPath = join(partDir, file)
try {
const content = readFileSync(partPath, "utf-8")
const part = JSON.parse(content) as StoredToolPart
if (part.type !== "tool" || !part.callID) continue
if (!callIds.has(part.callID)) continue
if (!part.state?.output || part.truncated) continue
const result = truncateToolResult(partPath)
if (result.success) {
truncatedCount++
}
} catch {
continue
}
}
}
if (truncatedCount > 0) {
log("[auto-compact] pruned duplicate tool outputs", {
sessionID,
truncatedCount,
})
}
return { truncatedCount }
}
async function truncateToolOutputsByCallIdFromSDK(
client: OpencodeClient,
sessionID: string,
callIds: Set<string>,
): Promise<{ truncatedCount: number }> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
let truncatedCount = 0
for (const msg of messages) {
const messageID = msg.info?.id
if (!messageID || !msg.parts) continue
for (const part of msg.parts) {
if (part.type !== "tool" || !part.callID) continue
if (!callIds.has(part.callID)) continue
if (!part.state?.output || part.state?.time?.compacted) continue
const result = await truncateToolResultAsync(client, sessionID, messageID, part.id, part)
if (result.success) {
truncatedCount++
}
}
}
if (truncatedCount > 0) {
log("[auto-compact] pruned duplicate tool outputs (SDK)", {
sessionID,
truncatedCount,
})
}
return { truncatedCount }
} catch {
return { truncatedCount: 0 }
}
}
@@ -0,0 +1,127 @@
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../../config"
import * as originalDeduplicationRecovery from "./deduplication-recovery"
const attemptDeduplicationRecoveryMock = mock(async () => {})
mock.module("./deduplication-recovery", () => ({
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
}))
afterAll(() => {
mock.module("./deduplication-recovery", () => originalDeduplicationRecovery)
})
function createImmediateTimeouts(): () => void {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
callback(...args)
return 0 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((_: ReturnType<typeof setTimeout>) => {}) as typeof clearTimeout
return () => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
}
}
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
beforeEach(() => {
attemptDeduplicationRecoveryMock.mockClear()
})
test("calls deduplication recovery when compaction is already in progress", async () => {
//#given
const restoreTimeouts = createImmediateTimeouts()
const experimental = {
dynamic_context_pruning: {
enabled: true,
strategies: {
deduplication: { enabled: true },
},
},
} satisfies ExperimentalConfig
let resolveSummarize: (() => void) | null = null
const summarizePromise = new Promise<void>((resolve) => {
resolveSummarize = resolve
})
const mockClient = {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => summarizePromise),
revert: mock(() => Promise.resolve()),
promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
}
try {
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental })
// first error triggers compaction (setTimeout runs immediately due to mock)
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-96", error: "prompt is too long" },
},
})
//#when - second error while compaction is in progress
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-96", error: "prompt is too long" },
},
})
//#then - deduplication recovery was called for the second error
expect(attemptDeduplicationRecoveryMock).toHaveBeenCalledTimes(1)
expect(attemptDeduplicationRecoveryMock.mock.calls[0]![0]).toBe("session-96")
} finally {
if (resolveSummarize) resolveSummarize()
restoreTimeouts()
}
})
test("does not call deduplication when compaction is not in progress", async () => {
//#given
const mockClient = {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
}
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx)
//#when - single error (no compaction in progress)
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-no-dedup", error: "some other error" },
},
})
//#then
expect(attemptDeduplicationRecoveryMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,118 @@
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import * as originalExecutor from "./executor"
import * as originalParser from "./parser"
import * as originalLogger from "../../shared/logger"
const executeCompactMock = mock(async () => {})
const getLastAssistantMock = mock(async () => ({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
}))
const parseAnthropicTokenLimitErrorMock = mock(() => ({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
}))
mock.module("./executor", () => ({
executeCompact: executeCompactMock,
getLastAssistant: getLastAssistantMock,
}))
mock.module("./parser", () => ({
parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
afterAll(() => {
mock.module("./executor", () => originalExecutor)
mock.module("./parser", () => originalParser)
mock.module("../../shared/logger", () => originalLogger)
})
function createMockContext(): PluginInput {
return {
client: {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
},
directory: "/tmp",
} as PluginInput
}
function setupDelayedTimeoutMocks(): {
restore: () => void
getClearTimeoutCalls: () => Array<ReturnType<typeof setTimeout>>
} {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const clearTimeoutCalls: Array<ReturnType<typeof setTimeout>> = []
let timeoutCounter = 0
globalThis.setTimeout = ((_: () => void, _delay?: number) => {
timeoutCounter += 1
return timeoutCounter as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((timeoutID: ReturnType<typeof setTimeout>) => {
clearTimeoutCalls.push(timeoutID)
}) as typeof clearTimeout
return {
restore: () => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
},
getClearTimeoutCalls: () => clearTimeoutCalls,
}
}
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
beforeEach(() => {
executeCompactMock.mockClear()
getLastAssistantMock.mockClear()
parseAnthropicTokenLimitErrorMock.mockClear()
})
afterEach(() => {
mock.restore()
})
test("cancels pending timer when session.idle handles compaction first", async () => {
//#given
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
try {
//#when
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-race", error: "prompt is too long" },
},
})
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-race" },
},
})
//#then
expect(getClearTimeoutCalls()).toEqual([1 as ReturnType<typeof setTimeout>])
expect(executeCompactMock).toHaveBeenCalledTimes(1)
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race")
} finally {
restore()
}
})
})
@@ -0,0 +1,174 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { Client } from "./client"
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
experimental?: ExperimentalConfig
pluginConfig: OhMyOpenCodeConfig
}
function createRecoveryState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
}
export function createAnthropicContextWindowLimitRecoveryHook(
ctx: PluginInput,
options?: AnthropicContextWindowLimitRecoveryOptions,
) {
const autoCompactState = createRecoveryState()
const experimental = options?.experimental
const pluginConfig = options?.pluginConfig!
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const timeoutID = pendingCompactionTimeoutBySession.get(sessionInfo.id)
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
pendingCompactionTimeoutBySession.delete(sessionInfo.id)
}
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id)
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
autoCompactState.compactionInProgress.delete(sessionInfo.id)
}
return
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
log("[auto-compact] session.error received", { sessionID, error: props?.error })
if (!sessionID) return
const parsed = parseAnthropicTokenLimitError(props?.error)
log("[auto-compact] parsed result", { parsed, hasError: !!props?.error })
if (parsed) {
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
if (autoCompactState.compactionInProgress.has(sessionID)) {
await attemptDeduplicationRecovery(sessionID, parsed, experimental, ctx.client)
return
}
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
const providerID = parsed.providerID ?? (lastAssistant?.providerID as string | undefined)
const modelID = parsed.modelID ?? (lastAssistant?.modelID as string | undefined)
await ctx.client.tui
.showToast({
body: {
title: "Context Limit Hit",
message: "Truncating large tool outputs and recovering...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
const timeoutID = setTimeout(() => {
pendingCompactionTimeoutBySession.delete(sessionID)
executeCompact(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client as Client,
ctx.directory,
pluginConfig,
experimental,
)
}, 300)
pendingCompactionTimeoutBySession.set(sessionID, timeoutID)
}
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "assistant" && info.error) {
log("[auto-compact] message.updated with error", { sessionID, error: info.error })
const parsed = parseAnthropicTokenLimitError(info.error)
log("[auto-compact] message.updated parsed result", { parsed })
if (parsed) {
parsed.providerID = info.providerID as string | undefined
parsed.modelID = info.modelID as string | undefined
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
}
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (!autoCompactState.pendingCompact.has(sessionID)) return
const timeoutID = pendingCompactionTimeoutBySession.get(sessionID)
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
pendingCompactionTimeoutBySession.delete(sessionID)
}
const errorData = autoCompactState.errorDataBySession.get(sessionID)
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
if (lastAssistant?.summary === true) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
const providerID = errorData?.providerID ?? (lastAssistant?.providerID as string | undefined)
const modelID = errorData?.modelID ?? (lastAssistant?.modelID as string | undefined)
await ctx.client.tui
.showToast({
body: {
title: "Auto Compact",
message: "Token limit exceeded. Attempting recovery...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
await executeCompact(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client as Client,
ctx.directory,
pluginConfig,
experimental,
)
}
}
return {
event: eventHandler,
}
}
@@ -0,0 +1,2 @@
export { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy"
export { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
@@ -0,0 +1,53 @@
import type { AutoCompactState, RetryState, TruncateState } from "./types"
export function getOrCreateRetryState(
autoCompactState: AutoCompactState,
sessionID: string,
): RetryState {
let state = autoCompactState.retryStateBySession.get(sessionID)
if (!state) {
state = { attempt: 0, lastAttemptTime: 0, firstAttemptTime: 0 }
autoCompactState.retryStateBySession.set(sessionID, state)
}
return state
}
export function getOrCreateTruncateState(
autoCompactState: AutoCompactState,
sessionID: string,
): TruncateState {
let state = autoCompactState.truncateStateBySession.get(sessionID)
if (!state) {
state = { truncateAttempt: 0 }
autoCompactState.truncateStateBySession.set(sessionID, state)
}
return state
}
export function clearSessionState(
autoCompactState: AutoCompactState,
sessionID: string,
): void {
autoCompactState.pendingCompact.delete(sessionID)
autoCompactState.errorDataBySession.delete(sessionID)
autoCompactState.retryStateBySession.delete(sessionID)
autoCompactState.truncateStateBySession.delete(sessionID)
autoCompactState.emptyContentAttemptBySession.delete(sessionID)
autoCompactState.compactionInProgress.delete(sessionID)
}
export function getEmptyContentAttempt(
autoCompactState: AutoCompactState,
sessionID: string,
): number {
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0
}
export function incrementEmptyContentAttempt(
autoCompactState: AutoCompactState,
sessionID: string,
): number {
const attempt = getEmptyContentAttempt(autoCompactState, sessionID)
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
return attempt
}
@@ -0,0 +1,6 @@
import { MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
export { MESSAGE_STORAGE as MESSAGE_STORAGE_DIR, PART_STORAGE as PART_STORAGE_DIR }
export const TRUNCATION_MESSAGE =
"[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]"
@@ -1,4 +1,4 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import { truncateUntilTargetTokens } from "./storage"
import * as storage from "./storage"
@@ -11,6 +11,10 @@ mock.module("./storage", () => {
}
})
afterAll(() => {
mock.module("./storage", () => storage)
})
describe("truncateUntilTargetTokens", () => {
const sessionID = "test-session"
@@ -21,10 +25,10 @@ describe("truncateUntilTargetTokens", () => {
truncateToolResult.mockReset()
})
test("truncates only until target is reached", () => {
test("truncates only until target is reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// #given: Two tool results, each 1000 chars. Target reduction is 500 chars.
// given: Two tool results, each 1000 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 1000 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 1000 },
@@ -37,11 +41,11 @@ describe("truncateUntilTargetTokens", () => {
originalSize: 1000
}))
// #when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// charsPerToken=1 for simplicity in test
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// #then: Should only truncate the first tool
// then: Should only truncate the first tool
expect(result.truncatedCount).toBe(1)
expect(truncateToolResult).toHaveBeenCalledTimes(1)
expect(truncateToolResult).toHaveBeenCalledWith("path1")
@@ -49,10 +53,10 @@ describe("truncateUntilTargetTokens", () => {
expect(result.sufficient).toBe(true)
})
test("truncates all if target not reached", () => {
test("truncates all if target not reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// #given: Two tool results, each 100 chars. Target reduction is 500 chars.
// given: Two tool results, each 100 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 100 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 100 },
@@ -65,10 +69,10 @@ describe("truncateUntilTargetTokens", () => {
originalSize: 100
}))
// #when: reduce 500 chars
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// when: reduce 500 chars
const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// #then: Should truncate both
// then: Should truncate both
expect(result.truncatedCount).toBe(2)
expect(truncateToolResult).toHaveBeenCalledTimes(2)
expect(result.totalBytesRemoved).toBe(200)
@@ -1,250 +1,18 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { getOpenCodeStorageDir } from "../../shared/data-path"
export type { AggressiveTruncateResult, ToolResultInfo } from "./tool-part-types"
const OPENCODE_STORAGE = getOpenCodeStorageDir()
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
export {
countTruncatedResults,
findLargestToolResult,
findToolResultsBySize,
getTotalToolOutputSize,
truncateToolResult,
} from "./tool-result-storage"
const TRUNCATION_MESSAGE =
"[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]"
export {
countTruncatedResultsFromSDK,
findToolResultsBySizeFromSDK,
getTotalToolOutputSizeFromSDK,
truncateToolResultAsync,
} from "./tool-result-storage-sdk"
interface StoredToolPart {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: {
status: "pending" | "running" | "completed" | "error"
input: Record<string, unknown>
output?: string
error?: string
time?: {
start: number
end?: number
compacted?: number
}
}
truncated?: boolean
originalSize?: number
}
export interface ToolResultInfo {
partPath: string
partId: string
messageID: string
toolName: string
outputSize: number
}
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 ""
}
function getMessageIds(sessionID: string): string[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messageIds: string[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
const messageId = file.replace(".json", "")
messageIds.push(messageId)
}
return messageIds
}
export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
const messageIds = getMessageIds(sessionID)
const results: ToolResultInfo[] = []
for (const messageID of messageIds) {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) continue
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const partPath = join(partDir, file)
const content = readFileSync(partPath, "utf-8")
const part = JSON.parse(content) as StoredToolPart
if (part.type === "tool" && part.state?.output && !part.truncated) {
results.push({
partPath,
partId: part.id,
messageID,
toolName: part.tool,
outputSize: part.state.output.length,
})
}
} catch {
continue
}
}
}
return results.sort((a, b) => b.outputSize - a.outputSize)
}
export function findLargestToolResult(sessionID: string): ToolResultInfo | null {
const results = findToolResultsBySize(sessionID)
return results.length > 0 ? results[0] : null
}
export function truncateToolResult(partPath: string): {
success: boolean
toolName?: string
originalSize?: number
} {
try {
const content = readFileSync(partPath, "utf-8")
const part = JSON.parse(content) as StoredToolPart
if (!part.state?.output) {
return { success: false }
}
const originalSize = part.state.output.length
const toolName = part.tool
part.truncated = true
part.originalSize = originalSize
part.state.output = TRUNCATION_MESSAGE
if (!part.state.time) {
part.state.time = { start: Date.now() }
}
part.state.time.compacted = Date.now()
writeFileSync(partPath, JSON.stringify(part, null, 2))
return { success: true, toolName, originalSize }
} catch {
return { success: false }
}
}
export function getTotalToolOutputSize(sessionID: string): number {
const results = findToolResultsBySize(sessionID)
return results.reduce((sum, r) => sum + r.outputSize, 0)
}
export function countTruncatedResults(sessionID: string): number {
const messageIds = getMessageIds(sessionID)
let count = 0
for (const messageID of messageIds) {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) continue
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
const part = JSON.parse(content)
if (part.truncated === true) {
count++
}
} catch {
continue
}
}
}
return count
}
export interface AggressiveTruncateResult {
success: boolean
sufficient: boolean
truncatedCount: number
totalBytesRemoved: number
targetBytesToRemove: number
truncatedTools: Array<{ toolName: string; originalSize: number }>
}
export function truncateUntilTargetTokens(
sessionID: string,
currentTokens: number,
maxTokens: number,
targetRatio: number = 0.8,
charsPerToken: number = 4
): AggressiveTruncateResult {
const targetTokens = Math.floor(maxTokens * targetRatio)
const tokensToReduce = currentTokens - targetTokens
const charsToReduce = tokensToReduce * charsPerToken
if (tokensToReduce <= 0) {
return {
success: true,
sufficient: true,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove: 0,
truncatedTools: [],
}
}
const results = findToolResultsBySize(sessionID)
if (results.length === 0) {
return {
success: false,
sufficient: false,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove: charsToReduce,
truncatedTools: [],
}
}
let totalRemoved = 0
let truncatedCount = 0
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
for (const result of results) {
const truncateResult = truncateToolResult(result.partPath)
if (truncateResult.success) {
truncatedCount++
const removedSize = truncateResult.originalSize ?? result.outputSize
totalRemoved += removedSize
truncatedTools.push({
toolName: truncateResult.toolName ?? result.toolName,
originalSize: removedSize,
})
if (totalRemoved >= charsToReduce) {
break
}
}
}
const sufficient = totalRemoved >= charsToReduce
return {
success: truncatedCount > 0,
sufficient,
truncatedCount,
totalBytesRemoved: totalRemoved,
targetBytesToRemove: charsToReduce,
truncatedTools,
}
}
export { truncateUntilTargetTokens } from "./target-token-truncation"
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
type TimeoutCall = {
delay: number
}
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map<string, RetryState>(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
}
describe("runSummarizeRetryStrategy", () => {
const sessionID = "ses_retry_timeout"
const directory = "/tmp"
let autoCompactState: AutoCompactState
const summarizeMock = mock(() => Promise.resolve())
const showToastMock = mock(() => Promise.resolve())
const client = {
session: {
summarize: summarizeMock,
messages: mock(() => Promise.resolve({ data: [] })),
promptAsync: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
},
tui: {
showToast: showToastMock,
},
}
beforeEach(() => {
autoCompactState = createAutoCompactState()
summarizeMock.mockReset()
showToastMock.mockReset()
summarizeMock.mockResolvedValue(undefined)
showToastMock.mockResolvedValue(undefined)
})
afterEach(() => {
globalThis.setTimeout = originalSetTimeout
})
const originalSetTimeout = globalThis.setTimeout
test("stops retries when total summarize timeout is exceeded", async () => {
//#given
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, {
currentTokens: 250000,
maxTokens: 200000,
errorType: "token_limit_exceeded",
})
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 1,
lastAttemptTime: Date.now(),
firstAttemptTime: Date.now() - 130000,
})
//#when
await runSummarizeRetryStrategy({
sessionID,
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
autoCompactState,
client: client as never,
directory,
pluginConfig: {} as OhMyOpenCodeConfig,
})
//#then
expect(summarizeMock).not.toHaveBeenCalled()
expect(autoCompactState.pendingCompact.has(sessionID)).toBe(false)
expect(autoCompactState.errorDataBySession.has(sessionID)).toBe(false)
expect(autoCompactState.retryStateBySession.has(sessionID)).toBe(false)
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
title: "Auto Compact Timed Out",
}),
}),
)
})
test("caps retry delay by remaining total timeout window", async () => {
//#given
const timeoutCalls: TimeoutCall[] = []
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
timeoutCalls.push({ delay: delay ?? 0 })
return 1 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 1,
lastAttemptTime: Date.now(),
firstAttemptTime: Date.now() - 119700,
})
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
//#when
await runSummarizeRetryStrategy({
sessionID,
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
autoCompactState,
client: client as never,
directory,
pluginConfig: {} as OhMyOpenCodeConfig,
})
//#then
expect(timeoutCalls.length).toBe(1)
expect(timeoutCalls[0]!.delay).toBeGreaterThan(0)
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(500)
})
})
@@ -0,0 +1,170 @@
import type { AutoCompactState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
import { RETRY_CONFIG } from "./types"
import type { Client } from "./client"
import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state"
import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
import { fixEmptyMessages } from "./empty-content-recovery"
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000
export async function runSummarizeRetryStrategy(params: {
sessionID: string
msg: Record<string, unknown>
autoCompactState: AutoCompactState
client: Client
directory: string
pluginConfig: OhMyOpenCodeConfig
errorType?: string
messageIndex?: number
}): Promise<void> {
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
const now = Date.now()
if (retryState.firstAttemptTime === 0) {
retryState.firstAttemptTime = now
}
const elapsedTimeMs = now - retryState.firstAttemptTime
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
if (params.errorType?.includes("non-empty content")) {
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
if (attempt < 3) {
const fixed = await fixEmptyMessages({
sessionID: params.sessionID,
autoCompactState: params.autoCompactState,
client: params.client,
messageIndex: params.messageIndex,
})
if (fixed) {
setTimeout(() => {
void runSummarizeRetryStrategy(params)
}, 500)
return
}
} else {
await params.client.tui
.showToast({
body: {
title: "Recovery Failed",
message:
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
variant: "error",
duration: 10000,
},
})
.catch(() => {})
return
}
}
if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0
retryState.firstAttemptTime = Date.now()
params.autoCompactState.truncateStateBySession.delete(params.sessionID)
}
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
retryState.attempt++
retryState.lastAttemptTime = Date.now()
const providerID = params.msg.providerID as string | undefined
const modelID = params.msg.modelID as string | undefined
if (providerID && modelID) {
try {
await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client)
await params.client.tui
.showToast({
body: {
title: "Auto Compact",
message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
params.pluginConfig,
params.sessionID,
providerID,
modelID
)
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
await params.client.session.summarize({
path: { id: params.sessionID },
body: summarizeBody as never,
query: { directory: params.directory },
})
return
} catch {
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
if (remainingTimeMs <= 0) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
const delay =
RETRY_CONFIG.initialDelayMs *
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs)
setTimeout(() => {
void runSummarizeRetryStrategy(params)
}, cappedDelay)
return
}
} else {
await params.client.tui
.showToast({
body: {
title: "Summarize Skipped",
message: "Missing providerID or modelID.",
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
}
}
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Failed",
message: "All recovery attempts failed. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
}
@@ -0,0 +1,196 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { AggressiveTruncateResult } from "./tool-part-types"
import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage"
import { truncateToolResultAsync } from "./tool-result-storage-sdk"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"]
interface SDKToolPart {
id: string
type: string
tool?: string
state?: {
output?: string
time?: { start?: number; end?: number; compacted?: number }
}
originalSize?: number
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKToolPart[]
}
function calculateTargetBytesToRemove(
currentTokens: number,
maxTokens: number,
targetRatio: number,
charsPerToken: number
): { tokensToReduce: number; targetBytesToRemove: number } {
const targetTokens = Math.floor(maxTokens * targetRatio)
const tokensToReduce = currentTokens - targetTokens
const targetBytesToRemove = tokensToReduce * charsPerToken
return { tokensToReduce, targetBytesToRemove }
}
export async function truncateUntilTargetTokens(
sessionID: string,
currentTokens: number,
maxTokens: number,
targetRatio: number = 0.8,
charsPerToken: number = 4,
client?: OpencodeClient
): Promise<AggressiveTruncateResult> {
const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove(
currentTokens,
maxTokens,
targetRatio,
charsPerToken
)
if (tokensToReduce <= 0) {
return {
success: true,
sufficient: true,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove: 0,
truncatedTools: [],
}
}
if (client && isSqliteBackend()) {
let toolPartsByKey = new Map<string, SDKToolPart>()
try {
const response = (await client.session.messages({
path: { id: sessionID },
})) as { data?: SDKMessage[] }
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
toolPartsByKey = new Map<string, SDKToolPart>()
for (const message of messages) {
const messageID = message.info?.id
if (!messageID || !message.parts) continue
for (const part of message.parts) {
if (part.type !== "tool") continue
toolPartsByKey.set(`${messageID}:${part.id}`, part)
}
}
} catch {
toolPartsByKey = new Map<string, SDKToolPart>()
}
const results: import("./tool-part-types").ToolResultInfo[] = []
for (const [key, part] of toolPartsByKey) {
if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) {
results.push({
partPath: "",
partId: part.id,
messageID: key.split(":")[0],
toolName: part.tool,
outputSize: part.state.output.length,
})
}
}
results.sort((a, b) => b.outputSize - a.outputSize)
if (results.length === 0) {
return {
success: false,
sufficient: false,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove,
truncatedTools: [],
}
}
let totalRemoved = 0
let truncatedCount = 0
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
for (const result of results) {
const part = toolPartsByKey.get(`${result.messageID}:${result.partId}`)
if (!part) continue
const truncateResult = await truncateToolResultAsync(
client,
sessionID,
result.messageID,
result.partId,
part
)
if (truncateResult.success) {
truncatedCount++
const removedSize = truncateResult.originalSize ?? result.outputSize
totalRemoved += removedSize
truncatedTools.push({
toolName: truncateResult.toolName ?? result.toolName,
originalSize: removedSize,
})
if (totalRemoved >= targetBytesToRemove) {
break
}
}
}
const sufficient = totalRemoved >= targetBytesToRemove
return {
success: truncatedCount > 0,
sufficient,
truncatedCount,
totalBytesRemoved: totalRemoved,
targetBytesToRemove,
truncatedTools,
}
}
const results = findToolResultsBySize(sessionID)
if (results.length === 0) {
return {
success: false,
sufficient: false,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove,
truncatedTools: [],
}
}
let totalRemoved = 0
let truncatedCount = 0
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
for (const result of results) {
const truncateResult = truncateToolResult(result.partPath)
if (truncateResult.success) {
truncatedCount++
const removedSize = truncateResult.originalSize ?? result.outputSize
totalRemoved += removedSize
truncatedTools.push({
toolName: truncateResult.toolName ?? result.toolName,
originalSize: removedSize,
})
if (totalRemoved >= targetBytesToRemove) {
break
}
}
}
const sufficient = totalRemoved >= targetBytesToRemove
return {
success: truncatedCount > 0,
sufficient,
truncatedCount,
totalBytesRemoved: totalRemoved,
targetBytesToRemove,
truncatedTools,
}
}
@@ -0,0 +1,38 @@
export interface StoredToolPart {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: {
status: "pending" | "running" | "completed" | "error"
input: Record<string, unknown>
output?: string
error?: string
time?: {
start: number
end?: number
compacted?: number
}
}
truncated?: boolean
originalSize?: number
}
export interface ToolResultInfo {
partPath: string
partId: string
messageID: string
toolName: string
outputSize: number
}
export interface AggressiveTruncateResult {
success: boolean
sufficient: boolean
truncatedCount: number
totalBytesRemoved: number
targetBytesToRemove: number
truncatedTools: Array<{ toolName: string; originalSize: number }>
}
@@ -0,0 +1,123 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { TRUNCATION_MESSAGE } from "./storage-paths"
import type { ToolResultInfo } from "./tool-part-types"
import { patchPart } from "../../shared/opencode-http-api"
import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"]
interface SDKToolPart {
id: string
type: string
callID?: string
tool?: string
state?: {
status?: string
input?: Record<string, unknown>
output?: string
error?: string
time?: { start?: number; end?: number; compacted?: number }
}
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKToolPart[]
}
export async function findToolResultsBySizeFromSDK(
client: OpencodeClient,
sessionID: string
): Promise<ToolResultInfo[]> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
const results: ToolResultInfo[] = []
for (const msg of messages) {
const messageID = msg.info?.id
if (!messageID || !msg.parts) continue
for (const part of msg.parts) {
if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) {
results.push({
partPath: "",
partId: part.id,
messageID,
toolName: part.tool,
outputSize: part.state.output.length,
})
}
}
}
return results.sort((a, b) => b.outputSize - a.outputSize)
} catch {
return []
}
}
export async function truncateToolResultAsync(
client: OpencodeClient,
sessionID: string,
messageID: string,
partId: string,
part: SDKToolPart
): Promise<{ success: boolean; toolName?: string; originalSize?: number }> {
if (!part.state?.output) return { success: false }
const originalSize = part.state.output.length
const toolName = part.tool
const updatedPart: Record<string, unknown> = {
...part,
state: {
...part.state,
output: TRUNCATION_MESSAGE,
time: {
...(part.state.time ?? { start: Date.now() }),
compacted: Date.now(),
},
},
}
try {
const patched = await patchPart(client, sessionID, messageID, partId, updatedPart)
if (!patched) return { success: false }
return { success: true, toolName, originalSize }
} catch (error) {
log("[context-window-recovery] truncateToolResultAsync failed", { error: String(error) })
return { success: false }
}
}
export async function countTruncatedResultsFromSDK(
client: OpencodeClient,
sessionID: string
): Promise<number> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
let count = 0
for (const msg of messages) {
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type === "tool" && part.state?.time?.compacted) count++
}
}
return count
} catch {
return 0
}
}
export async function getTotalToolOutputSizeFromSDK(
client: OpencodeClient,
sessionID: string
): Promise<number> {
const results = await findToolResultsBySizeFromSDK(client, sessionID)
return results.reduce((sum, result) => sum + result.outputSize, 0)
}
@@ -0,0 +1,119 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { getMessageIds } from "./message-storage-directory"
import { PART_STORAGE_DIR, TRUNCATION_MESSAGE } from "./storage-paths"
import type { StoredToolPart, ToolResultInfo } from "./tool-part-types"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { log } from "../../shared/logger"
let hasLoggedTruncateWarning = false
export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
const messageIds = getMessageIds(sessionID)
const results: ToolResultInfo[] = []
for (const messageID of messageIds) {
const partDir = join(PART_STORAGE_DIR, messageID)
if (!existsSync(partDir)) continue
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const partPath = join(partDir, file)
const content = readFileSync(partPath, "utf-8")
const part = JSON.parse(content) as StoredToolPart
if (part.type === "tool" && part.state?.output && !part.truncated) {
results.push({
partPath,
partId: part.id,
messageID,
toolName: part.tool,
outputSize: part.state.output.length,
})
}
} catch {
continue
}
}
}
return results.sort((a, b) => b.outputSize - a.outputSize)
}
export function findLargestToolResult(sessionID: string): ToolResultInfo | null {
const results = findToolResultsBySize(sessionID)
return results.length > 0 ? results[0] : null
}
export function truncateToolResult(partPath: string): {
success: boolean
toolName?: string
originalSize?: number
} {
if (isSqliteBackend()) {
if (!hasLoggedTruncateWarning) {
log("[context-window-recovery] Disabled on SQLite backend: truncateToolResult")
hasLoggedTruncateWarning = true
}
return { success: false }
}
try {
const content = readFileSync(partPath, "utf-8")
const part = JSON.parse(content) as StoredToolPart
if (!part.state?.output) {
return { success: false }
}
const originalSize = part.state.output.length
const toolName = part.tool
part.truncated = true
part.originalSize = originalSize
part.state.output = TRUNCATION_MESSAGE
if (!part.state.time) {
part.state.time = { start: Date.now() }
}
part.state.time.compacted = Date.now()
writeFileSync(partPath, JSON.stringify(part, null, 2))
return { success: true, toolName, originalSize }
} catch {
return { success: false }
}
}
export function getTotalToolOutputSize(sessionID: string): number {
const results = findToolResultsBySize(sessionID)
return results.reduce((sum, result) => sum + result.outputSize, 0)
}
export function countTruncatedResults(sessionID: string): number {
const messageIds = getMessageIds(sessionID)
let count = 0
for (const messageID of messageIds) {
const partDir = join(PART_STORAGE_DIR, messageID)
if (!existsSync(partDir)) continue
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
const part = JSON.parse(content)
if (part.truncated === true) {
count++
}
} catch {
continue
}
}
}
return count
}
@@ -11,6 +11,7 @@ export interface ParsedTokenLimitError {
export interface RetryState {
attempt: number
lastAttemptTime: number
firstAttemptTime: number
}
export interface TruncateState {
+52
View File
@@ -0,0 +1,52 @@
import { log, normalizeModelID } from "../../shared"
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
function isClaudeProvider(providerID: string, modelID: string): boolean {
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true
return false
}
function isOpus46(modelID: string): boolean {
const normalized = normalizeModelID(modelID)
return OPUS_4_6_PATTERN.test(normalized)
}
interface ChatParamsInput {
sessionID: string
agent: { name?: string }
model: { providerID: string; modelID: string }
provider: { id: string }
message: { variant?: string }
}
interface ChatParamsOutput {
temperature?: number
topP?: number
topK?: number
options: Record<string, unknown>
}
export function createAnthropicEffortHook() {
return {
"chat.params": async (
input: ChatParamsInput,
output: ChatParamsOutput
): Promise<void> => {
const { model, message } = input
if (!model?.modelID || !model?.providerID) return
if (message.variant !== "max") return
if (!isClaudeProvider(model.providerID, model.modelID)) return
if (!isOpus46(model.modelID)) return
if (output.options.effort !== undefined) return
output.options.effort = "max"
log("anthropic-effort: injected effort=max", {
sessionID: input.sessionID,
provider: model.providerID,
model: model.modelID,
})
},
}
}
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from "bun:test"
import { createAnthropicEffortHook } from "./index"
interface ChatParamsInput {
sessionID: string
agent: { name?: string }
model: { providerID: string; modelID: string; id?: string; api?: { npm?: string } }
provider: { id: string }
message: { variant?: string }
}
interface ChatParamsOutput {
temperature?: number
topP?: number
topK?: number
options: Record<string, unknown>
}
function createMockParams(overrides: {
providerID?: string
modelID?: string
variant?: string
agentName?: string
existingOptions?: Record<string, unknown>
}): { input: ChatParamsInput; output: ChatParamsOutput } {
const providerID = overrides.providerID ?? "anthropic"
const modelID = overrides.modelID ?? "claude-opus-4-6"
const variant = "variant" in overrides ? overrides.variant : "max"
const agentName = overrides.agentName ?? "sisyphus"
const existingOptions = overrides.existingOptions ?? {}
return {
input: {
sessionID: "test-session",
agent: { name: agentName },
model: { providerID, modelID },
provider: { id: providerID },
message: { variant },
},
output: {
temperature: 0.1,
options: { ...existingOptions },
},
}
}
describe("createAnthropicEffortHook", () => {
describe("opus 4-6 with variant max", () => {
it("should inject effort max for anthropic opus-4-6 with variant max", async () => {
//#given anthropic opus-4-6 model with variant max
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected into options
expect(output.options.effort).toBe("max")
})
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
//#given github-copilot provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "github-copilot",
modelID: "claude-opus-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected (github-copilot resolves to anthropic)
expect(output.options.effort).toBe("max")
})
it("should inject effort max for opencode provider with claude-opus-4-6", async () => {
//#given opencode provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "opencode",
modelID: "claude-opus-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected
expect(output.options.effort).toBe("max")
})
it("should inject effort max for google-vertex-anthropic provider", async () => {
//#given google-vertex-anthropic provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "google-vertex-anthropic",
modelID: "claude-opus-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected
expect(output.options.effort).toBe("max")
})
it("should handle normalized model ID with dots (opus-4.6)", async () => {
//#given model ID with dots instead of hyphens
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
modelID: "claude-opus-4.6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then should normalize and inject effort
expect(output.options.effort).toBe("max")
})
})
describe("conditions NOT met - should skip", () => {
it("should NOT inject effort when variant is not max", async () => {
//#given opus-4-6 with variant high (not max)
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ variant: "high" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT inject effort when variant is undefined", async () => {
//#given opus-4-6 with no variant
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ variant: undefined })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT inject effort for non-opus model", async () => {
//#given claude-sonnet-4-6 (not opus)
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
modelID: "claude-sonnet-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
//#given openai provider with gpt model
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "openai",
modelID: "gpt-5.4",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT throw when model.modelID is undefined", async () => {
//#given model with undefined modelID (runtime edge case)
const hook = createAnthropicEffortHook()
const input = {
sessionID: "test-session",
agent: { name: "sisyphus" },
model: { providerID: "anthropic", modelID: undefined as unknown as string },
provider: { id: "anthropic" },
message: { variant: "max" as const },
}
const output = { temperature: 0.1, options: {} }
//#when chat.params hook is called with undefined modelID
await hook["chat.params"](input, output)
//#then should gracefully skip without throwing
expect(output.options.effort).toBeUndefined()
})
})
describe("preserves existing options", () => {
it("should NOT overwrite existing effort if already set", async () => {
//#given options already have effort set
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
existingOptions: { effort: "high" },
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then existing effort should be preserved
expect(output.options.effort).toBe("high")
})
it("should preserve other existing options when injecting effort", async () => {
//#given options with existing thinking config
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
existingOptions: {
thinking: { type: "enabled", budgetTokens: 31999 },
},
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be added without affecting thinking
expect(output.options.effort).toBe("max")
expect(output.options.thinking).toEqual({
type: "enabled",
budgetTokens: 31999,
})
})
})
})
+1
View File
@@ -0,0 +1 @@
export { createAnthropicEffortHook } from "./hook";
+64
View File
@@ -0,0 +1,64 @@
# src/hooks/atlas/ — Master Boulder Orchestrator
**Generated:** 2026-03-06
## OVERVIEW
17 files (~1976 LOC). The `atlasHook` — Continuation Tier hook that monitors session.idle events and forces continuation when boulder sessions (ralph-loop, task-spawned agents) have incomplete work. Also enforces write/edit policies for subagent sessions.
## WHAT ATLAS DOES
Atlas is the "keeper of sessions" — it tracks every session and decides:
1. Should this session be forced to continue? (if boulder session with incomplete todos)
2. Should write/edit be blocked? (policy enforcement for certain session types)
3. Should a verification reminder be injected? (after tool execution)
## DECISION GATE (session.idle)
```
session.idle event
→ Is this a boulder/ralph/atlas session? (session-last-agent.ts)
→ Is there an abort signal? (is-abort-error.ts)
→ Failure count < max? (state.promptFailureCount)
→ No running background tasks?
→ Agent matches expected? (recent-model-resolver.ts)
→ Plan complete? (todo status)
→ Cooldown passed? (5s between injections)
→ Inject continuation prompt (boulder-continuation-injector.ts)
```
## KEY FILES
| File | Purpose |
|------|---------|
| `atlas-hook.ts` | `createAtlasHook()` — composes event + tool handlers, maintains session state |
| `event-handler.ts` | `createAtlasEventHandler()` — decision gate for session.idle events |
| `boulder-continuation-injector.ts` | Build + inject continuation prompt into session |
| `system-reminder-templates.ts` | Templates for continuation reminder messages |
| `tool-execute-before.ts` | Block write/edit based on session policy |
| `tool-execute-after.ts` | Inject verification reminders post-tool |
| `write-edit-tool-policy.ts` | Policy: which sessions can write/edit? |
| `verification-reminders.ts` | Reminder content for verifying work |
| `session-last-agent.ts` | Determine which agent owns the session |
| `recent-model-resolver.ts` | Resolve model used in recent messages |
| `subagent-session-id.ts` | Detect if session is a subagent session |
| `sisyphus-path.ts` | Resolve `.sisyphus/` directory path |
| `is-abort-error.ts` | Detect abort signals in session output |
| `types.ts` | `SessionState`, `AtlasHookOptions`, `AtlasContext` |
## STATE PER SESSION
```typescript
interface SessionState {
promptFailureCount: number // Increments on failed continuations
// Resets on successful continuation
}
```
Max consecutive failures before 5min pause: 5 (exponential backoff in todo-continuation-enforcer).
## RELATIONSHIP TO OTHER HOOKS
- **atlasHook** (Continuation Tier): Master orchestrator, handles boulder sessions
- **todoContinuationEnforcer** (Continuation Tier): "Boulder" mechanism for main Sisyphus sessions
- Both inject into session.idle but serve different session types
+27
View File
@@ -0,0 +1,27 @@
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, PendingTaskRef, SessionState } from "./types"
export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
const sessions = new Map<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
const autoCommit = options?.autoCommit ?? true
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({ ctx, pendingFilePaths, pendingTaskRefs }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
}
}
@@ -0,0 +1,84 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
import { resolveRecentPromptContextForSession } 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
worktreePath?: string
preferredTaskSessionId?: string
preferredTaskTitle?: string
backgroundManager?: BackgroundManager
sessionState: SessionState
}): Promise<void> {
const {
ctx,
sessionID,
planName,
remaining,
total,
agent,
worktreePath,
preferredTaskSessionId,
preferredTaskTitle,
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 worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
const preferredSessionContext = preferredTaskSessionId
? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]`
: ""
const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext +
worktreeContext
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: agent ?? "atlas",
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(prompt)],
},
query: { directory: ctx.directory },
})
sessionState.promptFailureCount = 0
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} catch (err) {
sessionState.promptFailureCount += 1
sessionState.lastFailureAt = Date.now()
log(`[${HOOK_NAME}] Boulder continuation failed`, {
sessionID,
error: String(err),
promptFailureCount: sessionState.promptFailureCount,
})
}
}
@@ -0,0 +1,44 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
export async function isSessionInBoulderLineage(input: {
client: PluginInput["client"]
sessionID: string
boulderSessionIDs: string[]
}): Promise<boolean> {
const visitedSessionIDs = new Set<string>()
let currentSessionID = input.sessionID
while (!visitedSessionIDs.has(currentSessionID)) {
visitedSessionIDs.add(currentSessionID)
const sessionResult = await input.client.session
.get({ path: { id: currentSessionID } })
.catch((error: unknown) => {
log(`[${HOOK_NAME}] Failed to resolve session lineage`, {
sessionID: input.sessionID,
currentSessionID,
error,
})
return null
})
if (!sessionResult || sessionResult.error) {
return false
}
const parentSessionID = sessionResult.data?.parentID
if (!parentSessionID) {
return false
}
if (input.boulderSessionIDs.includes(parentSessionID)) {
return true
}
currentSessionID = parentSessionID
}
return false
}
@@ -0,0 +1,108 @@
declare const require: (name: string) => any
const { afterEach, beforeEach, describe, expect, mock, test } = require("bun:test")
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting } from "../../features/claude-code-session-state"
import type { BoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-compaction-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directory = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(directory) ? directory : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
const { createAtlasHook } = await import("./index")
describe("atlas hook compaction agent filtering", () => {
let testDirectory: string
function createMockPluginInput() {
const promptMock = mock(() => Promise.resolve())
return {
directory: testDirectory,
client: {
session: {
prompt: promptMock,
promptAsync: promptMock,
},
},
_promptMock: promptMock,
} as Parameters<typeof createAtlasHook>[0] & { _promptMock: ReturnType<typeof mock> }
}
function writeMessage(sessionID: string, fileName: string, agent: string): void {
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(messageDir, { recursive: true })
writeFileSync(
join(messageDir, fileName),
JSON.stringify({
agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}),
)
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-compaction-test-${randomUUID()}`)
mkdirSync(testDirectory, { recursive: true })
clearBoulderState(testDirectory)
_resetForTesting()
})
afterEach(() => {
clearBoulderState(testDirectory)
rmSync(testDirectory, { recursive: true, force: true })
_resetForTesting()
})
test("should inject continuation when the latest message is compaction but the previous agent matches atlas", async () => {
// given
const sessionID = "main-session-after-compaction"
const planPath = join(testDirectory, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "test-plan",
agent: "atlas",
}
writeBoulderState(testDirectory, state)
writeMessage(sessionID, "msg_001.json", "atlas")
writeMessage(sessionID, "msg_002.json", "compaction")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID },
},
})
// then
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
})
})
+104
View File
@@ -0,0 +1,104 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error"
import { handleAtlasSessionIdle } from "./idle-event"
import type { AtlasHookOptions, SessionState } from "./types"
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
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
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
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
if (role === "user") {
state.waitingForFinalWaveApproval = 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) {
const deletedState = sessions.get(sessionInfo.id)
if (deletedState?.pendingRetryTimer) {
clearTimeout(deletedState.pendingRetryTimer)
}
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) {
const compactedState = sessions.get(sessionID)
if (compactedState?.pendingRetryTimer) {
clearTimeout(compactedState.pendingRetryTimer)
}
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID })
}
}
}
}
@@ -0,0 +1,222 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createOpencodeClient } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import type { BoulderState } from "../../features/boulder-state"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-final-wave-regression-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directoryPath = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(directoryPath) ? directoryPath : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
describe("Atlas final-wave approval gate regressions", () => {
let testDirectory = ""
function createMockPluginInput(): AtlasHookContext {
const client = createOpencodeClient({ baseUrl: "http://localhost" })
Reflect.set(client.session, "prompt", async () => ({
data: { info: {} as AssistantMessage, parts: [] },
request: new Request("http://localhost/session/prompt"),
response: new Response(),
}))
Reflect.set(client.session, "promptAsync", async () => ({
data: undefined,
request: new Request("http://localhost/session/prompt_async"),
response: new Response(),
}))
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
const parentID = path.id === "ses_nested_scope_review"
? "atlas-nested-final-wave-session"
: path.id.startsWith("ses_parallel_review_")
? "atlas-parallel-final-wave-session"
: "main-session-123"
return {
data: {
id: path.id,
parentID,
} as Session,
request: new Request(`http://localhost/session/${path.id}`),
response: new Response(),
}
})
return {
directory: testDirectory,
project: {} as AtlasHookContext["project"],
worktree: testDirectory,
serverUrl: new URL("http://localhost"),
$: {} as AtlasHookContext["$"],
client,
}
}
function setupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDirectory)) {
mkdirSync(messageDirectory, { recursive: true })
}
writeFileSync(
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}),
)
}
function writePlanState(sessionID: string, planName: string, planContent: string): void {
const planPath = join(testDirectory, `${planName}.md`)
writeFileSync(planPath, planContent)
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: planName,
agent: "atlas",
}
writeBoulderState(testDirectory, state)
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-regression-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
clearBoulderState(testDirectory)
})
afterEach(() => {
clearBoulderState(testDirectory)
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
test("waits for approval when nested plan checkboxes remain but the only pending top-level task is final-wave", async () => {
// given
const sessionID = "atlas-nested-final-wave-session"
setupMessageStorage(sessionID)
writePlanState(sessionID, "nested-final-wave-plan", `# Plan
## TODOs
- [x] 1. Implement feature
**Acceptance Criteria**:
- [ ] bun test src/feature.test.ts -> PASS
**Evidence to Capture**:
- [ ] Each evidence file named: task-1-happy-path.txt
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
- [x] F1. **Plan Compliance Audit** - \`oracle\`
- [x] F2. **Code Quality Review** - \`unspecified-high\`
- [x] F3. **Real Manual QA** - \`unspecified-high\`
- [ ] F4. **Scope Fidelity Check** - \`deep\`
## Final Checklist
- [ ] All tests pass
`)
const hook = createAtlasHook(createMockPluginInput())
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [1/1 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
<task_metadata>
session_id: ses_nested_scope_review
</task_metadata>`,
metadata: {},
}
// when
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
// then
expect(toolOutput.output).toContain("FINAL WAVE APPROVAL GATE")
expect(toolOutput.output).toContain("explicit user approval")
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
})
test("waits for approval after the final parallel reviewer approves before plan checkboxes are updated", async () => {
// given
const sessionID = "atlas-parallel-final-wave-session"
setupMessageStorage(sessionID)
writePlanState(sessionID, "parallel-final-wave-plan", `# Plan
## TODOs
- [x] 1. Ship implementation
- [x] 2. Verify implementation
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
- [ ] F1. **Plan Compliance Audit** - \`oracle\`
- [ ] F2. **Code Quality Review** - \`unspecified-high\`
- [ ] F3. **Real Manual QA** - \`unspecified-high\`
- [ ] F4. **Scope Fidelity Check** - \`deep\`
`)
const hook = createAtlasHook(createMockPluginInput())
const firstThreeOutputs = [1, 2, 3].map((index) => ({
title: `Final review ${index}`,
output: `Reviewer ${index} | VERDICT: APPROVE
<task_metadata>
session_id: ses_parallel_review_${index}
</task_metadata>`,
metadata: {},
}))
const lastOutput = {
title: "Final review 4",
output: `Reviewer 4 | VERDICT: APPROVE
<task_metadata>
session_id: ses_parallel_review_4
</task_metadata>`,
metadata: {},
}
// when
for (const toolOutput of firstThreeOutputs) {
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
}
await hook["tool.execute.after"]({ tool: "task", sessionID }, lastOutput)
// then
for (const toolOutput of firstThreeOutputs) {
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
}
expect(lastOutput.output).toContain("FINAL WAVE APPROVAL GATE")
expect(lastOutput.output).toContain("explicit user approval")
expect(lastOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
})
})
@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createOpencodeClient } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import type { BoulderState } from "../../features/boulder-state"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-final-wave-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directoryPath = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(directoryPath) ? directoryPath : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
type PromptMock = ReturnType<typeof mock>
describe("Atlas final verification approval gate", () => {
let testDirectory = ""
function createMockPluginInput(): AtlasHookContext & { _promptMock: PromptMock } {
const client = createOpencodeClient({ baseUrl: "http://localhost" })
const promptMock = mock((input: unknown) => input)
Reflect.set(client.session, "prompt", async (input: unknown) => {
promptMock(input)
return {
data: { info: {} as AssistantMessage, parts: [] },
request: new Request("http://localhost/session/prompt"),
response: new Response(),
}
})
Reflect.set(client.session, "promptAsync", async (input: unknown) => {
promptMock(input)
return {
data: undefined,
request: new Request("http://localhost/session/prompt_async"),
response: new Response(),
}
})
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
const parentID = path.id === "ses_final_wave_review"
? "atlas-final-wave-session"
: path.id === "ses_feature_task"
? "atlas-non-final-session"
: "main-session-123"
return {
data: {
id: path.id,
parentID,
} as Session,
request: new Request(`http://localhost/session/${path.id}`),
response: new Response(),
}
})
return {
directory: testDirectory,
project: {} as AtlasHookContext["project"],
worktree: testDirectory,
serverUrl: new URL("http://localhost"),
$: {} as AtlasHookContext["$"],
client,
_promptMock: promptMock,
}
}
function setupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDirectory)) {
mkdirSync(messageDirectory, { recursive: true })
}
writeFileSync(
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}),
)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDirectory)) {
rmSync(messageDirectory, { recursive: true, force: true })
}
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-test-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
clearBoulderState(testDirectory)
})
afterEach(() => {
clearBoulderState(testDirectory)
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
test("waits for explicit user approval after the last final-wave approval arrives", async () => {
// given
const sessionID = "atlas-final-wave-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "final-wave-plan.md")
writeFileSync(
planPath,
`# Plan
## TODOs
- [x] 1. Ship the implementation
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
- [x] F1. **Plan Compliance Audit** - \`oracle\`
- [x] F2. **Code Quality Review** - \`unspecified-high\`
- [x] F3. **Real Manual QA** - \`unspecified-high\`
- [ ] F4. **Scope Fidelity Check** - \`deep\`
`,
)
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "final-wave-plan",
agent: "atlas",
}
writeBoulderState(testDirectory, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
<task_metadata>
session_id: ses_final_wave_review
</task_metadata>`,
metadata: {},
}
// when
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// then
expect(toolOutput.output).toContain("FINAL WAVE APPROVAL GATE")
expect(toolOutput.output).toContain("explicit user approval")
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
expect(mockInput._promptMock).not.toHaveBeenCalled()
cleanupMessageStorage(sessionID)
})
test("keeps normal auto-continue instructions for non-final tasks", async () => {
// given
const sessionID = "atlas-non-final-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "implementation-plan.md")
writeFileSync(
planPath,
`# Plan
## TODOs
- [x] 1. Setup
- [ ] 2. Implement feature
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
- [ ] F1. **Plan Compliance Audit** - \`oracle\`
- [ ] F2. **Code Quality Review** - \`unspecified-high\`
- [ ] F3. **Real Manual QA** - \`unspecified-high\`
- [ ] F4. **Scope Fidelity Check** - \`deep\`
`,
)
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "implementation-plan",
agent: "atlas",
}
writeBoulderState(testDirectory, state)
const hook = createAtlasHook(createMockPluginInput())
const toolOutput = {
title: "Sisyphus Task",
output: `Implementation finished successfully
<task_metadata>
session_id: ses_feature_task
</task_metadata>`,
metadata: {},
}
// when
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
// then
expect(toolOutput.output).toContain("COMPLETION GATE")
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
cleanupMessageStorage(sessionID)
})
})
@@ -0,0 +1,47 @@
import type { SessionState } from "./types"
import { readFinalWavePlanState } from "./final-wave-plan-state"
const APPROVE_VERDICT_PATTERN = /\bVERDICT:\s*APPROVE\b/i
function clearFinalWaveApprovalTracking(sessionState: SessionState): void {
sessionState.pendingFinalWaveTaskCount = undefined
sessionState.approvedFinalWaveTaskCount = undefined
}
export function shouldPauseForFinalWaveApproval(input: {
planPath: string
taskOutput: string
sessionState: SessionState
}): boolean {
const planState = readFinalWavePlanState(input.planPath)
if (!planState) {
return false
}
if (planState.pendingImplementationTaskCount > 0 || planState.pendingFinalWaveTaskCount === 0) {
clearFinalWaveApprovalTracking(input.sessionState)
return false
}
if (!APPROVE_VERDICT_PATTERN.test(input.taskOutput)) {
return false
}
if (planState.pendingFinalWaveTaskCount === 1) {
clearFinalWaveApprovalTracking(input.sessionState)
return true
}
if (input.sessionState.pendingFinalWaveTaskCount !== planState.pendingFinalWaveTaskCount) {
input.sessionState.pendingFinalWaveTaskCount = planState.pendingFinalWaveTaskCount
input.sessionState.approvedFinalWaveTaskCount = 0
}
input.sessionState.approvedFinalWaveTaskCount = (input.sessionState.approvedFinalWaveTaskCount ?? 0) + 1
const shouldPause = input.sessionState.approvedFinalWaveTaskCount >= planState.pendingFinalWaveTaskCount
if (shouldPause) {
clearFinalWaveApprovalTracking(input.sessionState)
}
return shouldPause
}
+60
View File
@@ -0,0 +1,60 @@
import { existsSync, readFileSync } from "node:fs"
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
const UNCHECKED_CHECKBOX_PATTERN = /^\s*[-*]\s*\[\s*\]\s*(.+)$/
const TODO_TASK_PATTERN = /^\d+\./
const FINAL_WAVE_TASK_PATTERN = /^F\d+\./i
type PlanSection = "todo" | "final-wave" | "other"
export type FinalWavePlanState = {
pendingImplementationTaskCount: number
pendingFinalWaveTaskCount: number
}
export function readFinalWavePlanState(planPath: string): FinalWavePlanState | null {
if (!existsSync(planPath)) {
return null
}
try {
const content = readFileSync(planPath, "utf-8")
const lines = content.split(/\r?\n/)
let section: PlanSection = "other"
let pendingImplementationTaskCount = 0
let pendingFinalWaveTaskCount = 0
for (const line of lines) {
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
section = TODO_HEADING_PATTERN.test(line)
? "todo"
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
? "final-wave"
: "other"
}
const uncheckedTaskMatch = line.match(UNCHECKED_CHECKBOX_PATTERN)
if (!uncheckedTaskMatch) {
continue
}
const taskLabel = uncheckedTaskMatch[1].trim()
if (section === "todo" && TODO_TASK_PATTERN.test(taskLabel)) {
pendingImplementationTaskCount += 1
}
if (section === "final-wave" && FINAL_WAVE_TASK_PATTERN.test(taskLabel)) {
pendingFinalWaveTaskCount += 1
}
}
return {
pendingImplementationTaskCount,
pendingFinalWaveTaskCount,
}
} catch {
return null
}
}
+1
View File
@@ -0,0 +1 @@
export const HOOK_NAME = "atlas"
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
const { createAtlasHook } = await import("./index")
describe("atlas hook idle-event session lineage", () => {
const MAIN_SESSION_ID = "main-session-123"
let testDirectory = ""
let promptCalls: Array<unknown> = []
function writeIncompleteBoulder(): void {
const planPath = join(testDirectory, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(testDirectory, state)
}
function createHook(parentSessionIDs?: Record<string, string | undefined>) {
return createAtlasHook({
directory: testDirectory,
client: {
session: {
get: async (input: { path: { id: string } }) => ({
data: {
parentID: parentSessionIDs?.[input.path.id],
},
}),
messages: async () => ({ data: [] }),
prompt: async (input: unknown) => {
promptCalls.push(input)
return { data: {} }
},
promptAsync: async (input: unknown) => {
promptCalls.push(input)
return { data: {} }
},
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-idle-lineage-${randomUUID()}`)
if (!existsSync(testDirectory)) {
mkdirSync(testDirectory, { recursive: true })
}
promptCalls = []
clearBoulderState(testDirectory)
_resetForTesting()
subagentSessions.clear()
})
afterEach(() => {
clearBoulderState(testDirectory)
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
_resetForTesting()
})
it("does not append unrelated subagent sessions during idle", async () => {
const unrelatedSubagentSessionID = "subagent-session-unrelated"
const unrelatedParentSessionID = "unrelated-parent-session"
writeIncompleteBoulder()
subagentSessions.add(unrelatedSubagentSessionID)
const hook = createHook({
[unrelatedSubagentSessionID]: unrelatedParentSessionID,
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: unrelatedSubagentSessionID },
},
})
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(unrelatedSubagentSessionID), false)
assert.equal(promptCalls.length, 0)
})
it("appends boulder-owned subagent sessions during idle when lineage reaches tracked session", async () => {
const subagentSessionID = "subagent-session-456"
const intermediateParentSessionID = "subagent-parent-789"
writeIncompleteBoulder()
subagentSessions.add(subagentSessionID)
const hook = createHook({
[subagentSessionID]: intermediateParentSessionID,
[intermediateParentSessionID]: MAIN_SESSION_ID,
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
assert.equal(promptCalls.length, 1)
})
})
+199
View File
@@ -0,0 +1,199 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
getPlanProgress,
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
const backgroundManager = options?.backgroundManager
return backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
}
async function injectContinuation(input: {
ctx: PluginInput
sessionID: string
sessionState: SessionState
options?: AtlasHookOptions
planName: string
progress: { total: number; completed: number }
agent?: string
worktreePath?: string
}): Promise<void> {
const remaining = input.progress.total - input.progress.completed
input.sessionState.lastContinuationInjectedAt = Date.now()
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const currentTask = currentBoulder
? readCurrentTopLevelTask(currentBoulder.active_plan)
: null
const preferredTaskSession = currentTask
? getTaskSessionState(input.ctx.directory, currentTask.key)
: null
await injectBoulderContinuation({
ctx: input.ctx,
sessionID: input.sessionID,
planName: input.planName,
remaining,
total: input.progress.total,
agent: input.agent,
worktreePath: input.worktreePath,
preferredTaskSessionId: preferredTaskSession?.session_id,
preferredTaskTitle: preferredTaskSession?.task_title,
backgroundManager: input.options?.backgroundManager,
sessionState: input.sessionState,
})
} catch (error) {
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error })
input.sessionState.promptFailureCount += 1
}
}
function scheduleRetry(input: {
ctx: PluginInput
sessionID: string
sessionState: SessionState
options?: AtlasHookOptions
}): void {
const { ctx, sessionID, sessionState, options } = input
if (sessionState.pendingRetryTimer) {
return
}
sessionState.pendingRetryTimer = setTimeout(async () => {
sessionState.pendingRetryTimer = undefined
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return
if (sessionState.waitingForFinalWaveApproval) return
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
if (hasRunningBackgroundTasks(sessionID, options)) return
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: currentBoulder.plan_name,
progress: currentProgress,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
})
}, RETRY_DELAY_MS)
}
export async function handleAtlasSessionIdle(input: {
ctx: PluginInput
options?: AtlasHookOptions
getState: (sessionID: string) => SessionState
sessionID: string
}): Promise<void> {
const { ctx, options, getState, sessionID } = input
log(`[${HOOK_NAME}] session.idle`, { sessionID })
const activeBoulderSession = await resolveActiveBoulderSession({
client: ctx.client,
directory: ctx.directory,
sessionID,
})
if (!activeBoulderSession) {
log(`[${HOOK_NAME}] Skipped: session not registered in active boulder`, { sessionID })
return
}
const { boulderState, progress, appendedSession } = activeBoulderSession
if (progress.isComplete) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
if (appendedSession) {
log(`[${HOOK_NAME}] Appended subagent session to boulder during idle`, {
sessionID,
plan: boulderState.plan_name,
})
}
const sessionState = getState(sessionID)
const now = Date.now()
if (sessionState.waitingForFinalWaveApproval) {
log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID })
return
}
if (sessionState.lastEventWasAbortError) {
sessionState.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) {
const timeSinceLastFailure =
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
sessionID,
promptFailureCount: sessionState.promptFailureCount,
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
})
return
}
sessionState.promptFailureCount = 0
sessionState.lastFailureAt = undefined
}
if (hasRunningBackgroundTasks(sessionID, options)) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (options?.isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
scheduleRetry({ ctx, sessionID, sessionState, options })
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID,
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - sessionState.lastContinuationInjectedAt),
pendingRetry: !!sessionState.pendingRetryTimer,
})
return
}
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: boulderState.plan_name,
progress,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
})
}
File diff suppressed because it is too large Load Diff
+3 -773
View File
@@ -1,773 +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 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"]
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 \`delegate_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 \`delegate_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
- Mark each checkbox [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 \`delegate_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 \`delegate_task\`.
**CORRECT APPROACH:**
\`\`\`
delegate_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:**
\`\`\`
delegate_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>
}
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 isCallerOrchestrator(sessionID?: string): boolean {
if (!sessionID) return false
const messageDir = getMessageDir(sessionID)
if (!messageDir) return false
const nearest = findNearestMessageWithFields(messageDir)
return nearest?.agent?.toLowerCase() === "atlas"
}
interface SessionState {
lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number
}
const CONTINUATION_COOLDOWN_MS = 5000
export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
}
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 = {}
sessions.set(sessionID, state)
}
return state
}
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number): Promise<void> {
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.prompt({
path: { id: sessionID },
body: {
agent: "atlas",
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Boulder continuation failed`, { sessionID, error: String(err) })
}
}
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
}
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 (!isCallerOrchestrator(sessionID)) {
log(`[${HOOK_NAME}] Skipped: last agent is not Atlas`, { sessionID })
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)
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
}
},
"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 delegate_task - inject single-task directive
if (input.tool === "delegate_task") {
const prompt = output.args.prompt as string | undefined
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
output.args.prompt = prompt + `\n<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>`
log(`[${HOOK_NAME}] Injected single-task directive to delegate_task`, {
sessionID: input.sessionID,
})
}
}
},
"tool.execute.after": async (
input: ToolExecuteAfterInput,
output: ToolExecuteAfterOutput
): Promise<void> => {
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 !== "delegate_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
}
+66
View File
@@ -0,0 +1,66 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
} from "../../features/hook-message-injector"
import { getMessageDir, isSqliteBackend, normalizePromptTools, normalizeSDKResponse } from "../../shared"
import type { ModelInfo } from "./types"
type PromptContext = {
model?: ModelInfo
tools?: Record<string, boolean>
}
export async function resolveRecentPromptContextForSession(
ctx: PluginInput,
sessionID: string
): Promise<PromptContext> {
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: {
model?: ModelInfo
modelID?: string
providerID?: string
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
const model = info?.model
const tools = normalizePromptTools(info?.tools)
if (model?.providerID && model?.modelID) {
return { model: { providerID: model.providerID, modelID: model.modelID }, tools }
}
if (info?.providerID && info?.modelID) {
return { model: { providerID: info.providerID, modelID: info.modelID }, tools }
}
}
} catch {
// ignore - fallback to message storage
}
let currentMessage = null
if (isSqliteBackend()) {
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
}
const model = currentMessage?.model
const tools = normalizePromptTools(currentMessage?.tools)
if (!model?.providerID || !model?.modelID) {
return { tools }
}
return { model: { providerID: model.providerID, modelID: model.modelID }, tools }
}
export async function resolveRecentModelForSession(
ctx: PluginInput,
sessionID: string
): Promise<ModelInfo | undefined> {
const context = await resolveRecentPromptContextForSession(ctx, sessionID)
return context.model
}
@@ -0,0 +1,53 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
export async function resolveActiveBoulderSession(input: {
client: PluginInput["client"]
directory: string
sessionID: string
}): Promise<{
boulderState: BoulderState
progress: PlanProgress
appendedSession: boolean
} | null> {
const boulderState = readBoulderState(input.directory)
if (!boulderState) {
return null
}
const progress = getPlanProgress(boulderState.active_plan)
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
}
if (boulderState.session_ids.includes(input.sessionID)) {
return { boulderState, progress, appendedSession: false }
}
if (!subagentSessions.has(input.sessionID)) {
return null
}
const belongsToActiveBoulder = await isSessionInBoulderLineage({
client: input.client,
sessionID: input.sessionID,
boulderSessionIDs: boulderState.session_ids,
})
if (!belongsToActiveBoulder) {
return null
}
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
return null
}
return {
boulderState: updatedBoulderState,
progress,
appendedSession: true,
}
}
@@ -0,0 +1,46 @@
const { describe, expect, mock, test } = require("bun:test")
mock.module("../../shared", () => ({
getMessageDir: () => null,
isSqliteBackend: () => true,
normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData): TData => response.data ?? fallback,
}))
const { getLastAgentFromSession } = await import("./session-last-agent")
function createMockClient(messages: Array<{ info?: { agent?: string } }>) {
return {
session: {
messages: async () => ({ data: messages }),
},
}
}
describe("getLastAgentFromSession sqlite branch", () => {
test("should skip compaction and return the previous real agent from sqlite messages", async () => {
// given
const client = createMockClient([
{ info: { agent: "atlas" } },
{ info: { agent: "compaction" } },
])
// when
const result = await getLastAgentFromSession("ses_sqlite_compaction", client)
// then
expect(result).toBe("atlas")
})
test("should return null when sqlite history contains only compaction", async () => {
// given
const client = createMockClient([{ info: { agent: "compaction" } }])
// when
const result = await getLastAgentFromSession("ses_sqlite_only_compaction", client)
// then
expect(result).toBeNull()
})
})
export {}
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
type SessionMessagesClient = {
session: {
messages: (input: { path: { id: string } }) => Promise<unknown>
}
}
function isCompactionAgent(agent: unknown): boolean {
return typeof agent === "string" && agent.toLowerCase() === "compaction"
}
function getLastAgentFromMessageDir(messageDir: string): string | null {
try {
const files = readdirSync(messageDir)
.filter((fileName) => fileName.endsWith(".json"))
.sort()
for (let i = files.length - 1; i >= 0; i--) {
const fileName = files[i]
try {
const content = readFileSync(join(messageDir, fileName), "utf-8")
const parsed = JSON.parse(content) as { agent?: unknown }
if (typeof parsed.agent === "string" && !isCompactionAgent(parsed.agent)) {
return parsed.agent.toLowerCase()
}
} catch {
continue
}
}
} catch {
return null
}
return null
}
export async function getLastAgentFromSession(
sessionID: string,
client?: SessionMessagesClient
): Promise<string | null> {
if (isSqliteBackend() && client) {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as Array<{ info?: { agent?: string } }>, {
preferResponseOnMissingData: true,
})
for (let i = messages.length - 1; i >= 0; i--) {
const agent = messages[i].info?.agent
if (typeof agent === "string" && !isCompactionAgent(agent)) {
return agent.toLowerCase()
}
}
return null
}
const messageDir = getMessageDir(sessionID)
if (!messageDir) return null
return getLastAgentFromMessageDir(messageDir)
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Cross-platform check if a path is inside .sisyphus/ directory.
* Handles both forward slashes (Unix) and backslashes (Windows).
* Uses path segment matching (not substring) to avoid false positives like "not-sisyphus/file.txt"
*/
export function isSisyphusPath(filePath: string): boolean {
return /\.sisyphus[/\\]/.test(filePath)
}
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test"
import { extractSessionIdFromOutput } from "./subagent-session-id"
describe("extractSessionIdFromOutput", () => {
test("extracts Session ID blocks from background output", () => {
// given
const output = `Background task launched.\n\nSession ID: ses_bg_12345`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_bg_12345")
})
test("extracts session_id from task metadata blocks", () => {
// given
const output = `Task completed.\n\n<task_metadata>\nsession_id: ses_sync_12345\n</task_metadata>`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_sync_12345")
})
test("extracts hyphenated session IDs from task metadata blocks", () => {
// given
const output = `Task completed.\n\n<task_metadata>\nsession_id: ses_auth-flow-123\n</task_metadata>`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_auth-flow-123")
})
test("returns undefined when no session id is present", () => {
// given
const output = "Task completed without metadata"
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBeUndefined()
})
test("prefers the session id inside the trailing task_metadata block", () => {
// given
const output = `The previous attempt mentioned session_id: ses_wrong_body_123 but that was only context.
<task_metadata>
session_id: ses_real_metadata_456
</task_metadata>`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_real_metadata_456")
})
test("does not let task_metadata parsing bleed into incidental body text after the closing tag", () => {
// given
const output = `<task_metadata>
session_id: ses_real_metadata_456
</task_metadata>
debug log: session_id: ses_wrong_body_789`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_real_metadata_456")
})
})
+44
View File
@@ -0,0 +1,44 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { HOOK_NAME } from "./hook-name"
export function extractSessionIdFromOutput(output: string): string | undefined {
const taskMetadataBlocks = [...output.matchAll(/<task_metadata>([\s\S]*?)<\/task_metadata>/gi)]
const lastTaskMetadataBlock = taskMetadataBlocks.at(-1)?.[1]
if (lastTaskMetadataBlock) {
const taskMetadataSessionMatch = lastTaskMetadataBlock.match(/session_id:\s*(ses_[a-zA-Z0-9_-]+)/i)
if (taskMetadataSessionMatch) {
return taskMetadataSessionMatch[1]
}
}
const explicitSessionMatches = [...output.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_-]+)/g)]
return explicitSessionMatches.at(-1)?.[1]
}
export async function validateSubagentSessionId(input: {
client: PluginInput["client"]
sessionID?: string
lineageSessionIDs: string[]
}): Promise<string | undefined> {
if (!input.sessionID || input.lineageSessionIDs.length === 0) {
return undefined
}
const belongsToLineage = await isSessionInBoulderLineage({
client: input.client,
sessionID: input.sessionID,
boulderSessionIDs: input.lineageSessionIDs,
})
if (!belongsToLineage) {
log(`[${HOOK_NAME}] Ignoring extracted session id outside active lineage`, {
sessionID: input.sessionID,
lineageSessionIDs: input.lineageSessionIDs,
})
return undefined
}
return input.sessionID
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "bun:test"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
describe("BOULDER_CONTINUATION_PROMPT", () => {
describe("checkbox-first priority rules", () => {
it("first rule after RULES: mentions both reading the plan AND marking a still-unchecked completed task", () => {
const rulesSection = BOULDER_CONTINUATION_PROMPT.split("RULES:")[1]!
const firstRule = rulesSection.split("\n")[1]!.trim()
expect(firstRule).toContain("Read the plan")
expect(firstRule).toContain("mark")
expect(firstRule).toContain("completed")
})
it("first rule includes IMMEDIATELY keyword", () => {
const rulesSection = BOULDER_CONTINUATION_PROMPT.split("RULES:")[1]!
const firstRule = rulesSection.split("\n")[1]!.trim()
expect(firstRule).toContain("IMMEDIATELY")
})
it("checkbox-marking guidance appears BEFORE Proceed without asking for permission", () => {
const rulesSection = BOULDER_CONTINUATION_PROMPT.split("RULES:")[1]!
const checkboxMarkingMatch = rulesSection.match(/- \[x\]/i)
const proceedMatch = rulesSection.match(/Proceed without asking for permission/)
expect(checkboxMarkingMatch).not.toBeNull()
expect(proceedMatch).not.toBeNull()
const checkboxPosition = checkboxMarkingMatch!.index
const proceedPosition = proceedMatch!.index
expect(checkboxPosition).toBeLessThan(proceedPosition)
})
})
})
@@ -0,0 +1,238 @@
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:
- **FIRST**: Read the plan file NOW. If the last completed task is still unchecked, mark it \`- [x]\` IMMEDIATELY before anything else
- Proceed without asking for permission
- 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 = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.**
Subagents say "done" when code has errors, tests pass trivially, logic is wrong,
or they quietly added features nobody asked for. This happens EVERY TIME.
Assume the work is broken until YOU prove otherwise.
---
**PHASE 1: READ THE CODE FIRST (before running anything)**
Do NOT run tests yet. Read the code FIRST so you know what you're testing.
1. \`Bash("git diff --stat")\` — see exactly which files changed. Any file outside expected scope = scope creep.
2. \`Read\` EVERY changed file — no exceptions, no skimming.
3. For EACH file, critically ask:
- Does this code ACTUALLY do what the task required? (Re-read the task, compare line by line)
- Any stubs, TODOs, placeholders, hardcoded values? (\`Grep\` for TODO, FIXME, HACK, xxx)
- Logic errors? Trace the happy path AND the error path in your head.
- Anti-patterns? (\`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch, console.log in changed files)
- Scope creep? Did the subagent touch things or add features NOT in the task spec?
4. Cross-check every claim:
- Said "Updated X" — READ X. Actually updated, or just superficially touched?
- Said "Added tests" — READ the tests. Do they test REAL behavior or just \`expect(true).toBe(true)\`?
- Said "Follows patterns" — OPEN a reference file. Does it ACTUALLY match?
**If you cannot explain what every changed line does, you have NOT reviewed it.**
**PHASE 2: RUN AUTOMATED CHECKS (targeted, then broad)**
Now that you understand the code, verify mechanically:
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors
2. Run tests for changed modules FIRST, then full suite
3. Build/typecheck — exit 0
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. The code has bugs that tests don't cover. Fix the code.
**PHASE 3: HANDS-ON QA — ACTUALLY RUN IT (MANDATORY for user-facing changes)**
Tests and linters CANNOT catch: visual bugs, wrong CLI output, broken user flows, API response shape issues.
**If this task produced anything a user would SEE or INTERACT with, you MUST launch it and verify yourself.**
- **Frontend/UI**: \`/playwright\` skill — load the page, click through the flow, check console. Verify: page loads, interactions work, console clean, responsive.
- **TUI/CLI**: \`interactive_bash\` — run the command, try good input, try bad input, try --help. Verify: command runs, output correct, error messages helpful, edge inputs handled.
- **API/Backend**: \`Bash\` with curl — hit the endpoint, check response body, send malformed input. Verify: returns 200, body correct, error cases return proper errors.
- **Config/Build**: Actually start the service or import the config. Verify: loads without error, backward compatible.
This is NOT optional "if applicable". If the deliverable is user-facing and you did not run it, you are shipping untested work.
**PHASE 4: GATE DECISION — Should you proceed to the next task?**
Answer honestly:
1. Can I explain what EVERY changed line does? (If no — back to Phase 1)
2. Did I SEE it work with my own eyes? (If user-facing and no — back to Phase 3)
3. Am I confident nothing existing is broken? (If no — run broader tests)
ALL three must be YES. "Probably" = NO. "I think so" = NO. Investigate until CERTAIN.
- **All 3 YES** — Proceed: mark task complete, move to next.
- **Any NO** — Reject: resume session with \`session_id\`, fix the specific issue.
- **Unsure** — Reject: "unsure" = "no". Investigate until you have a definitive answer.
**DO NOT proceed to the next task until all 4 phases are complete and the gate passes.**`
export const VERIFICATION_REMINDER_GEMINI = `**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
The subagent CLAIMS this task is done. Based on thousands of executions, subagent claims are FALSE more often than true.
They ROUTINELY:
- Ship code with syntax errors they didn't bother to check
- Create stub implementations with TODOs and call it "done"
- Write tests that pass trivially (testing nothing meaningful)
- Implement logic that does NOT match what was requested
- Add features nobody asked for and call it "improvement"
- Report "all tests pass" when they didn't run any tests
**This is NOT a theoretical warning. This WILL happen on this task. Assume the work is BROKEN.**
**YOU MUST VERIFY WITH ACTUAL TOOL CALLS. NOT REASONING. TOOL CALLS.**
Thinking "it looks correct" is NOT verification. Running \`lsp_diagnostics\` IS.
---
**PHASE 1: READ THE CODE FIRST (DO NOT SKIP — DO NOT RUN TESTS YET)**
Read the code FIRST so you know what you're testing.
1. \`Bash("git diff --stat")\` — see exactly which files changed.
2. \`Read\` EVERY changed file — no exceptions, no skimming.
3. For EACH file:
- Does this code ACTUALLY do what the task required? RE-READ the task spec.
- Any stubs, TODOs, placeholders? \`Grep\` for TODO, FIXME, HACK, xxx
- Anti-patterns? \`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch
- Scope creep? Did the subagent add things NOT in the task spec?
4. Cross-check EVERY claim against actual code.
**If you cannot explain what every changed line does, GO BACK AND READ AGAIN.**
**PHASE 2: RUN AUTOMATED CHECKS**
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors. ACTUALLY RUN THIS.
2. Run tests for changed modules, then full suite. ACTUALLY RUN THESE.
3. Build/typecheck — exit 0.
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. Fix the code.
**PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)**
- **Frontend/UI**: \`/playwright\`
- **TUI/CLI**: \`interactive_bash\`
- **API/Backend**: \`Bash\` with curl
**If user-facing and you did not run it, you are shipping UNTESTED BROKEN work.**
**PHASE 4: GATE DECISION**
1. Can I explain what EVERY changed line does? (If no → Phase 1)
2. Did I SEE it work via tool calls? (If user-facing and no → Phase 3)
3. Am I confident nothing is broken? (If no → broader tests)
ALL three must be YES. "Probably" = NO. "I think so" = NO.
**DO NOT proceed to the next task until all 4 phases are complete.**`
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.**
`
+247
View File
@@ -0,0 +1,247 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
appendSessionId,
getPlanProgress,
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
upsertTaskSessionState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { isCallerOrchestrator } from "../../shared/session-utils"
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate"
import { HOOK_NAME } from "./hook-name"
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
import {
buildCompletionGate,
buildFinalWaveApprovalReminder,
buildOrchestratorReminder,
buildStandaloneVerificationReminder,
} from "./verification-reminders"
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
import type { PendingTaskRef, SessionState } from "./types"
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput, TrackedTopLevelTaskRef } from "./types"
function resolvePreferredSessionId(currentSessionId?: string, trackedSessionId?: string): string {
return currentSessionId ?? trackedSessionId ?? "<session_id>"
}
function resolveTaskContext(
pendingTaskRef: PendingTaskRef | undefined,
planPath: string,
): {
currentTask: TrackedTopLevelTaskRef | null
shouldSkipTaskSessionUpdate: boolean
shouldIgnoreCurrentSessionId: boolean
} {
if (!pendingTaskRef) {
return {
currentTask: readCurrentTopLevelTask(planPath),
shouldSkipTaskSessionUpdate: false,
shouldIgnoreCurrentSessionId: false,
}
}
if (pendingTaskRef.kind === "track") {
return {
currentTask: pendingTaskRef.task,
shouldSkipTaskSessionUpdate: false,
shouldIgnoreCurrentSessionId: false,
}
}
if (pendingTaskRef.reason === "explicit_resume") {
return {
currentTask: readCurrentTopLevelTask(planPath),
shouldSkipTaskSessionUpdate: true,
shouldIgnoreCurrentSessionId: true,
}
}
return {
currentTask: pendingTaskRef.task,
shouldSkipTaskSessionUpdate: true,
shouldIgnoreCurrentSessionId: true,
}
}
export function createToolExecuteAfterHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
autoCommit: boolean
getState: (sessionID: string) => SessionState
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!toolOutput) {
return
}
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
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 pendingTaskRef = toolInput.callID ? pendingTaskRefs.get(toolInput.callID) : undefined
if (toolInput.callID) {
pendingTaskRefs.delete(toolInput.callID)
}
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued")
if (isBackgroundLaunch) {
return
}
if (toolOutput.output && typeof toolOutput.output === "string") {
const gitStats = collectGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const extractedSessionId = extractSessionIdFromOutput(toolOutput.output)
const boulderState = readBoulderState(ctx.directory)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
const {
currentTask,
shouldSkipTaskSessionUpdate,
shouldIgnoreCurrentSessionId,
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
const trackedTaskSession = currentTask
? getTaskSessionState(ctx.directory, currentTask.key)
: null
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
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,
})
}
const lineageSessionIDs = toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)
? [...boulderState.session_ids, toolInput.sessionID]
: boulderState.session_ids
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
lineageSessionIDs,
})
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
}
const preferredSessionId = resolvePreferredSessionId(
shouldIgnoreCurrentSessionId ? undefined : subagentSessionId,
trackedTaskSession?.session_id,
)
// Preserve original subagent response - critical for debugging failed tasks
const originalResponse = toolOutput.output
const shouldPauseForApproval = sessionState
? shouldPauseForFinalWaveApproval({
planPath: boulderState.active_plan,
taskOutput: originalResponse,
sessionState,
})
: false
if (sessionState) {
sessionState.waitingForFinalWaveApproval = shouldPauseForApproval
if (shouldPauseForApproval && sessionState.pendingRetryTimer) {
clearTimeout(sessionState.pendingRetryTimer)
sessionState.pendingRetryTimer = undefined
}
}
const leadReminder = shouldPauseForApproval
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
const followupReminder = shouldPauseForApproval
? null
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
toolOutput.output = `
<system-reminder>
${leadReminder}
</system-reminder>
## SUBAGENT WORK COMPLETED
${fileChanges}
---
**Subagent Response:**
${originalResponse}
${
followupReminder === null
? ""
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
}`
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
plan: boulderState.plan_name,
progress: `${progress.completed}/${progress.total}`,
fileCount: gitStats.length,
preferredSessionId,
waitingForFinalWaveApproval: shouldPauseForApproval,
})
} else {
const lineageSessionIDs = toolInput.sessionID ? [toolInput.sessionID] : []
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
lineageSessionIDs,
})
const preferredSessionId = pendingTaskRef?.kind === "skip"
? undefined
: subagentSessionId
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(
resolvePreferredSessionId(preferredSessionId),
)}\n</system-reminder>`
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
sessionID: toolInput.sessionID,
fileCount: gitStats.length,
})
}
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { isCallerOrchestrator } from "../../shared/session-utils"
import type { PluginInput } from "@opencode-ai/plugin"
import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
import { HOOK_NAME } from "./hook-name"
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs } = input
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
pendingTaskRefs.set(callID, { kind: "track", task })
}
return async (toolInput, toolOutput): Promise<void> => {
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
return
}
// Check Write/Edit tools for orchestrator - inject strong warning
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
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") {
if (toolInput.callID) {
const requestedSessionId = toolOutput.args.session_id as string | undefined
if (requestedSessionId) {
pendingTaskRefs.set(toolInput.callID, {
kind: "skip",
reason: "explicit_resume",
})
} else {
const boulderState = readBoulderState(ctx.directory)
const currentTask = boulderState
? readCurrentTopLevelTask(boulderState.active_plan)
: null
if (currentTask) {
const task = {
key: currentTask.key,
label: currentTask.label,
title: currentTask.title,
}
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
))
if (hasExistingClaim) {
pendingTaskRefs.set(toolInput.callID, {
kind: "skip",
reason: "ambiguous_task_key",
task,
})
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
sessionID: toolInput.sessionID,
callID: toolInput.callID,
taskKey: task.key,
})
} else {
trackTask(toolInput.callID, 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,
})
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["bun-types"]
},
"include": ["./**/*.ts", "./**/*.d.ts"],
"exclude": []
}
+44
View File
@@ -0,0 +1,44 @@
import type { AgentOverrides } from "../../config"
import type { BackgroundManager } from "../../features/background-agent"
import type { TopLevelTaskRef } from "../../features/boulder-state"
export type ModelInfo = { providerID: string; modelID: string }
export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
isContinuationStopped?: (sessionID: string) => boolean
agentOverrides?: AgentOverrides
/** Enable auto-commit after each atomic task completion (default: true) */
autoCommit?: boolean
}
export interface ToolExecuteAfterInput {
tool: string
sessionID?: string
callID?: string
}
export interface ToolExecuteAfterOutput {
title: string
output: string
metadata: Record<string, unknown>
}
export type TrackedTopLevelTaskRef = Pick<TopLevelTaskRef, "key" | "label" | "title">
export type PendingTaskRef =
| { kind: "track"; task: TrackedTopLevelTaskRef }
| { kind: "skip"; reason: "explicit_resume" }
| { kind: "skip"; reason: "ambiguous_task_key"; task: TrackedTopLevelTaskRef }
export interface SessionState {
lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number
promptFailureCount: number
lastFailureAt?: number
pendingRetryTimer?: ReturnType<typeof setTimeout>
waitingForFinalWaveApproval?: boolean
pendingFinalWaveTaskCount?: number
approvedFinalWaveTaskCount?: number
}
@@ -0,0 +1,94 @@
import { describe, expect, it } from "bun:test"
import { buildOrchestratorReminder, buildCompletionGate } from "./verification-reminders"
// Test helpers for given/when/then pattern
const given = describe
const when = describe
const then = it
describe("buildCompletionGate", () => {
given("a plan name and session id", () => {
const planName = "test-plan"
const sessionId = "test-session-123"
when("buildCompletionGate is called", () => {
const gate = buildCompletionGate(planName, sessionId)
then("completion gate text is present", () => {
expect(gate).toContain("COMPLETION GATE")
})
then("gate appears before verification phase text", () => {
const gateIndex = gate.indexOf("COMPLETION GATE")
const verificationIndex = gate.indexOf("VERIFICATION_REMINDER")
expect(gateIndex).toBeLessThan(verificationIndex)
})
then("gate interpolates the plan name path", () => {
expect(gate).toContain(planName)
expect(gate).toContain(`.sisyphus/plans/${planName}.md`)
})
then("gate includes Edit instructions", () => {
expect(gate.toLowerCase()).toContain("edit")
})
then("gate includes Read instructions", () => {
expect(gate.toLowerCase()).toContain("read")
})
then("old STEP 7 MARK COMPLETION text is absent", () => {
expect(gate).not.toContain("STEP 7")
expect(gate).not.toContain("MARK COMPLETION IN PLAN FILE")
})
then("step numbering remains consecutive after removal", () => {
const stepMatches = gate.match(/STEP \d+:/g) ?? []
if (stepMatches.length > 1) {
const numbers = stepMatches.map((s: string) => parseInt(s.match(/\d+/)?.[0] ?? "0"))
for (let i = 1; i < numbers.length; i++) {
expect(numbers[i]).toBe(numbers[i - 1] + 1)
}
}
})
})
})
})
describe("buildOrchestratorReminder", () => {
given("progress with completed tasks", () => {
const planName = "my-test-plan"
const sessionId = "session-abc"
const progress = { total: 10, completed: 3 }
when("buildOrchestratorReminder is called with autoCommit true", () => {
const reminder = buildOrchestratorReminder(planName, progress, sessionId, true)
then("old STEP 7 MARK COMPLETION IN PLAN FILE text is absent", () => {
expect(reminder).not.toContain("STEP 7: MARK COMPLETION IN PLAN FILE")
})
then("completion gate appears before verification reminder", () => {
const gateIndex = reminder.indexOf("COMPLETION GATE")
const verificationIndex = reminder.indexOf("VERIFICATION_REMINDER")
expect(gateIndex).toBeGreaterThanOrEqual(0)
expect(gateIndex).toBeLessThan(verificationIndex)
})
})
when("buildOrchestratorReminder is called with autoCommit false", () => {
const reminder = buildOrchestratorReminder(planName, progress, sessionId, false)
then("old STEP 7 MARK COMPLETION IN PLAN FILE text is absent", () => {
expect(reminder).not.toContain("STEP 7: MARK COMPLETION IN PLAN FILE")
})
then("completion gate appears before verification reminder", () => {
const gateIndex = reminder.indexOf("COMPLETION GATE")
const verificationIndex = reminder.indexOf("VERIFICATION_REMINDER")
expect(gateIndex).toBeGreaterThanOrEqual(0)
expect(gateIndex).toBeLessThan(verificationIndex)
})
})
})
})
+197
View File
@@ -0,0 +1,197 @@
import { VERIFICATION_REMINDER } from "./system-reminder-templates"
function buildReuseHint(sessionId: string): string {
return `
**PREFERRED REUSE SESSION FOR THE CURRENT TOP-LEVEL PLAN TASK**
- Reuse \`${sessionId}\` first if verification fails or the result needs follow-up.
- Start a fresh subagent session only when reuse is unavailable or would cross task boundaries.
`
}
export function buildCompletionGate(planName: string, sessionId: string): string {
return `
**COMPLETION GATE — DO NOT PROCEED UNTIL THIS IS DONE**
Your completion will NOT be recorded until you complete ALL of the following:
1. **Edit** the plan file \`.sisyphus/plans/${planName}.md\`:
- Change \`- [ ]\` to \`- [x]\` for the completed task
- Use \`Edit\` tool to modify the checkbox
2. **Read** the plan file AGAIN:
\`\`\`
Read(".sisyphus/plans/${planName}.md")
\`\`\`
- Verify the checkbox count changed (more \`- [x]\` than before)
3. **DO NOT call \`task()\` again** until you have completed steps 1 and 2 above.
If anything fails while closing this out, resume the same session immediately:
\`\`\`typescript
task(session_id="${sessionId}", prompt="fix: checkbox not recorded correctly")
\`\`\`
**Your completion is NOT tracked until the checkbox is marked in the plan file.**
**VERIFICATION_REMINDER**
${buildReuseHint(sessionId)}`
}
function buildVerificationReminder(sessionId: string): string {
return `**VERIFICATION_REMINDER**
${VERIFICATION_REMINDER}
---
**If ANY verification fails, use this immediately:**
\`\`\`
task(session_id="${sessionId}", prompt="fix: [describe the specific failure]")
\`\`\`
${buildReuseHint(sessionId)}`
}
export function buildOrchestratorReminder(
planName: string,
progress: { total: number; completed: number },
sessionId: string,
autoCommit: boolean = true,
includeCompletionGate: boolean = true
): string {
const remaining = progress.total - progress.completed
const commitStep = autoCommit
? `
**STEP 7: COMMIT ATOMIC UNIT**
- Stage ONLY the verified changes
- Commit with clear message describing what was done
`
: ""
const nextStepNumber = autoCommit ? 8 : 7
return `
---
**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining
---
${includeCompletionGate ? `${buildCompletionGate(planName, sessionId)}
` : ""}${buildVerificationReminder(sessionId)}
**STEP 5: READ SUBAGENT NOTEPAD (LEARNINGS, ISSUES, PROBLEMS)**
The subagent was instructed to record findings in notepad files. Read them NOW:
\`\`\`
Glob(".sisyphus/notepads/${planName}/*.md")
\`\`\`
Then \`Read\` each file found — especially:
- **learnings.md**: Patterns, conventions, successful approaches discovered
- **issues.md**: Problems, blockers, gotchas encountered during work
- **problems.md**: Unresolved issues, technical debt flagged
**USE this information to:**
- Inform your next delegation (avoid known pitfalls)
- Adjust your plan if blockers were discovered
- Propagate learnings to subsequent subagents
**STEP 6: CHECK BOULDER STATE DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
Do NOT rely on cached progress. Read the plan file NOW:
\`\`\`
Read(".sisyphus/plans/${planName}.md")
\`\`\`
Count exactly: how many \`- [ ]\` remain? How many \`- [x]\` completed?
This is YOUR ground truth. Use it to decide what comes next.
${commitStep}
**STEP ${nextStepNumber}: PROCEED TO NEXT TASK**
- Read the plan file AGAIN to identify the next \`- [ ]\` task
- Start immediately - DO NOT STOP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**${remaining} tasks remain. Keep bouldering.**`
}
export function buildFinalWaveApprovalReminder(
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)}
**FINAL WAVE APPROVAL GATE**
The last Final Verification Wave result just passed.
This is the ONLY point where approval-style user interaction is required.
1. Read \
\`.sisyphus/plans/${planName}.md\` again and confirm every remaining unchecked **top-level** task belongs to F1-F4.
Ignore nested checkboxes under Acceptance Criteria, Evidence, or Final Checklist sections.
2. Consolidate the F1-F4 verdicts into a short summary for the user.
3. Tell the user all final reviewers approved.
4. Ask for explicit user approval before editing any remaining final-wave checkboxes or marking the plan complete.
5. Wait for the user's explicit approval. Do NOT auto-continue. Do NOT call \
\`task()\` again unless the user rejects and requests fixes.
If the user rejects or requests changes:
- delegate the required fix
- re-run the affected final-wave reviewer
- present the updated results again
- wait again for explicit user approval
**DO NOT mark the final-wave checkbox complete until the user explicitly says okay.**`
}
export function buildStandaloneVerificationReminder(sessionId: string): string {
return `
---
${buildVerificationReminder(sessionId)}
**STEP 5: CHECK YOUR PROGRESS DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
Do NOT rely on memory or cached state. Run \`todoread\` NOW to see exact current state.
Count pending vs completed tasks. This is your ground truth for what comes next.
**STEP 6: 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 7: 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 8: PROCEED TO NEXT PENDING TASK**
- Run \`todoread\` AGAIN to identify the next \`pending\` task
- 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)
}
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { AUTO_SLASH_COMMAND_TAG_OPEN } from "./constants"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
CommandExecuteBeforeInput,
CommandExecuteBeforeOutput,
} from "./types"
import * as shared from "../../shared"
const executeSlashCommandMock = mock(
async (parsed: { command: string; args: string; raw: string }) => ({
success: true,
replacementText: parsed.raw,
})
)
mock.module("./executor", () => ({
executeSlashCommand: executeSlashCommandMock,
}))
const logMock = spyOn(shared, "log").mockImplementation(() => {})
const { createAutoSlashCommandHook } = await import("./hook")
function createChatInput(sessionID: string, messageID: string): AutoSlashCommandHookInput {
return {
sessionID,
messageID,
}
}
function createChatOutput(text: string): AutoSlashCommandHookOutput {
return {
message: {},
parts: [{ type: "text", text }],
}
}
function createCommandInput(sessionID: string, command: string): CommandExecuteBeforeInput {
return {
sessionID,
command,
arguments: "",
}
}
function createCommandOutput(text: string): CommandExecuteBeforeOutput {
return {
parts: [{ type: "text", text }],
}
}
describe("createAutoSlashCommandHook leak prevention", () => {
beforeEach(() => {
executeSlashCommandMock.mockClear()
logMock.mockClear()
})
describe("#given hook with sessionProcessedCommandExecutions", () => {
describe("#when same command executed twice after fallback dedup window", () => {
it("#then second execution is treated as intentional rerun", async () => {
//#given
const nowSpy = spyOn(Date, "now")
try {
const hook = createAutoSlashCommandHook()
const input = createCommandInput("session-dedup", "leak-test-command")
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
//#when
nowSpy.mockReturnValue(0)
await hook["command.execute.before"](input, firstOutput)
nowSpy.mockReturnValue(101)
await hook["command.execute.before"](input, secondOutput)
//#then
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(secondOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
} finally {
nowSpy.mockRestore()
}
})
})
describe("#when same command is repeated within fallback dedup window", () => {
it("#then duplicate dispatch is suppressed", async () => {
//#given
const nowSpy = spyOn(Date, "now")
try {
const hook = createAutoSlashCommandHook()
const input = createCommandInput("session-dedup", "leak-test-command")
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
//#when
nowSpy.mockReturnValue(0)
await hook["command.execute.before"](input, firstOutput)
nowSpy.mockReturnValue(99)
await hook["command.execute.before"](input, secondOutput)
//#then
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(secondOutput.parts[0].text).toBe("second")
} finally {
nowSpy.mockRestore()
}
})
})
describe("#when same event identifier is dispatched twice", () => {
it("#then second dispatch is deduplicated regardless of elapsed seconds", async () => {
//#given
const nowSpy = spyOn(Date, "now")
try {
const hook = createAutoSlashCommandHook()
const input: CommandExecuteBeforeInput = {
...createCommandInput("session-dedup", "leak-test-command"),
eventID: "event-1",
}
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
//#when
nowSpy.mockReturnValue(0)
await hook["command.execute.before"](input, firstOutput)
nowSpy.mockReturnValue(29_999)
await hook["command.execute.before"](input, secondOutput)
//#then
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(secondOutput.parts[0].text).toBe("second")
} finally {
nowSpy.mockRestore()
}
})
})
})
describe("#given hook with entries from multiple sessions", () => {
describe("#when dispose() is called", () => {
it("#then both Sets are empty", async () => {
const hook = createAutoSlashCommandHook()
await hook["chat.message"](
createChatInput("session-chat", "message-chat"),
createChatOutput("/leak-chat")
)
await hook["command.execute.before"](
createCommandInput("session-command", "leak-command"),
createCommandOutput("before")
)
executeSlashCommandMock.mockClear()
hook.dispose()
const chatOutputAfterDispose = createChatOutput("/leak-chat")
const commandOutputAfterDispose = createCommandOutput("after")
await hook["chat.message"](
createChatInput("session-chat", "message-chat"),
chatOutputAfterDispose
)
await hook["command.execute.before"](
createCommandInput("session-command", "leak-command"),
commandOutputAfterDispose
)
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
expect(chatOutputAfterDispose.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(commandOutputAfterDispose.parts[0].text).toContain(
AUTO_SLASH_COMMAND_TAG_OPEN
)
})
})
})
describe("#given Set with more than 10000 entries", () => {
describe("#when new entry added", () => {
it("#then Set size is reduced", async () => {
const hook = createAutoSlashCommandHook()
const oldestInput = createChatInput("session-oldest", "message-oldest")
await hook["chat.message"](oldestInput, createChatOutput("/leak-oldest"))
for (let index = 0; index < 10000; index += 1) {
await hook["chat.message"](
createChatInput(`session-${index}`, `message-${index}`),
createChatOutput(`/leak-${index}`)
)
}
const newestInput = createChatInput("session-newest", "message-newest")
await hook["chat.message"](newestInput, createChatOutput("/leak-newest"))
executeSlashCommandMock.mockClear()
const oldestRetryOutput = createChatOutput("/leak-oldest")
const newestRetryOutput = createChatOutput("/leak-newest")
await hook["chat.message"](oldestInput, oldestRetryOutput)
await hook["chat.message"](newestInput, newestRetryOutput)
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
expect(oldestRetryOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(newestRetryOutput.parts[0].text).toBe("/leak-newest")
})
})
})
})
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test"
import { parseSlashCommand } from "./detector"
describe("slash command parsing pattern", () => {
describe("#given plugin namespace includes dot", () => {
it("#then parses command name with dot and colon", () => {
// given
const text = "/my.plugin:run ship"
// when
const parsed = parseSlashCommand(text)
// then
expect(parsed).not.toBeNull()
expect(parsed?.command).toBe("my.plugin:run")
expect(parsed?.args).toBe("ship")
})
})
})
+1 -1
View File
@@ -3,7 +3,7 @@ export const HOOK_NAME = "auto-slash-command" as const
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w.:@/-]*)\s*(.*)/
export const EXCLUDED_COMMANDS = new Set([
"ralph-loop",
+87 -74
View File
@@ -10,150 +10,163 @@ import {
describe("auto-slash-command detector", () => {
describe("removeCodeBlocks", () => {
it("should remove markdown code blocks", () => {
// #given text with code blocks
// given text with code blocks
const text = "Hello ```code here``` world"
// #when removing code blocks
// when removing code blocks
const result = removeCodeBlocks(text)
// #then code blocks should be removed
// then code blocks should be removed
expect(result).toBe("Hello world")
})
it("should remove multiline code blocks", () => {
// #given text with multiline code blocks
// given text with multiline code blocks
const text = `Before
\`\`\`javascript
/command-inside-code
\`\`\`
After`
// #when removing code blocks
// when removing code blocks
const result = removeCodeBlocks(text)
// #then code blocks should be removed
// then code blocks should be removed
expect(result).toContain("Before")
expect(result).toContain("After")
expect(result).not.toContain("/command-inside-code")
})
it("should handle text without code blocks", () => {
// #given text without code blocks
// given text without code blocks
const text = "Just regular text"
// #when removing code blocks
// when removing code blocks
const result = removeCodeBlocks(text)
// #then text should remain unchanged
// then text should remain unchanged
expect(result).toBe("Just regular text")
})
})
describe("parseSlashCommand", () => {
it("should parse simple command without args", () => {
// #given a simple slash command
// given a simple slash command
const text = "/commit"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract command correctly
// then should extract command correctly
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
expect(result?.args).toBe("")
})
it("should parse command with arguments", () => {
// #given a slash command with arguments
// given a slash command with arguments
const text = "/plan create a new feature for auth"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract command and args
// then should extract command and args
expect(result).not.toBeNull()
expect(result?.command).toBe("plan")
expect(result?.args).toBe("create a new feature for auth")
})
it("should parse command with quoted arguments", () => {
// #given a slash command with quoted arguments
// given a slash command with quoted arguments
const text = '/execute "build the API"'
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract command and args
// then should extract command and args
expect(result).not.toBeNull()
expect(result?.command).toBe("execute")
expect(result?.args).toBe('"build the API"')
})
it("should parse command with hyphen in name", () => {
// #given a slash command with hyphen
// given a slash command with hyphen
const text = "/frontend-template-creator project"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract full command name
// then should extract full command name
expect(result).not.toBeNull()
expect(result?.command).toBe("frontend-template-creator")
expect(result?.args).toBe("project")
})
it("should return null for non-slash text", () => {
// #given text without slash
const text = "regular text"
it("should parse namespaced marketplace commands", () => {
// given a namespaced command
const text = "/daplug:run-prompt build bridge"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null
// then should keep full namespaced command
expect(result).not.toBeNull()
expect(result?.command).toBe("daplug:run-prompt")
expect(result?.args).toBe("build bridge")
})
it("should return null for non-slash text", () => {
// given text without slash
const text = "regular text"
// when parsing
const result = parseSlashCommand(text)
// then should return null
expect(result).toBeNull()
})
it("should return null for slash not at start", () => {
// #given text with slash in middle
// given text with slash in middle
const text = "some text /command"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null (slash not at start)
// then should return null (slash not at start)
expect(result).toBeNull()
})
it("should return null for just a slash", () => {
// #given just a slash
// given just a slash
const text = "/"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null
// then should return null
expect(result).toBeNull()
})
it("should return null for slash followed by number", () => {
// #given slash followed by number
// given slash followed by number
const text = "/123"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null (command must start with letter)
// then should return null (command must start with letter)
expect(result).toBeNull()
})
it("should handle whitespace before slash", () => {
// #given command with leading whitespace
// given command with leading whitespace
const text = " /commit"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should parse after trimming
// then should parse after trimming
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
})
@@ -161,31 +174,31 @@ After`
describe("isExcludedCommand", () => {
it("should exclude ralph-loop", () => {
// #given ralph-loop command
// #when checking exclusion
// #then should be excluded
// given ralph-loop command
// when checking exclusion
// then should be excluded
expect(isExcludedCommand("ralph-loop")).toBe(true)
})
it("should exclude cancel-ralph", () => {
// #given cancel-ralph command
// #when checking exclusion
// #then should be excluded
// given cancel-ralph command
// when checking exclusion
// then should be excluded
expect(isExcludedCommand("cancel-ralph")).toBe(true)
})
it("should be case-insensitive for exclusion", () => {
// #given uppercase variants
// #when checking exclusion
// #then should still be excluded
// given uppercase variants
// when checking exclusion
// then should still be excluded
expect(isExcludedCommand("RALPH-LOOP")).toBe(true)
expect(isExcludedCommand("Cancel-Ralph")).toBe(true)
})
it("should not exclude regular commands", () => {
// #given regular commands
// #when checking exclusion
// #then should not be excluded
// given regular commands
// when checking exclusion
// then should not be excluded
expect(isExcludedCommand("commit")).toBe(false)
expect(isExcludedCommand("plan")).toBe(false)
expect(isExcludedCommand("execute")).toBe(false)
@@ -194,102 +207,102 @@ After`
describe("detectSlashCommand", () => {
it("should detect slash command in plain text", () => {
// #given plain text with slash command
// given plain text with slash command
const text = "/commit fix typo"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should detect
// then should detect
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
expect(result?.args).toBe("fix typo")
})
it("should NOT detect slash command inside code block", () => {
// #given slash command inside code block
// given slash command inside code block
const text = "```bash\n/command\n```"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should not detect (only code block content)
// then should not detect (only code block content)
expect(result).toBeNull()
})
it("should detect command when text has code blocks elsewhere", () => {
// #given slash command before code block
// given slash command before code block
const text = "/commit fix\n```code```"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should detect the command
// then should detect the command
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
})
it("should NOT detect excluded commands", () => {
// #given excluded command
// given excluded command
const text = "/ralph-loop do something"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should not detect
// then should not detect
expect(result).toBeNull()
})
it("should return null for non-command text", () => {
// #given regular text
// given regular text
const text = "Just some regular text"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should return null
// then should return null
expect(result).toBeNull()
})
})
describe("extractPromptText", () => {
it("should extract text from parts", () => {
// #given message parts
// given message parts
const parts = [
{ type: "text", text: "Hello " },
{ type: "tool_use", id: "123" },
{ type: "text", text: "world" },
]
// #when extracting
// when extracting
const result = extractPromptText(parts)
// #then should join text parts
// then should join text parts
expect(result).toBe("Hello world")
})
it("should handle empty parts", () => {
// #given empty parts
// given empty parts
const parts: Array<{ type: string; text?: string }> = []
// #when extracting
// when extracting
const result = extractPromptText(parts)
// #then should return empty string
// then should return empty string
expect(result).toBe("")
})
it("should handle parts without text", () => {
// #given parts without text content
// given parts without text content
const parts = [
{ type: "tool_use", id: "123" },
{ type: "tool_result", output: "result" },
]
// #when extracting
// when extracting
const result = extractPromptText(parts)
// #then should return empty string
// then should return empty string
expect(result).toBe("")
})
})
+27 -4
View File
@@ -58,8 +58,31 @@ export function detectSlashCommand(text: string): ParsedSlashCommand | null {
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join(" ")
const textParts = parts.filter((p) => p.type === "text")
const slashPart = textParts.find((p) => (p.text ?? "").trim().startsWith("/"))
if (slashPart?.text) {
return slashPart.text
}
const nonSyntheticParts = textParts.filter(
(p) => !(p as { synthetic?: boolean }).synthetic
)
if (nonSyntheticParts.length > 0) {
return nonSyntheticParts.map((p) => p.text || "").join(" ")
}
return textParts.map((p) => p.text || "").join(" ")
}
export function findSlashCommandPartIndex(
parts: Array<{ type: string; text?: string }>
): number {
for (let idx = 0; idx < parts.length; idx += 1) {
const part = parts[idx]
if (part.type !== "text") continue
if ((part.text ?? "").trim().startsWith("/")) {
return idx
}
}
return -1
}
@@ -0,0 +1,98 @@
import { describe, expect, it, mock } from "bun:test"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
mock.module("../../shared", () => ({
resolveCommandsInText: async (content: string) => content,
resolveFileReferencesInText: async (content: string) => content,
}))
mock.module("../../tools/slashcommand", () => ({
discoverCommandsSync: () => [
{
name: "shadowed",
metadata: { name: "shadowed", description: "builtin" },
content: "builtin template",
scope: "builtin",
},
{
name: "shadowed",
metadata: { name: "shadowed", description: "project" },
content: "project template",
scope: "project",
},
],
}))
mock.module("../../features/opencode-skill-loader", () => ({
discoverAllSkills: async (): Promise<LoadedSkill[]> => [],
}))
const { executeSlashCommand } = await import("./executor")
function createRestrictedSkill(): LoadedSkill {
return {
name: "restricted-skill",
definition: {
name: "restricted-skill",
description: "restricted",
template: "restricted template",
agent: "hephaestus",
},
scope: "user",
}
}
describe("executeSlashCommand resolution semantics", () => {
it("returns project command when project and builtin names collide", async () => {
//#given
const parsed = {
command: "shadowed",
args: "",
raw: "/shadowed",
}
//#when
const result = await executeSlashCommand(parsed, { skills: [] })
//#then
expect(result.success).toBe(true)
expect(result.replacementText).toContain("**Scope**: project")
expect(result.replacementText).toContain("project template")
expect(result.replacementText).not.toContain("builtin template")
})
it("blocks slash skill invocation when invoking agent is missing", async () => {
//#given
const parsed = {
command: "restricted-skill",
args: "",
raw: "/restricted-skill",
}
//#when
const result = await executeSlashCommand(parsed, { skills: [createRestrictedSkill()] })
//#then
expect(result.success).toBe(false)
expect(result.error).toBe('Skill "restricted-skill" is restricted to agent "hephaestus"')
})
it("allows slash skill invocation when invoking agent matches restriction", async () => {
//#given
const parsed = {
command: "restricted-skill",
args: "",
raw: "/restricted-skill",
}
//#when
const result = await executeSlashCommand(parsed, {
skills: [createRestrictedSkill()],
agent: "hephaestus",
})
//#then
expect(result.success).toBe(true)
expect(result.replacementText).toContain("restricted template")
})
})
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { executeSlashCommand } from "./executor"
const ENV_KEYS = [
"CLAUDE_CONFIG_DIR",
"CLAUDE_PLUGINS_HOME",
"CLAUDE_SETTINGS_PATH",
"OPENCODE_CONFIG_DIR",
] as const
type EnvKey = (typeof ENV_KEYS)[number]
type EnvSnapshot = Record<EnvKey, string | undefined>
function writePluginFixture(baseDir: string): void {
const claudeConfigDir = join(baseDir, "claude-config")
const pluginsHome = join(claudeConfigDir, "plugins")
const settingsPath = join(claudeConfigDir, "settings.json")
const opencodeConfigDir = join(baseDir, "opencode-config")
const pluginInstallPath = join(baseDir, "installed-plugins", "daplug")
const pluginKey = "daplug@1.0.0"
mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true })
mkdirSync(join(pluginInstallPath, "commands"), { recursive: true })
writeFileSync(
join(pluginInstallPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2),
)
writeFileSync(
join(pluginInstallPath, "commands", "run-prompt.md"),
`---
description: Run prompt from daplug
---
Execute daplug prompt flow.
`,
)
writeFileSync(
join(pluginInstallPath, "commands", "templated.md"),
`---
description: Templated prompt from daplug
---
Echo $ARGUMENTS and \${user_message}.
`,
)
mkdirSync(pluginsHome, { recursive: true })
writeFileSync(
join(pluginsHome, "installed_plugins.json"),
JSON.stringify(
{
version: 2,
plugins: {
[pluginKey]: [
{
scope: "user",
installPath: pluginInstallPath,
version: "1.0.0",
installedAt: "2026-01-01T00:00:00.000Z",
lastUpdated: "2026-01-01T00:00:00.000Z",
},
],
},
},
null,
2,
),
)
mkdirSync(claudeConfigDir, { recursive: true })
writeFileSync(
settingsPath,
JSON.stringify(
{
enabledPlugins: {
[pluginKey]: true,
},
},
null,
2,
),
)
mkdirSync(opencodeConfigDir, { recursive: true })
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
process.env.CLAUDE_PLUGINS_HOME = pluginsHome
process.env.CLAUDE_SETTINGS_PATH = settingsPath
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
}
describe("auto-slash command executor plugin dispatch", () => {
let tempDir = ""
let envSnapshot: EnvSnapshot
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-"))
envSnapshot = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME,
CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
}
writePluginFixture(tempDir)
})
afterEach(() => {
for (const key of ENV_KEYS) {
const previousValue = envSnapshot[key]
if (previousValue === undefined) {
delete process.env[key]
} else {
process.env[key] = previousValue
}
}
rmSync(tempDir, { recursive: true, force: true })
})
it("resolves marketplace plugin commands when plugin loading is enabled", async () => {
const result = await executeSlashCommand(
{
command: "daplug:run-prompt",
args: "ship it",
raw: "/daplug:run-prompt ship it",
},
{
skills: [],
pluginsEnabled: true,
},
)
expect(result.success).toBe(true)
expect(result.replacementText).toContain("# /daplug:run-prompt Command")
expect(result.replacementText).toContain("**Scope**: plugin")
})
it("excludes marketplace commands when plugins are disabled via config toggle", async () => {
const result = await executeSlashCommand(
{
command: "daplug:run-prompt",
args: "",
raw: "/daplug:run-prompt",
},
{
skills: [],
pluginsEnabled: false,
},
)
expect(result.success).toBe(false)
expect(result.error).toBe(
'Command "/daplug:run-prompt" not found. Use the skill tool to list available skills and commands.',
)
})
it("returns standard not-found for unknown namespaced commands", async () => {
const result = await executeSlashCommand(
{
command: "daplug:missing",
args: "",
raw: "/daplug:missing",
},
{
skills: [],
pluginsEnabled: true,
},
)
expect(result.success).toBe(false)
expect(result.error).toBe(
'Command "/daplug:missing" not found. Use the skill tool to list available skills and commands.',
)
expect(result.error).not.toContain("Marketplace plugin commands")
})
it("replaces $ARGUMENTS placeholders in plugin command templates", async () => {
const result = await executeSlashCommand(
{
command: "daplug:templated",
args: "ship it",
raw: "/daplug:templated ship it",
},
{
skills: [],
pluginsEnabled: true,
},
)
expect(result.success).toBe(true)
expect(result.replacementText).toContain("Echo ship it and ship it.")
expect(result.replacementText).not.toContain("$ARGUMENTS")
expect(result.replacementText).not.toContain("${user_message}")
})
})
+40 -82
View File
@@ -1,84 +1,25 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path"
import { dirname } from "path"
import {
parseFrontmatter,
resolveCommandsInText,
resolveFileReferencesInText,
sanitizeModelField,
getClaudeConfigDir,
getOpenCodeConfigDir,
} from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
import { discoverCommandsSync } from "../../tools/slashcommand"
import type { CommandInfo as DiscoveredCommandInfo, CommandMetadata } from "../../tools/slashcommand/types"
import type { ParsedSlashCommand } from "./types"
interface CommandScope {
type: "user" | "project" | "opencode" | "opencode-project" | "skill"
}
interface CommandMetadata {
name: string
description: string
argumentHint?: string
model?: string
agent?: string
subtask?: boolean
}
interface CommandInfo {
interface SkillCommandInfo {
name: string
path?: string
metadata: CommandMetadata
content?: string
scope: CommandScope["type"]
scope: "skill"
lazyContentLoader?: LazyContentLoader
}
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
if (!existsSync(commandsDir)) {
return []
}
type CommandInfo = DiscoveredCommandInfo | SkillCommandInfo
const entries = readdirSync(commandsDir, { withFileTypes: true })
const commands: CommandInfo[] = []
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
try {
const content = readFileSync(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const metadata: CommandMetadata = {
name: commandName,
description: data.description || "",
argumentHint: data["argument-hint"],
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
agent: data.agent,
subtask: Boolean(data.subtask),
}
commands.push({
name: commandName,
path: commandPath,
metadata,
content: body,
scope,
})
} catch {
continue
}
}
return commands
}
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
function skillToCommandInfo(skill: LoadedSkill): SkillCommandInfo {
return {
name: skill.name,
path: skill.path,
@@ -98,29 +39,33 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
export interface ExecutorOptions {
skills?: LoadedSkill[]
pluginsEnabled?: boolean
enabledPluginsOverride?: Record<string, boolean>
agent?: string
}
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(configDir, "command")
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
const discoveredCommands = discoverCommandsSync(process.cwd(), {
pluginsEnabled: options?.pluginsEnabled,
enabledPluginsOverride: options?.enabledPluginsOverride,
})
const skills = options?.skills ?? await discoverAllSkills()
const skillCommands = skills.map(skillToCommandInfo)
const scopeOrder: DiscoveredCommandInfo["scope"][] = ["project", "user", "opencode-project", "opencode", "builtin", "plugin"]
const grouped = new Map<string, DiscoveredCommandInfo[]>()
for (const cmd of discoveredCommands) {
const list = grouped.get(cmd.scope) ?? []
list.push(cmd)
grouped.set(cmd.scope, list)
}
const orderedCommands = scopeOrder.flatMap((scope) => grouped.get(scope) ?? [])
return [
...opencodeProjectCommands,
...projectCommands,
...opencodeGlobalCommands,
...userCommands,
...skillCommands,
...orderedCommands,
]
}
@@ -164,7 +109,11 @@ async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<st
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
sections.push(resolvedContent.trim())
const resolvedArguments = args
const substitutedContent = resolvedContent
.replace(/\$\{user_message\}/g, resolvedArguments)
.replace(/\$ARGUMENTS/g, resolvedArguments)
sections.push(substitutedContent.trim())
if (args) {
sections.push("\n\n---\n")
@@ -187,7 +136,16 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
if (!command) {
return {
success: false,
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
error: `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`,
}
}
if (command.scope === "skill" && command.metadata.agent) {
if (!options?.agent || command.metadata.agent !== options.agent) {
return {
success: false,
error: `Skill "${command.name}" is restricted to agent "${command.metadata.agent}"`,
}
}
}
+236
View File
@@ -0,0 +1,236 @@
import {
detectSlashCommand,
extractPromptText,
findSlashCommandPartIndex,
} from "./detector"
import { executeSlashCommand, type ExecutorOptions } from "./executor"
import { log } from "../../shared"
import {
AUTO_SLASH_COMMAND_TAG_CLOSE,
AUTO_SLASH_COMMAND_TAG_OPEN,
} from "./constants"
import { createProcessedCommandStore } from "./processed-command-store"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
CommandExecuteBeforeInput,
CommandExecuteBeforeOutput,
} from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
const COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS = 100
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getDeletedSessionID(properties: unknown): string | null {
if (!isRecord(properties)) {
return null
}
const info = properties.info
if (!isRecord(info)) {
return null
}
return typeof info.id === "string" ? info.id : null
}
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
const candidateKeys = [
"messageID",
"messageId",
"eventID",
"eventId",
"invocationID",
"invocationId",
"commandID",
"commandId",
]
const recordInput = input as unknown
if (!isRecord(recordInput)) {
return null
}
for (const key of candidateKeys) {
const candidateValue = recordInput[key]
if (typeof candidateValue === "string" && candidateValue.length > 0) {
return candidateValue
}
}
return null
}
export interface AutoSlashCommandHookOptions {
skills?: LoadedSkill[]
pluginsEnabled?: boolean
enabledPluginsOverride?: Record<string, boolean>
}
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
const executorOptions: ExecutorOptions = {
skills: options?.skills,
pluginsEnabled: options?.pluginsEnabled,
enabledPluginsOverride: options?.enabledPluginsOverride,
}
const sessionProcessedCommands = createProcessedCommandStore()
const sessionProcessedCommandExecutions = createProcessedCommandStore()
const dispose = (): void => {
sessionProcessedCommands.clear()
sessionProcessedCommandExecutions.clear()
}
return {
"chat.message": async (
input: AutoSlashCommandHookInput,
output: AutoSlashCommandHookOutput
): Promise<void> => {
const promptText = extractPromptText(output.parts)
// Debug logging to diagnose slash command issues
if (promptText.startsWith("/")) {
log(`[auto-slash-command] chat.message hook received slash command`, {
sessionID: input.sessionID,
promptText: promptText.slice(0, 100),
})
}
if (
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
) {
return
}
const parsed = detectSlashCommand(promptText)
if (!parsed) {
return
}
const commandKey = input.messageID
? `${input.sessionID}:${input.messageID}:${parsed.command}`
: `${input.sessionID}:${parsed.command}`
if (sessionProcessedCommands.has(commandKey)) {
return
}
sessionProcessedCommands.add(commandKey)
log(`[auto-slash-command] Detected: /${parsed.command}`, {
sessionID: input.sessionID,
args: parsed.args,
})
const executionOptions: ExecutorOptions = {
...executorOptions,
agent: input.agent,
}
const result = await executeSlashCommand(parsed, executionOptions)
const idx = findSlashCommandPartIndex(output.parts)
if (idx < 0) {
return
}
if (!result.success || !result.replacementText) {
log(`[auto-slash-command] Command not found, skipping`, {
sessionID: input.sessionID,
command: parsed.command,
error: result.error,
})
return
}
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
output.parts[idx].text = taggedContent
log(`[auto-slash-command] Replaced message with command template`, {
sessionID: input.sessionID,
command: parsed.command,
})
},
"command.execute.before": async (
input: CommandExecuteBeforeInput,
output: CommandExecuteBeforeOutput
): Promise<void> => {
const eventID = getCommandExecutionEventID(input)
const commandKey = eventID
? `${input.sessionID}:event:${eventID}`
: `${input.sessionID}:fallback:${input.command.toLowerCase()}:${input.arguments || ""}`
if (sessionProcessedCommandExecutions.has(commandKey)) {
return
}
log(`[auto-slash-command] command.execute.before received`, {
sessionID: input.sessionID,
command: input.command,
arguments: input.arguments,
})
const parsed = {
command: input.command,
args: input.arguments || "",
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
}
const executionOptions: ExecutorOptions = {
...executorOptions,
agent: input.agent,
}
const result = await executeSlashCommand(parsed, executionOptions)
if (!result.success || !result.replacementText) {
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
sessionID: input.sessionID,
command: input.command,
error: result.error,
})
return
}
sessionProcessedCommandExecutions.add(
commandKey,
eventID ? undefined : COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS
)
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
const idx = findSlashCommandPartIndex(output.parts)
if (idx >= 0) {
output.parts[idx].text = taggedContent
} else {
output.parts.unshift({ type: "text", text: taggedContent })
}
log(`[auto-slash-command] command.execute.before - injected template`, {
sessionID: input.sessionID,
command: input.command,
})
},
event: async ({
event,
}: {
event: { type: string; properties?: unknown }
}): Promise<void> => {
if (event.type !== "session.deleted") {
return
}
const sessionID = getDeletedSessionID(event.properties)
if (!sessionID) {
return
}
sessionProcessedCommands.cleanupSession(sessionID)
sessionProcessedCommandExecutions.cleanupSession(sessionID)
},
dispose,
}
}
+204 -41
View File
@@ -1,7 +1,10 @@
import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
CommandExecuteBeforeInput,
CommandExecuteBeforeOutput,
} from "./types"
// Import real shared module to avoid mock leaking to other test files
@@ -19,7 +22,7 @@ function createMockInput(sessionID: string, messageID?: string): AutoSlashComman
sessionID,
messageID: messageID ?? `msg-${Date.now()}-${Math.random()}`,
agent: "test-agent",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
}
}
@@ -27,7 +30,7 @@ function createMockOutput(text: string): AutoSlashCommandHookOutput {
return {
message: {
agent: "test-agent",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
path: { cwd: "/test", root: "/test" },
tools: {},
},
@@ -42,118 +45,118 @@ describe("createAutoSlashCommandHook", () => {
describe("slash command replacement", () => {
it("should not modify message when command not found", async () => {
// #given a slash command that doesn't exist
// given a slash command that doesn't exist
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-notfound-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/nonexistent-command args")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should NOT modify the message (feature inactive when command not found)
// then should NOT modify the message (feature inactive when command not found)
expect(output.parts[0].text).toBe(originalText)
})
it("should not modify message for unknown command (feature inactive)", async () => {
// #given unknown slash command
// given unknown slash command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-tags-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/some-command")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should NOT modify (command not found = feature inactive)
// then should NOT modify (command not found = feature inactive)
expect(output.parts[0].text).toBe(originalText)
})
it("should not modify for unknown command (no prepending)", async () => {
// #given unknown slash command
// given unknown slash command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-replace-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/test-cmd some args")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (feature inactive for unknown commands)
// then should not modify (feature inactive for unknown commands)
expect(output.parts[0].text).toBe(originalText)
})
})
describe("no slash command", () => {
it("should do nothing for regular text", async () => {
// #given regular text without slash
// given regular text without slash
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-regular-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("Just regular text")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
it("should do nothing for slash in middle of text", async () => {
// #given slash in middle
// given slash in middle
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-middle-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("Please run /commit later")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not detect (not at start)
// then should not detect (not at start)
expect(output.parts[0].text).toBe(originalText)
})
})
describe("excluded commands", () => {
it("should NOT trigger for ralph-loop command", async () => {
// #given ralph-loop command
// given ralph-loop command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-ralph-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/ralph-loop do something")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (excluded command)
// then should not modify (excluded command)
expect(output.parts[0].text).toBe(originalText)
})
it("should NOT trigger for cancel-ralph command", async () => {
// #given cancel-ralph command
// given cancel-ralph command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-cancel-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/cancel-ralph")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
})
describe("already processed", () => {
it("should skip if auto-slash-command tags already present", async () => {
// #given text with existing tags
// given text with existing tags
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-existing-${Date.now()}`
const input = createMockInput(sessionID)
@@ -162,76 +165,76 @@ describe("createAutoSlashCommandHook", () => {
)
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
})
describe("code blocks", () => {
it("should NOT detect command inside code block", async () => {
// #given command inside code block
// given command inside code block
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-codeblock-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("```\n/commit\n```")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not detect
// then should not detect
expect(output.parts[0].text).toBe(originalText)
})
})
describe("edge cases", () => {
it("should handle empty text", async () => {
// #given empty text
// given empty text
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-empty-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("")
// #when hook is called
// #then should not throw
// when hook is called
// then should not throw
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
})
it("should handle just slash", async () => {
// #given just slash
// given just slash
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-slash-only-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
it("should handle command with special characters in args (not found = no modification)", async () => {
// #given command with special characters that doesn't exist
// given command with special characters that doesn't exist
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-special-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput('/execute "test & stuff <tag>"')
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (command not found = feature inactive)
// then should not modify (command not found = feature inactive)
expect(output.parts[0].text).toBe(originalText)
})
it("should handle multiple text parts (unknown command = no modification)", async () => {
// #given multiple text parts with unknown command
// given multiple text parts with unknown command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-multi-${Date.now()}`
const input = createMockInput(sessionID)
@@ -244,11 +247,171 @@ describe("createAutoSlashCommandHook", () => {
}
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (command not found = feature inactive)
// then should not modify (command not found = feature inactive)
expect(output.parts[0].text).toBe(originalText)
})
})
describe("command.execute.before hook", () => {
function createCommandInput(command: string, args: string = ""): CommandExecuteBeforeInput {
return {
command,
sessionID: `test-session-cmd-${Date.now()}-${Math.random()}`,
arguments: args,
}
}
function createCommandOutput(text?: string): CommandExecuteBeforeOutput {
return {
parts: text ? [{ type: "text", text }] : [],
}
}
it("should not modify output for unknown command", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("nonexistent-command-xyz")
const output = createCommandOutput("original text")
const originalText = output.parts[0].text
//#when
await hook["command.execute.before"](input, output)
//#then
expect(output.parts[0].text).toBe(originalText)
})
it("should add text part when parts array is empty and command is unknown", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("nonexistent-command-abc")
const output = createCommandOutput()
//#when
await hook["command.execute.before"](input, output)
//#then
expect(output.parts.length).toBe(0)
})
it("should inject template for known builtin commands like ralph-loop", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("ralph-loop")
const output = createCommandOutput("original")
//#when
await hook["command.execute.before"](input, output)
//#then
expect(output.parts[0].text).toContain("<auto-slash-command>")
expect(output.parts[0].text).toContain("/ralph-loop Command")
})
it("should pass command arguments correctly", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("some-command", "arg1 arg2 arg3")
const output = createCommandOutput("original")
//#when
await hook["command.execute.before"](input, output)
//#then
expect(logMock).toHaveBeenCalledWith(
"[auto-slash-command] command.execute.before received",
expect.objectContaining({
command: "some-command",
arguments: "arg1 arg2 arg3",
})
)
})
})
describe("skills as slash commands", () => {
function createTestSkill(name: string, template: string): LoadedSkill {
return {
name,
path: `/test/skills/${name}/SKILL.md`,
definition: {
name,
description: `Test skill: ${name}`,
template,
},
scope: "user",
}
}
it("should replace message with skill template when skill is used as slash command via chat.message", async () => {
// given a hook with a skill
const skill = createTestSkill("my-test-skill", "This is the skill template content")
const hook = createAutoSlashCommandHook({ skills: [skill] })
const sessionID = `test-session-skill-chat-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/my-test-skill some arguments")
// when hook processes the message
await hook["chat.message"](input, output)
// then should replace message with skill template
expect(output.parts[0].text).toContain("<auto-slash-command>")
expect(output.parts[0].text).toContain("/my-test-skill Command")
expect(output.parts[0].text).toContain("This is the skill template content")
})
it("should inject skill template via command.execute.before", async () => {
// given a hook with a skill
const skill = createTestSkill("my-test-skill", "Skill template for command execute")
const hook = createAutoSlashCommandHook({ skills: [skill] })
const input: CommandExecuteBeforeInput = {
command: "my-test-skill",
sessionID: `test-session-skill-cmd-${Date.now()}-${Math.random()}`,
arguments: "extra args",
}
const output: CommandExecuteBeforeOutput = {
parts: [{ type: "text", text: "original" }],
}
// when hook processes the command
await hook["command.execute.before"](input, output)
// then should inject skill template
expect(output.parts[0].text).toContain("<auto-slash-command>")
expect(output.parts[0].text).toContain("/my-test-skill Command")
expect(output.parts[0].text).toContain("Skill template for command execute")
expect(output.parts[0].text).toContain("extra args")
})
it("should handle skill with lazy content loader", async () => {
// given a skill with lazy content (no inline template)
const skill: LoadedSkill = {
name: "lazy-skill",
path: "/test/skills/lazy-skill/SKILL.md",
definition: {
name: "lazy-skill",
description: "A lazy-loaded skill",
template: "",
},
scope: "user",
lazyContent: {
loaded: false,
load: async () => "Lazy loaded skill content here",
},
}
const hook = createAutoSlashCommandHook({ skills: [skill] })
const sessionID = `test-session-lazy-skill-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/lazy-skill")
// when hook processes the message
await hook["chat.message"](input, output)
// then should replace message with lazily loaded content
expect(output.parts[0].text).toContain("<auto-slash-command>")
expect(output.parts[0].text).toContain("Lazy loaded skill content here")
})
})
})
+2 -84
View File
@@ -1,89 +1,7 @@
import {
detectSlashCommand,
extractPromptText,
} from "./detector"
import { executeSlashCommand, type ExecutorOptions } from "./executor"
import { log } from "../../shared"
import {
AUTO_SLASH_COMMAND_TAG_OPEN,
AUTO_SLASH_COMMAND_TAG_CLOSE,
} from "./constants"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
} from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
export * from "./detector"
export * from "./executor"
export * from "./constants"
export * from "./types"
const sessionProcessedCommands = new Set<string>()
export interface AutoSlashCommandHookOptions {
skills?: LoadedSkill[]
}
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
const executorOptions: ExecutorOptions = {
skills: options?.skills,
}
return {
"chat.message": async (
input: AutoSlashCommandHookInput,
output: AutoSlashCommandHookOutput
): Promise<void> => {
const promptText = extractPromptText(output.parts)
if (
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
) {
return
}
const parsed = detectSlashCommand(promptText)
if (!parsed) {
return
}
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
if (sessionProcessedCommands.has(commandKey)) {
return
}
sessionProcessedCommands.add(commandKey)
log(`[auto-slash-command] Detected: /${parsed.command}`, {
sessionID: input.sessionID,
args: parsed.args,
})
const result = await executeSlashCommand(parsed, executorOptions)
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx < 0) {
return
}
if (!result.success || !result.replacementText) {
log(`[auto-slash-command] Command not found, skipping`, {
sessionID: input.sessionID,
command: parsed.command,
error: result.error,
})
return
}
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
output.parts[idx].text = taggedContent
log(`[auto-slash-command] Replaced message with command template`, {
sessionID: input.sessionID,
command: parsed.command,
})
},
}
}
export { createAutoSlashCommandHook } from "./hook"
export type { AutoSlashCommandHookOptions } from "./hook"
@@ -0,0 +1,55 @@
const MAX_PROCESSED_ENTRY_COUNT = 10_000
const PROCESSED_COMMAND_TTL_MS = 30_000
function pruneExpiredEntries(entries: Map<string, number>, now: number): Map<string, number> {
return new Map(Array.from(entries.entries()).filter(([, expiresAt]) => expiresAt > now))
}
function trimProcessedEntries(entries: Map<string, number>): Map<string, number> {
if (entries.size <= MAX_PROCESSED_ENTRY_COUNT) {
return entries
}
return new Map(
Array.from(entries.entries())
.sort((left, right) => left[1] - right[1])
.slice(Math.floor(entries.size / 2))
)
}
function removeSessionEntries(entries: Map<string, number>, sessionID: string): Map<string, number> {
const sessionPrefix = `${sessionID}:`
return new Map(Array.from(entries.entries()).filter(([entry]) => !entry.startsWith(sessionPrefix)))
}
export interface ProcessedCommandStore {
has(commandKey: string): boolean
add(commandKey: string, ttlMs?: number): void
cleanupSession(sessionID: string): void
clear(): void
}
export function createProcessedCommandStore(): ProcessedCommandStore {
let entries = new Map<string, number>()
return {
has(commandKey: string): boolean {
const now = Date.now()
entries = pruneExpiredEntries(entries, now)
return entries.has(commandKey)
},
add(commandKey: string, ttlMs = PROCESSED_COMMAND_TTL_MS): void {
const now = Date.now()
entries = pruneExpiredEntries(entries, now)
entries.delete(commandKey)
entries.set(commandKey, now + ttlMs)
entries = trimProcessedEntries(entries)
},
cleanupSession(sessionID: string): void {
entries = removeSessionEntries(entries, sessionID)
},
clear(): void {
entries.clear()
},
}
}
+19
View File
@@ -21,3 +21,22 @@ export interface AutoSlashCommandResult {
parsedCommand?: ParsedSlashCommand
injectedMessage?: string
}
export interface CommandExecuteBeforeInput {
command: string
sessionID: string
arguments: string
agent?: string
messageID?: string
messageId?: string
eventID?: string
eventId?: string
invocationID?: string
invocationId?: string
commandID?: string
commandId?: string
}
export interface CommandExecuteBeforeOutput {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode")
const TEST_USER_CONFIG_DIR = "/tmp/opencode-config"
mock.module("./constants", () => ({
CACHE_DIR: TEST_OPENCODE_CACHE_DIR,
USER_CONFIG_DIR: TEST_USER_CONFIG_DIR,
PACKAGE_NAME: "oh-my-opencode",
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
function resetTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "latest", other: "1.0.0" } }, null, 2)
)
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "bun.lock"),
JSON.stringify(
{
workspaces: {
"": {
dependencies: { "oh-my-opencode": "latest", other: "1.0.0" },
},
},
packages: {
"oh-my-opencode": {},
other: {},
},
},
null,
2
)
)
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
'{"name":"oh-my-opencode"}'
)
}
describe("invalidatePackage", () => {
beforeEach(() => {
resetTestCache()
})
afterEach(() => {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
})
it("invalidates the installed package from the OpenCode cache directory", async () => {
const { invalidatePackage } = await import("./cache")
const result = invalidatePackage()
expect(result).toBe(true)
expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false)
const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as {
dependencies?: Record<string, string>
}
expect(packageJson.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(packageJson.dependencies?.other).toBe("1.0.0")
const bunLock = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), "utf-8")) as {
workspaces?: { ""?: { dependencies?: Record<string, string> } }
packages?: Record<string, unknown>
}
expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0")
expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined()
expect(bunLock.packages?.other).toEqual({})
})
})
+40 -35
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME } from "./constants"
import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { log } from "../../shared/logger"
interface BunLockfile {
@@ -16,65 +16,70 @@ function stripTrailingCommas(json: string): string {
return json.replace(/,(\s*[}\]])/g, "$1")
}
function removeFromBunLock(packageName: string): boolean {
const lockPath = path.join(CACHE_DIR, "bun.lock")
if (!fs.existsSync(lockPath)) return false
function removeFromTextBunLock(lockPath: string, packageName: string): boolean {
try {
const content = fs.readFileSync(lockPath, "utf-8")
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
let modified = false
if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
delete lock.workspaces[""].dependencies[packageName]
modified = true
}
if (lock.packages?.[packageName]) {
delete lock.packages[packageName]
modified = true
}
if (modified) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
return true
}
return modified
return false
} catch {
return false
}
}
function deleteBinaryBunLock(lockPath: string): boolean {
try {
fs.unlinkSync(lockPath)
log(`[auto-update-checker] Removed bun.lockb to force re-resolution`)
return true
} catch {
return false
}
}
function removeFromBunLock(packageName: string): boolean {
const textLockPath = path.join(CACHE_DIR, "bun.lock")
const binaryLockPath = path.join(CACHE_DIR, "bun.lockb")
if (fs.existsSync(textLockPath)) {
return removeFromTextBunLock(textLockPath, packageName)
}
// Binary lockfiles cannot be parsed; deletion forces bun to re-resolve
if (fs.existsSync(binaryLockPath)) {
return deleteBinaryBunLock(binaryLockPath)
}
return false
}
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try {
const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
const pkgJsonPath = path.join(CACHE_DIR, "package.json")
const pkgDirs = [
path.join(USER_CONFIG_DIR, "node_modules", packageName),
path.join(CACHE_DIR, "node_modules", packageName),
]
let packageRemoved = false
let dependencyRemoved = false
let lockRemoved = false
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
if (fs.existsSync(pkgJsonPath)) {
const content = fs.readFileSync(pkgJsonPath, "utf-8")
const pkgJson = JSON.parse(content)
if (pkgJson.dependencies?.[packageName]) {
delete pkgJson.dependencies[packageName]
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`)
dependencyRemoved = true
for (const pkgDir of pkgDirs) {
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
}
lockRemoved = removeFromBunLock(packageName)
if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
if (!packageRemoved && !lockRemoved) {
log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
return false
}
+10 -284
View File
@@ -1,284 +1,10 @@
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)
}
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"
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
export type { SyncResult } from "./checker/sync-package-json"
@@ -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,133 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import { updatePinnedVersion, revertPinnedVersion } from "./pinned-version-updater"
describe("pinned-version-updater", () => {
let tmpDir: string
let configPath: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omo-updater-test-"))
configPath = path.join(tmpDir, "opencode.json")
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe("updatePinnedVersion", () => {
test("updates pinned version in config", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.1.8"],
})
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
//#then
expect(result).toBe(true)
const updated = fs.readFileSync(configPath, "utf-8")
expect(updated).toContain("oh-my-opencode@3.4.0")
expect(updated).not.toContain("oh-my-opencode@3.1.8")
})
test("returns false when entry not found", () => {
//#given
const config = JSON.stringify({
plugin: ["some-other-plugin"],
})
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
//#then
expect(result).toBe(false)
})
test("returns false when no plugin array exists", () => {
//#given
const config = JSON.stringify({ agent: {} })
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
//#then
expect(result).toBe(false)
})
})
describe("revertPinnedVersion", () => {
test("reverts from failed version back to original entry", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.4.0"],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
//#then
expect(result).toBe(true)
const reverted = fs.readFileSync(configPath, "utf-8")
expect(reverted).toContain("oh-my-opencode@3.1.8")
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
})
test("reverts to unpinned entry", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.4.0"],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode")
//#then
expect(result).toBe(true)
const reverted = fs.readFileSync(configPath, "utf-8")
expect(reverted).toContain('"oh-my-opencode"')
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
})
test("returns false when failed version not found", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.1.8"],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
//#then
expect(result).toBe(false)
})
})
describe("update then revert roundtrip", () => {
test("config returns to original state after update + revert", () => {
//#given
const originalConfig = JSON.stringify({
plugin: ["oh-my-opencode@3.1.8"],
})
fs.writeFileSync(configPath, originalConfig)
//#when
updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
//#then
const finalConfig = fs.readFileSync(configPath, "utf-8")
expect(finalConfig).toContain("oh-my-opencode@3.1.8")
expect(finalConfig).not.toContain("oh-my-opencode@3.4.0")
})
})
})
@@ -0,0 +1,62 @@
import * as fs from "node:fs"
import { log } from "../../../shared/logger"
import { PACKAGE_NAME } from "../constants"
function replacePluginEntry(configPath: string, oldEntry: string, newEntry: string): boolean {
try {
const content = fs.readFileSync(configPath, "utf-8")
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
}
}
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
const newEntry = `${PACKAGE_NAME}@${newVersion}`
return replacePluginEntry(configPath, oldEntry, newEntry)
}
export function revertPinnedVersion(configPath: string, failedVersion: string, originalEntry: string): boolean {
const failedEntry = `${PACKAGE_NAME}@${failedVersion}`
return replacePluginEntry(configPath, failedEntry, originalEntry)
}
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { findPluginEntry } from "./plugin-entry"
describe("findPluginEntry", () => {
let temporaryDirectory: string
let configPath: string
beforeEach(() => {
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "omo-plugin-entry-test-"))
const opencodeDirectory = path.join(temporaryDirectory, ".opencode")
fs.mkdirSync(opencodeDirectory, { recursive: true })
configPath = path.join(opencodeDirectory, "opencode.json")
})
afterEach(() => {
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
})
test("returns unpinned for bare package name", () => {
// #given plugin is configured without a tag
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
// #then entry is not pinned
expect(pluginInfo).not.toBeNull()
expect(pluginInfo?.isPinned).toBe(false)
expect(pluginInfo?.pinnedVersion).toBeNull()
})
test("returns unpinned for latest dist-tag", () => {
// #given plugin is configured with latest dist-tag
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
// #then latest is treated as channel, not pin
expect(pluginInfo).not.toBeNull()
expect(pluginInfo?.isPinned).toBe(false)
expect(pluginInfo?.pinnedVersion).toBe("latest")
})
test("returns unpinned for beta dist-tag", () => {
// #given plugin is configured with beta dist-tag
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@beta"] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
// #then beta is treated as channel, not pin
expect(pluginInfo).not.toBeNull()
expect(pluginInfo?.isPinned).toBe(false)
expect(pluginInfo?.pinnedVersion).toBe("beta")
})
test("returns pinned for explicit semver", () => {
// #given plugin is configured with explicit version
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.5.2"] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
// #then explicit semver is treated as pin
expect(pluginInfo).not.toBeNull()
expect(pluginInfo?.isPinned).toBe(true)
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
})
})
@@ -0,0 +1,40 @@
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
}
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
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 = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
return { entry, isPinned, pinnedVersion, configPath }
}
}
} catch {
continue
}
}
return null
}
@@ -0,0 +1,341 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginEntryInfo } from "./plugin-entry"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__")
mock.module("../constants", () => ({
CACHE_DIR: TEST_CACHE_DIR,
PACKAGE_NAME: "oh-my-opencode",
NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags",
NPM_FETCH_TIMEOUT: 5000,
VERSION_FILE: join(TEST_CACHE_DIR, "version"),
USER_CONFIG_DIR: "/tmp/opencode-config",
USER_OPENCODE_CONFIG: "/tmp/opencode-config/opencode.json",
USER_OPENCODE_CONFIG_JSONC: "/tmp/opencode-config/opencode.jsonc",
INSTALLED_PACKAGE_JSON: join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
getWindowsAppdataDir: () => null,
}))
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
function resetTestCache(currentVersion = "3.10.0"): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": currentVersion, other: "1.0.0" } }, null, 2)
)
}
function cleanupTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
}
function readCachePackageJsonVersion(): string | undefined {
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
return pkg.dependencies?.["oh-my-opencode"]
}
describe("syncCachePackageJsonToIntent", () => {
beforeEach(() => {
resetTestCache()
})
afterEach(() => {
cleanupTestCache()
})
describe("#given cache package.json with pinned semver version", () => {
describe("#when opencode.json intent is latest tag", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#when opencode.json intent is next tag", () => {
it("#then updates package.json to use next", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@next",
isPinned: false,
pinnedVersion: "next",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("next")
})
})
describe("#when opencode.json has no version (implies latest)", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
})
describe("#given cache package.json already matches intent", () => {
it("#then returns synced false with no error", async () => {
resetTestCache("latest")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#given cache package.json does not exist", () => {
it("#then returns file_not_found error", async () => {
cleanupTestCache()
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("file_not_found")
})
})
describe("#given plugin not in cache package.json dependencies", () => {
it("#then returns plugin_not_in_deps error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2)
)
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("plugin_not_in_deps")
})
})
describe("#given user explicitly changed from one semver to another", () => {
it("#then updates package.json to new version", async () => {
resetTestCache("3.9.0")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@3.10.0",
isPinned: true,
pinnedVersion: "3.10.0",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("3.10.0")
})
})
describe("#given cache package.json with other dependencies", () => {
it("#then other dependencies are preserved when updating plugin version", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.["other"]).toBe("1.0.0")
})
})
describe("#given malformed JSON in cache package.json", () => {
it("#then returns parse_error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(join(TEST_CACHE_DIR, "package.json"), "{ invalid json }")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("parse_error")
})
})
describe("#given write permission denied", () => {
it("#then returns write_error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
)
const fs = await import("node:fs")
const originalWriteFileSync = fs.writeFileSync
const originalRenameSync = fs.renameSync
mock.module("node:fs", () => ({
...fs,
writeFileSync: mock(() => {
throw new Error("EACCES: permission denied")
}),
renameSync: fs.renameSync,
}))
try {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("write_error")
} finally {
mock.module("node:fs", () => ({
...fs,
writeFileSync: originalWriteFileSync,
renameSync: originalRenameSync,
}))
}
})
})
describe("#given rename fails after successful write", () => {
it("#then returns write_error and cleans up temp file", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
)
const fs = await import("node:fs")
const originalWriteFileSync = fs.writeFileSync
const originalRenameSync = fs.renameSync
let tempFilePath: string | null = null
mock.module("node:fs", () => ({
...fs,
writeFileSync: mock((path: string, data: string) => {
tempFilePath = path
return originalWriteFileSync(path, data)
}),
renameSync: mock(() => {
throw new Error("EXDEV: cross-device link not permitted")
}),
}))
try {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("write_error")
expect(tempFilePath).not.toBeNull()
expect(existsSync(tempFilePath!)).toBe(false)
} finally {
mock.module("node:fs", () => ({
...fs,
writeFileSync: originalWriteFileSync,
renameSync: originalRenameSync,
}))
}
})
})
})
@@ -0,0 +1,98 @@
import * as crypto from "node:crypto"
import * as fs from "node:fs"
import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME } from "../constants"
import { log } from "../../../shared/logger"
import type { PluginEntryInfo } from "./plugin-entry"
interface CachePackageJson {
dependencies?: Record<string, string>
}
export interface SyncResult {
synced: boolean
error: "file_not_found" | "plugin_not_in_deps" | "parse_error" | "write_error" | null
message?: string
}
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
function safeUnlink(filePath: string): void {
try {
fs.unlinkSync(filePath)
} catch (err) {
log(`[auto-update-checker] Failed to cleanup temp file: ${filePath}`, err)
}
}
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
if (!pluginInfo.pinnedVersion) {
return "latest"
}
return pluginInfo.pinnedVersion
}
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncResult {
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
if (!fs.existsSync(cachePackageJsonPath)) {
log("[auto-update-checker] Cache package.json not found, nothing to sync")
return { synced: false, error: "file_not_found", message: "Cache package.json not found" }
}
let content: string
let pkgJson: CachePackageJson
try {
content = fs.readFileSync(cachePackageJsonPath, "utf-8")
} catch (err) {
log("[auto-update-checker] Failed to read cache package.json:", err)
return { synced: false, error: "parse_error", message: "Failed to read cache package.json" }
}
try {
pkgJson = JSON.parse(content) as CachePackageJson
} catch (err) {
log("[auto-update-checker] Failed to parse cache package.json:", err)
return { synced: false, error: "parse_error", message: "Failed to parse cache package.json (malformed JSON)" }
}
if (!pkgJson || !pkgJson.dependencies?.[PACKAGE_NAME]) {
log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync")
return { synced: false, error: "plugin_not_in_deps", message: "Plugin not in cache package.json dependencies" }
}
const currentVersion = pkgJson.dependencies[PACKAGE_NAME]
const intentVersion = getIntentVersion(pluginInfo)
if (currentVersion === intentVersion) {
log("[auto-update-checker] Cache package.json already matches intent:", intentVersion)
return { synced: false, error: null, message: `Already matches intent: ${intentVersion}` }
}
const intentIsTag = !EXACT_SEMVER_REGEX.test(intentVersion.trim())
const currentIsSemver = EXACT_SEMVER_REGEX.test(String(currentVersion).trim())
if (intentIsTag && currentIsSemver) {
log(
`[auto-update-checker] Syncing cache package.json: "${currentVersion}" → "${intentVersion}" (opencode.json intent)`
)
} else {
log(
`[auto-update-checker] Updating cache package.json: "${currentVersion}" → "${intentVersion}"`
)
}
pkgJson.dependencies[PACKAGE_NAME] = intentVersion
const tmpPath = `${cachePackageJsonPath}.${crypto.randomUUID()}`
try {
fs.writeFileSync(tmpPath, JSON.stringify(pkgJson, null, 2))
fs.renameSync(tmpPath, cachePackageJsonPath)
return { synced: true, error: null, message: `Updated: "${currentVersion}" → "${intentVersion}"` }
} catch (err) {
log("[auto-update-checker] Failed to write cache package.json:", err)
safeUnlink(tmpPath)
return { synced: false, error: "write_error", message: "Failed to write cache package.json" }
}
}

Some files were not shown because too many files have changed in this diff Show More