merge(dev): resolve background-agent delegated fallback conflicts

Reconcile the latest dev branch changes with the delegated child-session fallback work. Preserve the upstream background-agent updates while keeping the delegated bootstrap cleanup and compatibility wiring fixes intact, then re-verify the affected regression suites and typecheck.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-11 03:02:14 +08:00
668 changed files with 44608 additions and 6160 deletions
+132 -162
View File
@@ -1,176 +1,146 @@
# src/hooks/ — 52 Lifecycle Hooks
# src/hooks/ — ~50 Lifecycle Hooks Across 57 Dirs
**Generated:** 2026-04-18
**Generated:** 2026-05-08
## OVERVIEW
52 hooks across dedicated modules and standalone files. Three-tier composition: Core(43) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
50 hooks (7 of the 57 dirs are `zauc-mocks-*` test scaffolds + 1 `shared/`). 5-tier composition wired in `src/plugin/hooks/`. All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS
## TIER COMPOSITION
| Tier | Composer | Base | With team-mode | Where |
|------|----------|------|----------------|-------|
| **Session** | `create-session-hooks.ts` | 24 | 24 | OpenCode session lifecycle + chat.params + chat.message |
| **Tool Guard** | `create-tool-guard-hooks.ts` | 14 | 15 | Pre/post tool execution (+1: `team-tool-gating`) |
| **Transform** | `create-transform-hooks.ts` | 5 | 7 | `experimental.chat.messages.transform` (+2: `team-mode-status-injector`, `team-mailbox-injector`) |
| **Continuation** | `create-continuation-hooks.ts` | 7 | 7 | Boulder/atlas/compaction/notification |
| **Skill** | `create-skill-hooks.ts` | 2 | 2 | Skill awareness (categorySkillReminder, autoSlashCommand) |
| **Direct event handlers** | `src/plugin/event.ts` | 0 | +4 | `team-session-events/` sub-files: `team-idle-wake-hint`, `team-lead-orphan-handler`, `team-member-error-handler`, `team-member-status-handler` |
Total exposed hooks: **52 base, 59 with team-mode** (counts the 4 team-session-events handlers individually).
Hook name allowlist for `disabled_hooks`: 53 enum values in [`src/config/schema/hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/hooks.ts) `HookNameSchema`. Team-session-event sub-hooks are not individually listed in the schema — they activate together with `team_mode.enabled`.
### Tier 1: Session Hooks (24)
| Hook | Event | Purpose |
|------|-------|---------|
| `contextWindowMonitor` | session.idle | Track context usage |
| `preemptiveCompaction` | session.idle | Trigger compaction before limit |
| `sessionRecovery` | session.error | Recover from structural errors (tool_result_missing, thinking_block_order) |
| `sessionNotification` | session.idle | OS notifications on completion |
| `thinkMode` | chat.params | Model variant switching for extended thinking |
| `anthropicContextWindowLimitRecovery` | session.error | Multi-strategy context recovery (truncation, compaction, dedup) |
| `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 lifecycle for interactive_bash tool |
| `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 tool labels |
| `taskResumeInfo` | chat.message | Inject task context on resume |
| `anthropicEffort` | chat.params | Adjust reasoning effort level |
| `modelFallback` | chat.params | Provider-level proactive model fallback |
| `noSisyphusGpt` | chat.message | Block Sisyphus from non-GPT providers (with warning toast) |
| `noHephaestusNonGpt` | chat.message | Block Hephaestus from non-GPT models |
| `runtimeFallback` | event | Reactive auto-switch on API provider errors |
| `legacyPluginToast` | chat.message | Show toast when legacy plugin name detected |
### Tier 2: Tool Guard Hooks (14)
| Hook | Event | Purpose |
|------|-------|---------|
| `commentChecker` | tool.execute.after | Block AI-slop comment patterns (binary: `@code-yeongyu/comment-checker`) |
| `toolOutputTruncator` | tool.execute.after | Truncate oversized tool output |
| `directoryAgentsInjector` | tool.execute.before | Inject dir-local AGENTS.md into context |
| `directoryReadmeInjector` | tool.execute.before | Inject dir-local README.md into context |
| `emptyTaskResponseDetector` | tool.execute.after | Detect empty task results |
| `rulesInjector` | tool.execute.before | Conditional rules injection (AGENTS.md, .rules) |
| `tasksTodowriteDisabler` | tool.execute.before | Disable TodoWrite when Sisyphus task system active |
| `writeExistingFileGuard` | tool.execute.before | Require Read before Write/Edit on existing files |
| `bashFileReadGuard` | tool.execute.before | Guard bash commands that read files (cat/head/tail) |
| `readImageResizer` | tool.execute.after | Resize large images for context efficiency |
| `todoDescriptionOverride` | tool.execute.before | Override todo item descriptions |
| `webfetchRedirectGuard` | tool.execute.before | Guard webfetch redirect behavior |
| `hashlineReadEnhancer` | tool.execute.after | Tag every Read output with `LINE#ID` content hashes |
| `jsonErrorRecovery` | tool.execute.after | Detect JSON parse errors, inject correction reminder |
### Tier 3: Transform Hooks (5)
| Hook | Event | Purpose |
|------|-------|---------|
| `claudeCodeHooks` | messages.transform | Claude Code settings.json compatibility |
| `keywordDetector` | messages.transform | Detect ultrawork/search/analyze/team modes; inject mode-specific prompt |
| `contextInjectorMessagesTransform` | messages.transform | Inject AGENTS.md/README.md into context |
| `thinkingBlockValidator` | messages.transform | Validate thinking block structure |
| `toolPairValidator` | messages.transform | Validate tool call/result pairing |
### Tier 4: Continuation Hooks (7)
| 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 |
### Tier 5: Skill Hooks (2)
| Hook | Event | Purpose |
|------|-------|---------|
| `categorySkillReminder` | chat.message | Hint to load skills before invoking categories |
| `autoSlashCommand` | chat.message | Auto-execute matching `/command` from user message |
### Team-mode Hooks (conditional, only when `team_mode.enabled: true`)
| Hook | Tier | Registered In | Purpose |
|------|------|---------------|---------|
| `team-mode-status-injector` | Transform | [`create-transform-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-transform-hooks.ts) | Inject `<team_mode_status>` block into messages |
| `team-mailbox-injector` | Transform | [`create-transform-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-transform-hooks.ts) | Pull pending team mailbox messages into agent context |
| `team-tool-gating` | Tool Guard | [`create-tool-guard-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-tool-guard-hooks.ts) | Restrict `team_*` tools based on member role + permissions |
| `team-idle-wake-hint` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Nudge idle team members back to work |
| `team-lead-orphan-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Detect lead departure → orphan members |
| `team-member-error-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | React to member session errors |
| `team-member-status-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Track member status transitions |
The 4 `team-session-events/` handlers live in `src/hooks/team-session-events/` (separate files: `team-idle-wake-hint.ts`, `team-lead-orphan-handler.ts`, `team-member-error-handler.ts`, `team-member-status-handler.ts`) and are wired into `src/plugin/event.ts` directly, not through a tier composer.
### Tier 1: Session Hooks (24) — `create-session-hooks.ts`
## STRUCTURE
```
hooks/
├── agent-usage-reminder/ # Reminds about available agents
├── atlas/ # Main orchestration (757 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize
── anthropic-effort/ # Reasoning effort level adjustment
├── auto-slash-command/ # Detects /command patterns
├── auto-update-checker/ # Plugin update check
├── background-notification/ # OS notification
├── 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
├── 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
├── legacy-plugin-toast/ # Legacy plugin name migration toast
├── 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
├── 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
├── todo-description-override/ # Override todo descriptions
├── tool-pair-validator/ # Validate tool pair usage
├── unstable-agent-babysitter/ # Monitor unstable agent behavior
├── webfetch-redirect-guard/ # Guard webfetch redirect behavior
├── write-existing-file-guard/ # Require Read before Write
└── index.ts # Hook aggregation + registration
├── shared/ # Cross-hook helpers (timing, prompt builders, etc.)
├── (50 hook directories — see tier tables above)
├── zauc-mocks-bg, zauc-mocks-cache, … # Test mocks (NOT hooks; named for sort-order isolation)
── (each hook dir)/
├── index.ts # createXXXHook factory + barrel
├── *.ts # implementation
└── *.test.ts # bun:test
```
| 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 |
| legacyPluginToast | chat.message | Show toast when legacy plugin name detected |
## ADDING A NEW HOOK
### Tier 2: Tool Guard Hooks (14) — `create-tool-guard-hooks.ts`
1. `mkdir src/hooks/{name}` + `index.ts` exporting `createXXXHook(deps)`
2. Pick the right tier:
- Session lifecycle? → `create-session-hooks.ts`
- Pre/post tool? → `create-tool-guard-hooks.ts`
- Message transform? → `create-transform-hooks.ts`
- Continuation/idle? → `create-continuation-hooks.ts`
- Skill awareness? → `create-skill-hooks.ts`
- Team-mode-only? → register inside the team-mode conditional block
3. Add hook name to [`config/schema/hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/hooks.ts) `HookNameSchema`
4. Cover with co-located `*.test.ts` (given/when/then style)
| 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 |
| bashFileReadGuard | tool.execute.before | Guard bash commands that read files |
| readImageResizer | tool.execute.after | Resize large images for context efficiency |
| todoDescriptionOverride | tool.execute.before | Override todo item descriptions |
| webfetchRedirectGuard | tool.execute.before | Guard webfetch redirect behavior |
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
## NOTES
### Tier 3: Transform Hooks (5) — `create-transform-hooks.ts`
| 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 |
| toolPairValidator | messages.transform | Validate tool call/result pairs |
### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts`
| 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 |
### Tier 5: Skill Hooks (2) — `create-skill-hooks.ts`
| Hook | Event | Purpose |
|------|-------|---------|
| categorySkillReminder | chat.message | Remind about category+skill delegation |
| autoSlashCommand | chat.message | Auto-detect `/command` in user input |
## KEY HOOKS (COMPLEX)
### anthropic-context-window-limit-recovery (31 files, ~2232 LOC)
Multi-strategy recovery when hitting context limits. Strategies: truncation, compaction, summarization.
### 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
- **Tier order matters within a phase:** within Session tier the registration order in `create-session-hooks.ts` determines invocation order — earlier hooks see un-mutated input, later hooks see accumulated output.
- **Mock files** (`zauc-mocks-*`, `zauc-sync-mocks`) are NOT hooks. They are placed inside `src/hooks/` purely so `bun:test` discovers them in the right order — auto-isolated by `script/run-ci-tests.ts` because they use `mock.module()`.
- **`atlasHook` vs `todoContinuationEnforcer`:** atlas handles boulder/ralph/subagent sessions, todoContinuationEnforcer handles the main Sisyphus session. Both fire on `session.idle` but check session type first.
- **`runtime-fallback` vs `model-fallback`:** runtime-fallback is reactive (after error); model-fallback is proactive (chat.params). They operate independently.
@@ -1,6 +1,6 @@
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -0,0 +1,176 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { AutoCompactState } from "./types"
type PromptAsyncCall = {
path: { id: string }
body: {
auto?: boolean
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
tools?: Record<string, boolean>
parts?: unknown
}
query: { directory: string }
}
const truncateUntilTargetTokensMock = mock(async () => ({
truncatedCount: 1,
totalBytesRemoved: 1000,
truncatedTools: [{ toolName: "bash" }],
sufficient: true,
}))
mock.module("./storage", () => ({
truncateUntilTargetTokens: truncateUntilTargetTokensMock,
}))
const findNearestMessageWithFieldsFromSDKMock = mock(async () => null)
const findNearestMessageWithFieldsMock = mock(() => null)
mock.module("../../features/hook-message-injector", () => ({
findNearestMessageWithFieldsFromSDK: findNearestMessageWithFieldsFromSDKMock,
findNearestMessageWithFields: findNearestMessageWithFieldsMock,
}))
import { _resetForTesting as resetSessionState, updateSessionAgent } from "../../features/claude-code-session-state/state"
import { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy"
type FakeClient = {
session: { promptAsync: (input: PromptAsyncCall) => Promise<unknown> }
tui: { showToast: (input: unknown) => Promise<unknown> }
}
function createRecordingClient(): { client: FakeClient; calls: PromptAsyncCall[] } {
const calls: PromptAsyncCall[] = []
const client: FakeClient = {
session: {
promptAsync: async (input: PromptAsyncCall) => {
calls.push(input)
return undefined
},
},
tui: {
showToast: async () => undefined,
},
}
return { client, calls }
}
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
retryStateBySession: new Map(),
retryTimerBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
}
async function flushDeferredPrompt(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 600))
}
describe("runAggressiveTruncationStrategy - pins agent/model/variant on recovered promptAsync", () => {
beforeEach(() => {
resetSessionState()
truncateUntilTargetTokensMock.mockClear()
findNearestMessageWithFieldsFromSDKMock.mockClear()
findNearestMessageWithFieldsMock.mockClear()
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue(null)
findNearestMessageWithFieldsMock.mockReturnValue(null)
})
afterEach(() => {
resetSessionState()
})
test("includes the session's resolved agent on promptAsync when agent is known", async () => {
// given
const { client, calls } = createRecordingClient()
const sessionID = "session-truncation-agent"
updateSessionAgent(sessionID, "sisyphus-junior")
// when
await runAggressiveTruncationStrategy({
sessionID,
autoCompactState: createAutoCompactState(),
client: client as never,
directory: "/tmp/test-truncation",
truncateAttempt: 0,
currentTokens: 250_000,
maxTokens: 200_000,
})
await flushDeferredPrompt()
// then
expect(calls).toHaveLength(1)
expect(calls[0].path.id).toBe(sessionID)
expect(calls[0].body.agent).toBe("sisyphus-junior")
expect(calls[0].body.auto).toBe(true)
})
test("pins provider/model/variant resolved from the nearest prior assistant message", async () => {
// given
const { client, calls } = createRecordingClient()
const sessionID = "session-truncation-model"
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" },
tools: undefined,
} as never)
findNearestMessageWithFieldsMock.mockReturnValue({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" },
tools: undefined,
} as never)
// when
await runAggressiveTruncationStrategy({
sessionID,
autoCompactState: createAutoCompactState(),
client: client as never,
directory: "/tmp/test-truncation",
truncateAttempt: 0,
currentTokens: 250_000,
maxTokens: 200_000,
})
await flushDeferredPrompt()
// then
expect(calls).toHaveLength(1)
expect(calls[0].body.agent).toBe("atlas")
expect(calls[0].body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
expect(calls[0].body.variant).toBe("high")
expect(calls[0].body.auto).toBe(true)
})
test("omits agent/model/variant when the session has nothing resolvable", async () => {
// given
const { client, calls } = createRecordingClient()
const sessionID = "session-truncation-empty"
// when
await runAggressiveTruncationStrategy({
sessionID,
autoCompactState: createAutoCompactState(),
client: client as never,
directory: "/tmp/test-truncation",
truncateAttempt: 0,
currentTokens: 250_000,
maxTokens: 200_000,
})
await flushDeferredPrompt()
// then
expect(calls).toHaveLength(1)
expect(calls[0].body.agent).toBeUndefined()
expect(calls[0].body.model).toBeUndefined()
expect(calls[0].body.variant).toBeUndefined()
expect(calls[0].body.auto).toBe(true)
})
})
@@ -5,7 +5,18 @@ import type { Client } from "./client"
import { clearSessionState } from "./state"
import { formatBytes } from "./message-builder"
import { log } from "../../shared/logger"
import { resolveInheritedPromptTools } from "../../shared"
import {
getMessageDir,
resolveInheritedPromptTools,
} from "../../shared"
import {
getSessionAgent,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state/state"
import {
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
} from "../../features/hook-message-injector"
export async function runAggressiveTruncationStrategy(params: {
sessionID: string
@@ -62,11 +73,27 @@ export async function runAggressiveTruncationStrategy(params: {
clearSessionState(params.autoCompactState, params.sessionID)
setTimeout(async () => {
try {
const inheritedTools = resolveInheritedPromptTools(params.sessionID)
const sdkMessage = await findNearestMessageWithFieldsFromSDK(params.client, params.sessionID)
const previousMessage = sdkMessage ?? (() => {
const messageDir = getMessageDir(params.sessionID)
return messageDir ? findNearestMessageWithFields(messageDir) : null
})()
const agentName = getSessionAgent(params.sessionID) ?? previousMessage?.agent
const launchAgent = resolveRegisteredAgentName(agentName)
const launchModel = previousMessage?.model?.providerID && previousMessage.model.modelID
? { providerID: previousMessage.model.providerID, modelID: previousMessage.model.modelID }
: undefined
const launchVariant = previousMessage?.model?.variant
const inheritedTools = resolveInheritedPromptTools(params.sessionID, previousMessage?.tools)
await params.client.session.promptAsync({
path: { id: params.sessionID },
body: {
auto: true,
...(launchAgent ? { agent: launchAgent } : {}),
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
} as never,
query: { directory: params.directory },
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/atlas/ — Master Boulder Orchestrator
**Generated:** 2026-04-18
**Generated:** 2026-05-08
## OVERVIEW
+14 -2
View File
@@ -21,7 +21,19 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
"tool.execute.after": createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit,
getState,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
}
}
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state"
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
@@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: {
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
pendingTaskRef,
boulderState.active_plan,
resolveBoulderPlanPath(ctx.directory, boulderState),
)
if (currentTask && !shouldSkipTaskSessionUpdate) {
@@ -91,6 +91,44 @@ describe("injectBoulderContinuation", () => {
expect(sessionState.lastContinuationInjectedAt).toBe(123)
})
test("#given a background task is still pending session creation #when injector checks again #then it still skips continuation", async () => {
// given
registerAgentName("atlas")
const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 }
const ctx = {
directory: "/tmp",
client: {
session: {
messages: messagesMock,
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
// when
const result = await injectBoulderContinuation({
ctx,
sessionID: "ses_test_pending",
planName: "test-plan",
remaining: 1,
total: 2,
agent: "atlas",
backgroundManager: {
getTasksByParentSession: () => [{ status: "pending" }],
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"],
sessionState,
})
// then
expect(result).toBe("skipped_background_tasks")
expect(promptAsyncMock).not.toHaveBeenCalled()
expect(sessionState.promptFailureCount).toBe(1)
expect(sessionState.lastContinuationInjectedAt).toBe(456)
})
test("#given the continuation agent is unavailable #when injector runs #then it reports skipped agent unavailable without prompting", async () => {
// given
const promptAsyncMock = mock(async (_request: unknown) => undefined)
@@ -1,5 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
isAgentRegistered,
resolveRegisteredAgentName,
@@ -9,10 +8,12 @@ import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../
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"
import type { BackgroundTaskStatusProvider, SessionState } from "./types"
export type BoulderContinuationResult = "injected" | "skipped_background_tasks" | "skipped_agent_unavailable" | "failed"
const ACTIVE_BACKGROUND_TASK_STATUSES = new Set(["pending", "running"])
export async function injectBoulderContinuation(input: {
ctx: PluginInput
sessionID: string
@@ -23,7 +24,7 @@ export async function injectBoulderContinuation(input: {
worktreePath?: string
preferredTaskSessionId?: string
preferredTaskTitle?: string
backgroundManager?: BackgroundManager
backgroundManager?: BackgroundTaskStatusProvider
sessionState: SessionState
}): Promise<BoulderContinuationResult> {
const {
@@ -41,7 +42,7 @@ export async function injectBoulderContinuation(input: {
} = input
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => ACTIVE_BACKGROUND_TASK_STATUSES.has(t.status))
: false
if (hasRunningBgTasks) {
+13
View File
@@ -25,6 +25,16 @@ export function createAtlasEventHandler(input: {
state.lastEventWasAbortError = isAbort
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
if (!isAbort) {
const previousInjectedAt = state.lastContinuationInjectedAt
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
if (
state.lastContinuationInjectedAt !== undefined
&& state.lastContinuationInjectedAt !== previousInjectedAt
) {
state.skipNextIdleAfterRuntimeErrorRetry = true
}
}
return
}
@@ -44,6 +54,7 @@ export function createAtlasEventHandler(input: {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
if (role === "user") {
state.waitingForFinalWaveApproval = false
}
@@ -60,6 +71,7 @@ export function createAtlasEventHandler(input: {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
}
}
return
@@ -71,6 +83,7 @@ export function createAtlasEventHandler(input: {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
}
}
return
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, test, afterAll } from "bun:test"
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"
@@ -7,32 +7,7 @@ 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,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
import { createAtlasHook } from "./index"
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
type PromptMock = ReturnType<typeof mock>
@@ -89,28 +64,6 @@ describe("Atlas final verification approval gate", () => {
}
}
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-7" },
}),
)
}
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 })
@@ -127,7 +80,6 @@ describe("Atlas final verification approval gate", () => {
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(
@@ -155,7 +107,7 @@ describe("Atlas final verification approval gate", () => {
writeBoulderState(testDirectory, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createAtlasHook(mockInput, { directory: testDirectory, isCallerOrchestrator: async () => true })
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -176,13 +128,11 @@ session_id: ses_final_wave_review
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(
@@ -210,7 +160,10 @@ session_id: ses_final_wave_review
}
writeBoulderState(testDirectory, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = {
title: "Sisyphus Task",
output: `Implementation finished successfully
@@ -229,6 +182,5 @@ session_id: ses_feature_task
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
cleanupMessageStorage(sessionID)
})
})
+16 -2
View File
@@ -4,12 +4,14 @@ import {
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPath,
} from "../../features/boulder-state"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getLastAgentFromSession } from "./session-last-agent"
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
@@ -52,8 +54,12 @@ async function injectContinuation(input: {
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const currentPlanPath = currentBoulder
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
: null
const currentTask = currentBoulder
? readCurrentTopLevelTask(currentBoulder.active_plan)
&& currentPlanPath
? readCurrentTopLevelTask(currentPlanPath)
: null
const preferredTaskSession = currentTask
? getTaskSessionState(input.ctx.directory, currentTask.key)
@@ -163,7 +169,7 @@ function scheduleRetry(input: {
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
const canContinueSession = await canContinueTrackedBoulderSession({
@@ -254,6 +260,12 @@ export async function handleAtlasSessionIdle(input: {
return
}
if (sessionState.skipNextIdleAfterRuntimeErrorRetry) {
sessionState.skipNextIdleAfterRuntimeErrorRetry = false
log(`[${HOOK_NAME}] Skipped: stale idle after runtime error retry`, { sessionID })
return
}
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) {
const timeSinceLastFailure =
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
@@ -291,6 +303,8 @@ export async function handleAtlasSessionIdle(input: {
return
}
await settleAfterSessionIdle(options?.idleSettleMs)
await injectContinuation({
ctx,
sessionID,
+336 -136
View File
@@ -1,8 +1,9 @@
import { describe, expect, test, beforeEach, afterEach, mock, afterAll } from "bun:test"
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { createOpencodeClient } from "@opencode-ai/sdk"
import {
writeBoulderState,
clearBoulderState,
@@ -10,35 +11,16 @@ import {
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import type { PendingTaskRef } from "./types"
import type { AtlasHookOptions, PendingTaskRef } from "./types"
import { createAtlasHook } from "./index"
import { createToolExecuteAfterHandler } from "./tool-execute-after"
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-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 dir = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(dir) ? dir : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
const { createToolExecuteBeforeHandler } = await import("./tool-execute-before")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
const callerAgentBySession = new Map<string, string>()
type MockAtlasInput = Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
describe("atlas hook", () => {
let TEST_DIR: string
@@ -47,7 +29,7 @@ describe("atlas hook", () => {
function createMockPluginInput(overrides?: {
promptMock?: ReturnType<typeof mock>
sessionGetMock?: ReturnType<typeof mock>
}) {
}): MockAtlasInput {
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
const sessionGetMock = overrides?.sessionGetMock ?? mock(async ({ path }: { path: { id: string } }) => ({
data: {
@@ -55,40 +37,42 @@ describe("atlas hook", () => {
parentID: path.id.startsWith("ses_") ? "session-1" : "main-session-123",
},
}))
const client = createOpencodeClient({ baseUrl: "http://localhost" })
Reflect.set(client.session, "get", sessionGetMock)
Reflect.set(client.session, "prompt", promptMock)
Reflect.set(client.session, "promptAsync", promptMock)
return {
directory: TEST_DIR,
client: {
session: {
get: sessionGetMock,
prompt: promptMock,
promptAsync: promptMock,
},
},
project: {} as Parameters<typeof createAtlasHook>[0]["project"],
worktree: TEST_DIR,
serverUrl: new URL("http://localhost"),
$: {} as Parameters<typeof createAtlasHook>[0]["$"],
client,
_promptMock: promptMock,
_sessionGetMock: sessionGetMock,
} as unknown as Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
}
function setupMessageStorage(sessionID: string, agent: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDir)) {
mkdirSync(messageDir, { recursive: true })
}
const messageData = {
agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
callerAgentBySession.set(sessionID, agent)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDir)) {
rmSync(messageDir, { recursive: true, force: true })
callerAgentBySession.delete(sessionID)
}
function createTestAtlasHook(
input = createMockPluginInput(),
options: Partial<AtlasHookOptions> = {},
): ReturnType<typeof createAtlasHook> {
const resolvedOptions: AtlasHookOptions = {
directory: TEST_DIR,
idleSettleMs: 0,
isCallerOrchestrator: async (sessionID) => callerAgentBySession.get(sessionID ?? "") === "atlas",
...options,
}
return createAtlasHook(input, resolvedOptions)
}
beforeEach(() => {
@@ -104,10 +88,12 @@ describe("atlas hook", () => {
mkdirSync(SISYPHUS_DIR, { recursive: true })
}
clearBoulderState(TEST_DIR)
callerAgentBySession.clear()
})
afterEach(() => {
_resetForTesting()
callerAgentBySession.clear()
clearBoulderState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
@@ -117,12 +103,12 @@ describe("atlas hook", () => {
describe("tool.execute.after handler", () => {
test("should handle undefined output gracefully (issue #1035)", async () => {
// given - hook and undefined output (e.g., from /review command)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - calling with undefined output
const result = await hook["tool.execute.after"](
{ tool: "task", sessionID: "session-123" },
undefined as unknown as { title: string; output: string; metadata: Record<string, unknown> }
undefined
)
// then - returns undefined without throwing
@@ -131,7 +117,7 @@ describe("atlas hook", () => {
test("should ignore non-task tools", async () => {
// given - hook and non-task tool
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Test Tool",
output: "Original output",
@@ -164,7 +150,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -188,7 +174,7 @@ describe("atlas hook", () => {
const sessionID = "session-no-boulder-test"
setupMessageStorage(sessionID, "atlas")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -225,7 +211,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -264,7 +250,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed
@@ -301,7 +287,7 @@ session_id: ses_subagent_abc
const sessionID = "session-standalone-metadata-test"
setupMessageStorage(sessionID, "atlas")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed
@@ -349,7 +335,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Original output",
@@ -386,7 +372,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
@@ -422,7 +408,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput({
const hook = createTestAtlasHook(createMockPluginInput({
sessionGetMock: mock(async () => {
throw new Error("session lookup failed")
}),
@@ -462,7 +448,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
@@ -499,7 +485,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
@@ -536,7 +522,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
@@ -581,6 +567,7 @@ session_id: ses_standalone_def
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
const afterHandler = createToolExecuteAfterHandler({
ctx: createMockPluginInput(),
@@ -588,6 +575,7 @@ session_id: ses_standalone_def
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
// when - the task is captured before execution
@@ -634,7 +622,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed successfully
@@ -684,7 +672,7 @@ session_id: ses_auth_flow_123
plan_name: "stable-task-key-plan",
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - Atlas delegates task 1
await hook["tool.execute.before"](
@@ -744,7 +732,7 @@ session_id: ses_auth_flow_123
plan_name: "cross-task-resume-plan",
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - Atlas resumes an explicit prior session
await hook["tool.execute.before"](
@@ -806,7 +794,7 @@ session_id: ses_old_task_111
},
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task continued successfully
@@ -860,6 +848,7 @@ session_id: ses_old_task_111
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
const afterHandler = createToolExecuteAfterHandler({
ctx: createMockPluginInput(),
@@ -867,6 +856,7 @@ session_id: ses_old_task_111
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
// when - two task() calls start before either one completes
@@ -929,7 +919,7 @@ session_id: ses_parallel_collision_222
plan_name: "untrusted-session-id-plan",
})
const hook = createAtlasHook(createMockPluginInput({
const hook = createTestAtlasHook(createMockPluginInput({
sessionGetMock: mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
@@ -987,7 +977,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1022,7 +1012,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1061,7 +1051,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1093,7 +1083,7 @@ session_id: ses_untrusted_999
test("should append delegation reminder when orchestrator writes outside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
@@ -1107,14 +1097,14 @@ session_id: ses_untrusted_999
)
// then
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).toContain("DELEGATION REQUIRED")
expect(output.output).toContain("task")
expect(output.output).toContain("task")
})
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Edit",
output: "File edited successfully",
@@ -1128,12 +1118,12 @@ session_id: ses_untrusted_999
)
// then
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1149,7 +1139,7 @@ session_id: ses_untrusted_999
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
@@ -1157,7 +1147,7 @@ session_id: ses_untrusted_999
const nonOrchestratorSession = "non-orchestrator-session"
setupMessageStorage(nonOrchestratorSession, "sisyphus-junior")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1173,14 +1163,14 @@ session_id: ses_untrusted_999
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).not.toContain("DELEGATION REQUIRED")
cleanupMessageStorage(nonOrchestratorSession)
})
test("should NOT append reminder for read-only tools", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File content"
const output = {
title: "Read",
@@ -1200,7 +1190,7 @@ session_id: ses_untrusted_999
test("should handle missing filePath gracefully", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1221,7 +1211,7 @@ session_id: ses_untrusted_999
describe("cross-platform path validation (Windows support)", () => {
test("should NOT append reminder when orchestrator writes inside .sisyphus\\ (Windows backslash)", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1237,12 +1227,12 @@ session_id: ses_untrusted_999
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1258,12 +1248,12 @@ session_id: ses_untrusted_999
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1279,12 +1269,12 @@ session_id: ses_untrusted_999
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should append reminder for Windows path outside .sisyphus\\", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
@@ -1298,7 +1288,7 @@ session_id: ses_untrusted_999
)
// then
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).toContain("DELEGATION REQUIRED")
})
})
})
@@ -1339,7 +1329,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1357,10 +1347,44 @@ session_id: ses_untrusted_999
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should settle idle before injecting boulder continuation", async () => {
// given
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] 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(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput, { idleSettleMs: 50 })
// when
const startedAt = Date.now()
const eventPromise = hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
await Promise.resolve()
// then
expect(mockInput._promptMock).not.toHaveBeenCalled()
await eventPromise
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45)
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
})
test("should not inject when no boulder state exists", async () => {
// given - no boulder state
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1388,7 +1412,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - main session fires idle but is NOT in boulder's session_ids
await hook.handler({
@@ -1419,7 +1443,7 @@ session_id: ses_untrusted_999
updateSessionAgent(subagentSessionID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - subagent session goes idle before explicit tracking appends it
await hook.handler({
@@ -1451,7 +1475,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: {
@@ -1480,7 +1504,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1494,6 +1518,43 @@ session_id: ses_untrusted_999
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
// given
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-complete-plan.md")
mkdirSync(join(TEST_DIR, ".sisyphus", "plans"), { recursive: true })
mkdirSync(join(worktreeDir, ".sisyphus", "plans"), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
writeBoulderState(TEST_DIR, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "worktree-complete-plan",
worktree_path: worktreeDir,
})
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput)
try {
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then
expect(mockInput._promptMock).not.toHaveBeenCalled()
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
})
test("should skip when abort error occurred before idle", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
@@ -1508,7 +1569,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - send abort error then idle
await hook.handler({
@@ -1531,6 +1592,142 @@ session_id: ses_untrusted_999
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("#given boulder has incomplete tasks #when non-abort session error fires #then continuation injects immediately", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "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(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput)
// when - a recoverable runtime error fires without waiting for idle
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID: MAIN_SESSION_ID,
error: { name: "RuntimeError", message: "provider overloaded" },
},
},
})
// then - boulder resumes work immediately
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("#given boulder retried a runtime error #when stale idle follows #then no delayed duplicate retry is scheduled", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "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(TEST_DIR, state)
const originalSetTimeout = globalThis.setTimeout
const scheduledDelays: number[] = []
globalThis.setTimeout = ((_handler: Parameters<typeof setTimeout>[0], timeout?: number, ..._args: unknown[]) => {
scheduledDelays.push(timeout ?? 0)
return originalSetTimeout(() => undefined, 0)
}) as typeof setTimeout
try {
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput)
// when - runtime error resumes immediately and OpenCode later emits stale idle
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID: MAIN_SESSION_ID,
error: { name: "RuntimeError", message: "provider overloaded" },
},
},
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - stale idle is consumed, not converted into another scheduled continuation
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
expect(scheduledDelays).toHaveLength(0)
} finally {
globalThis.setTimeout = originalSetTimeout
}
})
test("#given boulder retried a runtime error #when assistant activity arrives #then next idle can continue", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "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(TEST_DIR, state)
const originalDateNow = Date.now
let now = 1000
Date.now = () => now
try {
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput)
// when - runtime error resumes immediately and then the retry run emits assistant activity
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID: MAIN_SESSION_ID,
error: { name: "RuntimeError", message: "provider overloaded" },
},
},
})
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID: MAIN_SESSION_ID, role: "assistant" } },
},
})
now = 7000
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - assistant activity marks the following idle as real work completion
expect(mockInput._promptMock).toHaveBeenCalledTimes(2)
} finally {
Date.now = originalDateNow
}
})
test("should skip when background tasks are running", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
@@ -1549,9 +1746,9 @@ session_id: ses_untrusted_999
}
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput, {
const hook = createTestAtlasHook(mockInput, {
directory: TEST_DIR,
backgroundManager: mockBackgroundManager as any,
backgroundManager: mockBackgroundManager,
})
// when
@@ -1580,7 +1777,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput, {
const hook = createTestAtlasHook(mockInput, {
directory: TEST_DIR,
isContinuationStopped: (sessionID: string) => sessionID === MAIN_SESSION_ID,
})
@@ -1611,7 +1808,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - abort error, then message update, then idle
await hook.handler({
@@ -1654,7 +1851,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1696,7 +1893,7 @@ session_id: ses_untrusted_999
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1731,7 +1928,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1762,7 +1959,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: {
@@ -1792,7 +1989,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1824,7 +2021,7 @@ session_id: ses_untrusted_999
registerAgentName("Atlas - Plan Executor")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1855,7 +2052,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - fire multiple idle events in rapid succession (simulating infinite loop bug)
await hook.handler({
@@ -1896,7 +2093,7 @@ session_id: ses_untrusted_999
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -1938,7 +2135,7 @@ session_id: ses_untrusted_999
promptMock.mockImplementationOnce(() => Promise.resolve())
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -1974,7 +2171,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2015,7 +2212,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2060,7 +2257,7 @@ session_id: ses_untrusted_999
}
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2111,7 +2308,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2155,7 +2352,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - create abort state then delete
await hook.handler({
@@ -2208,7 +2405,7 @@ session_id: ses_untrusted_999
updateSessionAgent(MAIN_SESSION_ID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -2223,8 +2420,7 @@ session_id: ses_untrusted_999
})
describe("delayed retry timer (abort-stuck fix)", () => {
const capturedTimers = new Map<number, { callback: Function; cleared: boolean }>()
let nextFakeId = 99000
const capturedTimers = new Map<ReturnType<typeof setTimeout>, { callback: () => void | Promise<void>; cleared: boolean }>()
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const originalDateNow = Date.now
@@ -2232,28 +2428,32 @@ session_id: ses_untrusted_999
beforeEach(() => {
capturedTimers.clear()
nextFakeId = 99000
fakeNow = 10000
Date.now = () => fakeNow
globalThis.setTimeout = ((callback: Function, delay?: number, ...args: unknown[]) => {
globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]) => {
const normalized = typeof delay === "number" ? delay : 0
if (normalized >= 5000) {
const id = nextFakeId++
capturedTimers.set(id, { callback: () => callback(...args), cleared: false })
return id as unknown as ReturnType<typeof setTimeout>
const timerID = originalSetTimeout(() => undefined, 0)
const capturedCallback = typeof callback === "function"
? () => callback(...args)
: () => undefined
capturedTimers.set(timerID, { callback: capturedCallback, cleared: false })
return timerID
}
return originalSetTimeout(callback as Parameters<typeof originalSetTimeout>[0], delay)
}) as unknown as typeof setTimeout
return typeof callback === "function"
? originalSetTimeout(callback, delay, ...args)
: originalSetTimeout(() => undefined, delay)
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
if (typeof id === "number" && capturedTimers.has(id)) {
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
if (id && capturedTimers.has(id)) {
capturedTimers.get(id)!.cleared = true
capturedTimers.delete(id)
return
}
originalClearTimeout(id as Parameters<typeof originalClearTimeout>[0])
}) as unknown as typeof clearTimeout
originalClearTimeout(id)
}) as typeof clearTimeout
})
afterEach(() => {
@@ -2287,7 +2487,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, second idle within cooldown schedules retry timer
await hook.handler({
@@ -2316,7 +2516,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, then 3 rapid idles within cooldown
await hook.handler({
@@ -2351,7 +2551,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, second schedules retry, then plan completes before timer fires
await hook.handler({
@@ -2382,7 +2582,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
@@ -2415,7 +2615,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
@@ -1,26 +1,30 @@
declare const require: (name: string) => any
const { describe, expect, mock, test, afterAll } = require("bun:test")
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { afterAll, describe, expect, test } from "bun:test"
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { ModelInfo } from "./types"
const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `recent-model-fallback-${Date.now()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
function findNearestTestMessage(messageDir: string): { model?: ModelInfo; tools?: Record<string, boolean> } | null {
const [message] = readdirSync(messageDir)
.filter((fileName) => fileName.endsWith(".json"))
.map((fileName) => {
const content = readFileSync(join(messageDir, fileName), "utf-8")
const parsed = JSON.parse(content) as { model?: ModelInfo; tools?: Record<string, boolean>; time?: { created?: number } }
return {
message: parsed,
createdAt: parsed.time?.created ?? Number.NEGATIVE_INFINITY,
fileName,
}
})
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
}))
return message?.message ?? null
}
afterAll(() => {
mock.restore()
while (testDirs.length > 0) {
const directory = testDirs.pop()
if (directory) {
@@ -34,8 +38,10 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
// given
const sessionID = "ses_recent_model_fallback"
const directory = mkdtempSync(join(tmpdir(), "recent-model-fallback-dir-"))
const storageRoot = mkdtempSync(join(tmpdir(), "recent-model-fallback-storage-"))
testDirs.push(directory)
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
testDirs.push(storageRoot)
const messageDir = join(storageRoot, sessionID)
mkdirSync(messageDir, { recursive: true })
writeFileSync(join(messageDir, "msg_ffff0000_000001.json"), JSON.stringify({
agent: "atlas",
@@ -50,8 +56,6 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
time: { created: 100 },
}), "utf-8")
const { resolveRecentPromptContextForSession } = await import("./recent-model-resolver")
const ctx = {
client: {
session: {
@@ -63,7 +67,12 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
}
// when
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID)
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID, {
isSqliteBackend: () => false,
getMessageDir: () => messageDir,
findNearestMessageWithFields: findNearestTestMessage,
findNearestMessageWithFieldsFromSDK: async () => null,
})
// then
expect(result.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
+20 -5
View File
@@ -11,9 +11,24 @@ type PromptContext = {
tools?: Record<string, boolean>
}
type RecentPromptContextDeps = {
isSqliteBackend: typeof isSqliteBackend
getMessageDir: typeof getMessageDir
findNearestMessageWithFields: typeof findNearestMessageWithFields
findNearestMessageWithFieldsFromSDK: typeof findNearestMessageWithFieldsFromSDK
}
const defaultDeps: RecentPromptContextDeps = {
isSqliteBackend,
getMessageDir,
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
}
export async function resolveRecentPromptContextForSession(
ctx: PluginInput,
sessionID: string
sessionID: string,
deps: RecentPromptContextDeps = defaultDeps,
): Promise<PromptContext> {
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
@@ -59,11 +74,11 @@ export async function resolveRecentPromptContextForSession(
}
let currentMessage = null
if (isSqliteBackend()) {
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
if (deps.isSqliteBackend()) {
currentMessage = await deps.findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const messageDir = deps.getMessageDir(sessionID)
currentMessage = messageDir ? deps.findNearestMessageWithFields(messageDir) : null
}
const model = currentMessage?.model
const tools = normalizePromptTools(currentMessage?.tools)
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
@@ -96,4 +96,39 @@ describe("resolveActiveBoulderSession", () => {
expect(result?.progress.isComplete).toBe(false)
expect(result?.boulderState.session_ids).toContain("ses_appended")
})
test("returns complete progress when a mirrored worktree plan is complete", async () => {
// given
const mainPlanPath = join(testDirectory, ".sisyphus", "plans", "worktree-plan.md")
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8")
writeBoulderState(testDirectory, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_tracked"],
session_origins: { ses_tracked: "direct" },
plan_name: "worktree-plan",
worktree_path: worktreeDirectory,
})
try {
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_tracked",
})
// then
expect(result).not.toBeNull()
expect(result?.progress.isComplete).toBe(true)
expect(result?.progress.completed).toBe(1)
} finally {
rmSync(worktreeDirectory, { recursive: true, force: true })
}
})
})
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
export async function resolveActiveBoulderSession(input: {
@@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: {
return null
}
const progress = getPlanProgress(boulderState.active_plan)
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
}
+30 -42
View File
@@ -6,24 +6,18 @@ export const DIRECT_WORK_REMINDER = `
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
You just performed direct file modifications outside \`.sisyphus/\`.
**You just edited a source file directly.**
**You are an ORCHESTRATOR, not an IMPLEMENTER.**
Did you ACTUALLY need to be the one doing that?
As an orchestrator, you should:
- **DELEGATE** implementation work to subagents via \`task\`
- **VERIFY** the work done by subagents
- **COORDINATE** multiple tasks and ensure completion
- If this was a tiny verification fix during subagent review → fine, continue.
- If this was implementation work of any size → **you violated orchestrator protocol.** Real work goes through \`task()\`. Revert the change and delegate it via \`task()\`. The subagent has the context, the tools, and the model for that work — you do not.
You should NOT:
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
- Make direct file edits outside \`.sisyphus/\`
- Implement features yourself
**Atlas does not implement. Atlas orchestrates.** Every direct edit erodes the
delegation pipeline you exist to run, and steals work the subagent is paid to do.
**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
Going forward: \`task()\` for implementation. Fan out in PARALLEL when independent
tasks remain — do not dispatch them one at a time.
---
`
@@ -168,47 +162,41 @@ export const ORCHESTRATOR_DELEGATION_REQUIRED = `
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
**STOP. Atlas does not edit source code.**
You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
Path attempted: \`$FILE_PATH\`
**Path attempted:** $FILE_PATH
Ask yourself, honestly, before this write goes through:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. **Do you ACTUALLY need to be the one doing this?**
If a subagent could do it via \`task()\` — and the answer is almost always yes — you are stealing the subagent's work.
**THIS IS FORBIDDEN** (except for VERIFICATION purposes)
2. **Is this STRICTLY a small verification fix on subagent output?**
(≤ a couple of lines, fixing something the subagent left wrong during review.)
If yes, fine. If no — STOP this edit. Delegate it.
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
If you are about to write more than a trivial verification patch, or you are touching code no subagent has produced yet, **you are implementing**. That is forbidden.
**ALLOWED direct file operations:**
- Files inside \`.sisyphus/\` (plans, notepads, drafts)
- Reading files for verification
- Running diagnostics/tests
**Implementing yourself is the single most expensive failure mode of this role.**
Atlas is paid to ORCHESTRATE. The subagents are paid to IMPLEMENT. Every direct edit erodes the delegation pipeline you exist to run.
**FORBIDDEN direct file operations:**
- Writing/editing source code
- Creating new files outside \`.sisyphus/\`
- Any implementation work
Correct action — delegate via \`task()\`. Fan out in PARALLEL when multiple independent items remain (one message, multiple \`task()\` calls — never one-by-one):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**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:**
\`\`\`
\`\`\`typescript
task(
category="...",
category="quick",
load_skills=[],
prompt="[specific single task with clear acceptance criteria]"
run_in_background=false,
prompt="[6 sections: TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT]"
)
\`\`\`
DELEGATE. DON'T IMPLEMENT.
Allowed direct operations:
- \`.sisyphus/\` files (plans, notepads)
- Reading any file (verification)
- Running commands (verification)
Everything else: DELEGATE.
---
`
+9 -6
View File
@@ -1,9 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
appendSessionId,
getPlanProgress,
getTaskSessionState,
readBoulderState,
resolveBoulderPlanPath,
upsertTaskSessionState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
@@ -32,15 +32,17 @@ export function createToolExecuteAfterHandler(input: {
pendingTaskRefs: Map<string, PendingTaskRef>
autoCommit: boolean
getState: (sessionID: string) => SessionState
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
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))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
@@ -98,12 +100,13 @@ export function createToolExecuteAfterHandler(input: {
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
const progress = getPlanProgress(planPath)
const {
currentTask,
shouldSkipTaskSessionUpdate,
shouldIgnoreCurrentSessionId,
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
} = resolveTaskContext(pendingTaskRef, planPath)
const trackedTaskSession = currentTask
? getTaskSessionState(ctx.directory, currentTask.key)
: null
@@ -136,7 +139,7 @@ export function createToolExecuteAfterHandler(input: {
const originalResponse = toolOutput.output
const shouldPauseForApproval = sessionState
? shouldPauseForFinalWaveApproval({
planPath: boulderState.active_plan,
planPath,
taskOutput: originalResponse,
sessionState,
})
+5 -3
View File
@@ -2,7 +2,7 @@ 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 { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } 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"
@@ -13,18 +13,20 @@ export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
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))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
@@ -60,7 +62,7 @@ export function createToolExecuteBeforeHandler(input: {
} else {
const boulderState = readBoulderState(ctx.directory)
const currentTask = boulderState
? readCurrentTopLevelTask(boulderState.active_plan)
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
: null
if (currentTask) {
const task = {
+8 -2
View File
@@ -1,14 +1,19 @@
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; variant?: string }
export interface BackgroundTaskStatusProvider {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}
export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
backgroundManager?: BackgroundTaskStatusProvider
isContinuationStopped?: (sessionID: string) => boolean
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
agentOverrides?: AgentOverrides
idleSettleMs?: number
/** Enable auto-commit after each atomic task completion (default: true) */
autoCommit?: boolean
}
@@ -34,6 +39,7 @@ export type PendingTaskRef =
export interface SessionState {
lastEventWasAbortError?: boolean
skipNextIdleAfterRuntimeErrorRetry?: boolean
lastContinuationInjectedAt?: number
isInjectingContinuation?: boolean
promptFailureCount: number
@@ -1,4 +1,5 @@
import { log } from "../../../shared/logger"
import { compareVersions } from "../../../shared/opencode-version"
import type { UpdateCheckResult } from "../types"
import { extractChannel } from "../version-channel"
import { isLocalDevMode } from "./local-dev-path"
@@ -55,7 +56,7 @@ export async function checkForUpdate(directory: string): Promise<UpdateCheckResu
}
}
const needsUpdate = currentVersion !== latestVersion
const needsUpdate = compareVersions(currentVersion, latestVersion) !== 0
log(
`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`
)
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
**Generated:** 2026-04-18
**Generated:** 2026-05-08
## OVERVIEW
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/comment-checker/ — AI Slop Comment Blocker
**Generated:** 2026-04-18
**Generated:** 2026-05-08
## OVERVIEW
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../shared/bun-spawn-shim"
import { createRequire } from "module"
import { dirname, join } from "path"
import { existsSync } from "fs"
@@ -35,7 +35,15 @@ export function createCompactionContextInjector(options?: {
const { recoverCheckpointedAgentConfig, maybeWarnAboutNoTextTail } = createRecoveryLogic(ctx, getTailState)
const restore = async (sessionID: string): Promise<boolean> => {
return recoverCheckpointedAgentConfig(sessionID, "compaction.autocontinue")
}
const capture = async (sessionID: string): Promise<void> => {
if (sessionID) {
clearCompactionAgentConfigCheckpoint(sessionID)
}
if (!ctx || !sessionID) {
return
}
@@ -160,5 +168,5 @@ export function createCompactionContextInjector(options?: {
}
}
return { capture, inject, event }
return { capture, restore, inject, event }
}
@@ -19,7 +19,9 @@ afterAll(() => {
})
import { createCompactionContextInjector } from "./index"
import type { BackgroundManager } from "../../features/background-agent"
import { TaskHistory } from "../../features/background-agent/task-history"
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
function createMockContext(
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
@@ -42,6 +44,10 @@ function createMockContext(
}
}
function createMockBackgroundManager(): BackgroundManager {
return { taskHistory: new TaskHistory() } as BackgroundManager
}
describe("createCompactionContextInjector", () => {
describe("Agent Verification State preservation", () => {
it("includes Agent Verification State section in compaction prompt", async () => {
@@ -112,7 +118,7 @@ describe("createCompactionContextInjector", () => {
it("injects actual task history when backgroundManager and sessionID provided", async () => {
//#given
const mockManager = { taskHistory: new TaskHistory() } as any
const mockManager = createMockBackgroundManager()
mockManager.taskHistory.record("ses_parent", { id: "t1", sessionID: "ses_child", agent: "explore", description: "Find patterns", status: "completed", category: "quick" })
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
@@ -128,7 +134,7 @@ describe("createCompactionContextInjector", () => {
it("does not inject task history section when no entries exist", async () => {
//#given
const mockManager = { taskHistory: new TaskHistory() } as any
const mockManager = createMockBackgroundManager()
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
//#when
@@ -164,12 +170,22 @@ describe("createCompactionContextInjector", () => {
},
},
],
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
@@ -203,6 +219,99 @@ describe("createCompactionContextInjector", () => {
})
})
it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
const ctx = createMockContext(
[
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: "allow" },
},
},
],
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
],
promptAsyncMock,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture("ses_autocontinue_checkpoint")
const restored = await injector.restore("ses_autocontinue_checkpoint")
//#then
expect(restored).toBe(true)
expect(promptAsyncMock).toHaveBeenCalledWith({
path: { id: "ses_autocontinue_checkpoint" },
body: {
noReply: true,
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
parts: [
{
type: "text",
text: expect.stringContaining("restore checkpointed session agent configuration"),
},
],
},
query: { directory: "/tmp/test" },
})
})
it("clears stale checkpoint when the next compaction capture has no prompt config", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
const sessionID = "ses_empty_checkpoint_capture"
setCompactionAgentConfigCheckpoint(sessionID, {
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
})
const ctx = createMockContext([[], [], []], promptAsyncMock)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture(sessionID)
const restored = await injector.restore(sessionID)
//#then
expect(restored).toBe(false)
expect(promptAsyncMock).not.toHaveBeenCalled()
})
it("recovers after five consecutive assistant messages with no text", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
@@ -28,7 +28,7 @@ export function createRecoveryLogic(
) {
const recoverCheckpointedAgentConfig = async (
sessionID: string,
reason: "session.compacted" | "no-text-tail",
reason: "compaction.autocontinue" | "session.compacted" | "no-text-tail",
): Promise<boolean> => {
if (!ctx) {
return false
@@ -73,7 +73,7 @@ export function createRecoveryLogic(
const model = expectedPromptConfig.model
const tools = expectedPromptConfig.tools
if (reason === "session.compacted") {
if (reason === "compaction.autocontinue" || reason === "session.compacted") {
const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
if (isPromptConfigRecovered(latestPromptConfig, expectedPromptConfig)) {
return false
@@ -1,5 +1,6 @@
export interface CompactionContextInjector {
capture: (sessionID: string) => Promise<void>
restore: (sessionID: string) => Promise<boolean>
inject: (sessionID?: string) => string
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
}
+121 -4
View File
@@ -2,15 +2,27 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
interface TodoSnapshot {
id: string
id?: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
}
type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise<void>
type ToolExecuteBeforeInput = { tool: string; sessionID: string; callID: string }
type ToolExecuteBeforeOutput = { args: Record<string, unknown> }
const HOOK_NAME = "compaction-todo-preserver"
const ATLAS_BOOTSTRAP_TODOS = [
{
id: "orchestrate-plan",
content: "Complete ALL implementation tasks",
},
{
id: "pass-final-wave",
content: "Pass Final Verification Wave - ALL reviewers APPROVE",
},
] as const
function extractTodos(response: unknown): TodoSnapshot[] {
const payload = response as { data?: unknown }
@@ -23,6 +35,51 @@ function extractTodos(response: unknown): TodoSnapshot[] {
return []
}
function isAtlasBootstrapTodo(todo: TodoSnapshot): boolean {
return ATLAS_BOOTSTRAP_TODOS.some((bootstrapTodo) =>
todo.id === bootstrapTodo.id || todo.content === bootstrapTodo.content
)
}
function hasDetailedTodos(todos: TodoSnapshot[]): boolean {
return todos.some((todo) => !isAtlasBootstrapTodo(todo))
}
function isAtlasBootstrapTodoList(todos: TodoSnapshot[]): boolean {
return todos.length > 0 && todos.every(isAtlasBootstrapTodo)
}
function shouldRestoreOverCurrentTodos(input: {
snapshot: TodoSnapshot[]
currentTodos: TodoSnapshot[]
}): boolean {
if (input.currentTodos.length === 0) return true
if (!isAtlasBootstrapTodoList(input.currentTodos)) return false
return hasDetailedTodos(input.snapshot)
}
function extractTodoArgument(value: unknown): TodoSnapshot[] {
if (Array.isArray(value)) {
return value as TodoSnapshot[]
}
if (typeof value !== "string") {
return []
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed as TodoSnapshot[] : []
} catch (err) {
log(`[${HOOK_NAME}] Failed to parse todowrite todos`, { error: String(err) })
return []
}
}
function isTodoWriteTool(toolName: string): boolean {
return toolName.trim().toLowerCase() === "todowrite"
}
async function resolveTodoWriter(): Promise<TodoWriter | null> {
try {
const loader = "opencode/session/todo"
@@ -46,23 +103,35 @@ function resolveSessionID(props?: Record<string, unknown>): string | undefined {
export interface CompactionTodoPreserver {
capture: (sessionID: string) => Promise<void>
restore: (sessionID: string) => Promise<void>
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
"tool.execute.before": (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>
}
export function createCompactionTodoPreserverHook(
ctx: PluginInput,
): CompactionTodoPreserver {
const snapshots = new Map<string, TodoSnapshot[]>()
const protectedSnapshots = new Map<string, TodoSnapshot[]>()
const capture = async (sessionID: string): Promise<void> => {
if (!sessionID) return
protectedSnapshots.delete(sessionID)
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
const todos = extractTodos(response)
if (todos.length === 0) return
if (todos.length === 0) {
snapshots.delete(sessionID)
return
}
if (!hasDetailedTodos(todos)) {
snapshots.delete(sessionID)
return
}
snapshots.set(sessionID, todos)
log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length })
} catch (err) {
snapshots.delete(sessionID)
log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) })
}
}
@@ -81,14 +150,22 @@ export function createCompactionTodoPreserverHook(
log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) })
}
if (hasCurrent && currentTodos.length > 0) {
if (hasCurrent && !shouldRestoreOverCurrentTodos({ snapshot, currentTodos })) {
snapshots.delete(sessionID)
if (hasDetailedTodos(currentTodos)) {
protectedSnapshots.set(sessionID, currentTodos)
} else {
protectedSnapshots.delete(sessionID)
}
log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length })
return
}
protectedSnapshots.set(sessionID, snapshot)
const writer = await resolveTodoWriter()
if (!writer) {
snapshots.delete(sessionID)
log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID })
return
}
@@ -110,6 +187,16 @@ export function createCompactionTodoPreserverHook(
const sessionID = resolveSessionID(props)
if (sessionID) {
snapshots.delete(sessionID)
protectedSnapshots.delete(sessionID)
}
return
}
if (event.type === "session.idle") {
const sessionID = resolveSessionID(props)
if (sessionID) {
snapshots.delete(sessionID)
protectedSnapshots.delete(sessionID)
}
return
}
@@ -123,5 +210,35 @@ export function createCompactionTodoPreserverHook(
}
}
return { capture, event }
const beforeToolExecute = async (
input: ToolExecuteBeforeInput,
output: ToolExecuteBeforeOutput,
): Promise<void> => {
if (!isTodoWriteTool(input.tool)) {
return
}
const snapshot = protectedSnapshots.get(input.sessionID)
if (!snapshot || !hasDetailedTodos(snapshot)) {
return
}
const requestedTodos = extractTodoArgument(output.args.todos)
if (requestedTodos.length === 0) {
return
}
if (!isAtlasBootstrapTodoList(requestedTodos)) {
protectedSnapshots.delete(input.sessionID)
return
}
output.args.todos = snapshot
log(`[${HOOK_NAME}] Replaced late Atlas bootstrap todowrite with restored snapshot`, {
sessionID: input.sessionID,
count: snapshot.length,
})
}
return { capture, restore, event, "tool.execute.before": beforeToolExecute }
}
@@ -1,17 +1,24 @@
import { describe, expect, it, afterAll, mock } from "bun:test"
import { describe, expect, it, afterAll, beforeEach, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createOpencodeClient } from "@opencode-ai/sdk"
import type { Todo } from "@opencode-ai/sdk"
import { createCompactionTodoPreserverHook } from "./index"
const updateMock = mock(async () => {})
let todoWriter: typeof updateMock | undefined = updateMock
mock.module("opencode/session/todo", () => ({
Todo: {
update: updateMock,
get update() {
return todoWriter
},
},
}))
beforeEach(() => {
todoWriter = updateMock
})
afterAll(() => {
mock.module("opencode/session/todo", () => ({
Todo: {
@@ -21,7 +28,9 @@ afterAll(() => {
mock.restore()
})
function createMockContext(todoResponses: Array<Todo>[]): PluginInput {
type TodoResponse = Todo[] | Error
function createMockContext(todoResponses: TodoResponse[]): PluginInput {
let callIndex = 0
const client = createOpencodeClient({ directory: "/tmp/test" })
@@ -33,6 +42,9 @@ function createMockContext(todoResponses: Array<Todo>[]): PluginInput {
client.session.todo = mock((_: SessionTodoOptions): SessionTodoResult => {
const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? []
callIndex += 1
if (current instanceof Error) {
return Promise.reject(current)
}
return Promise.resolve({ data: current, error: undefined, request, response })
})
@@ -52,8 +64,8 @@ describe("compaction-todo-preserver", () => {
updateMock.mockClear()
const sessionID = "session-compaction-missing"
const todos: Todo[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
{ content: "Task 1", status: "pending", priority: "high" },
{ content: "Task 2", status: "in_progress", priority: "medium" },
]
const ctx = createMockContext([todos, []])
const hook = createCompactionTodoPreserverHook(ctx)
@@ -72,7 +84,7 @@ describe("compaction-todo-preserver", () => {
updateMock.mockClear()
const sessionID = "session-compaction-present"
const todos: Todo[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ content: "Task 1", status: "pending", priority: "high" },
]
const ctx = createMockContext([todos, todos])
const hook = createCompactionTodoPreserverHook(ctx)
@@ -84,4 +96,227 @@ describe("compaction-todo-preserver", () => {
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("restores detailed todos when only Atlas bootstrap todos are present after compaction", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-atlas-bootstrap"
const detailedTodos: Todo[] = [
{ content: "Inspect runtime compaction state", status: "completed", priority: "high" },
{ content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" },
{ content: "Run focused tests and open PR", status: "pending", priority: "medium" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, atlasBootstrapTodos])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).toHaveBeenCalledTimes(1)
expect(updateMock).toHaveBeenCalledWith({ sessionID, todos: detailedTodos })
})
it("skips restore when current todos include meaningful post-compaction work", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-meaningful-current"
const detailedTodos: Todo[] = [
{ content: "Inspect runtime compaction state", status: "completed", priority: "high" },
{ content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" },
]
const currentTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Review post-compaction findings", status: "pending", priority: "medium" },
]
const ctx = createMockContext([detailedTodos, currentTodos])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not restore a stale snapshot after a later empty capture", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-empty-later"
const oldTodos: Todo[] = [
{ content: "Old task that no longer exists", status: "pending", priority: "high" },
]
const ctx = createMockContext([oldTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not restore a stale snapshot after a later failed capture", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-failed-later"
const oldTodos: Todo[] = [
{ content: "Old task that should not come back", status: "pending", priority: "high" },
]
const ctx = createMockContext([oldTodos, new Error("todo api unavailable")])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not retain a stale snapshot when Todo.update is unavailable", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-writer-unavailable"
const detailedTodos: Todo[] = [
{ content: "Detailed task before missing writer", status: "in_progress", priority: "high" },
]
const ctx = createMockContext([detailedTodos, [], []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
todoWriter = undefined
await hook.restore(sessionID)
todoWriter = updateMock
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not preserve Atlas bootstrap todos when they are the only pre-compaction snapshot", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-bootstrap-only-snapshot"
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([atlasBootstrapTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("preserves restored detailed todos when Atlas writes bootstrap todos after compaction", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-late-atlas-bootstrap"
const detailedTodos: Todo[] = [
{ content: "Inspect runtime compaction state", status: "completed", priority: "high" },
{ content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" },
{ content: "Run focused tests and open PR", status: "pending", priority: "medium" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
const output = { args: { todos: atlasBootstrapTodos } }
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output)
//#then
expect(updateMock).toHaveBeenCalledWith({ sessionID, todos: detailedTodos })
expect(output.args.todos).toEqual(detailedTodos)
})
it("protects detailed current todos from a later Atlas bootstrap write after compaction", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-detailed-current-late-bootstrap"
const detailedTodos: Todo[] = [
{ content: "Keep detailed task one", status: "in_progress", priority: "high" },
{ content: "Keep detailed task two", status: "pending", priority: "medium" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, detailedTodos])
const hook = createCompactionTodoPreserverHook(ctx)
const output = { args: { todos: atlasBootstrapTodos } }
//#when
await hook.capture(sessionID)
await hook.restore(sessionID)
await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output)
//#then
expect(updateMock).not.toHaveBeenCalled()
expect(output.args.todos).toEqual(detailedTodos)
})
it("clears late bootstrap protection when the session idles", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-protection-idle"
const detailedTodos: Todo[] = [
{ content: "Detailed task before idle", status: "in_progress", priority: "high" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, detailedTodos])
const hook = createCompactionTodoPreserverHook(ctx)
const output = { args: { todos: atlasBootstrapTodos } }
//#when
await hook.capture(sessionID)
await hook.restore(sessionID)
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output)
//#then
expect(output.args.todos).toEqual(atlasBootstrapTodos)
})
it("clears a pending snapshot when the session idles before restore", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-idle-before-restore"
const detailedTodos: Todo[] = [
{ content: "Detailed task before interrupted compaction", status: "in_progress", priority: "high" },
]
const ctx = createMockContext([detailedTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
})
@@ -26,6 +26,10 @@ export async function processFilePathForAgentsInjection(input: {
sessionID: string;
output: { title: string; output: string; metadata: unknown };
}): Promise<void> {
// Guard: output.output may be non-string at runtime (e.g. MCP bridge format changes).
// Consistent with the pattern used in tool-output-truncator and other hooks.
if (typeof input.output.output !== "string") return;
const resolved = resolveFilePath(input.ctx.directory, input.filePath);
if (!resolved) return;
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it } from "bun:test"
import { classifyPathEnvironment } from "../../shared/classify-path-environment"
import { clearAllSkips, recordFsyncSkip } from "../../shared/fsync-skip-tracker"
import { createFsyncSkipWarningHook } from "./index"
describe("createFsyncSkipWarningHook", () => {
beforeEach(() => {
clearAllSkips()
})
it("records callID start timestamp in tool.execute.before", async () => {
const hook = createFsyncSkipWarningHook()
const input = { tool: "bash", sessionID: "ses1", callID: "call-1" }
const output = { args: {} as Record<string, unknown> }
await hook["tool.execute.before"](input, output)
await Bun.sleep(2)
recordFsyncSkip({
filePath: "/tmp/a",
contextLabel: "atomicWrite:/tmp/a",
errorCode: "EPERM",
message: "operation not permitted",
pathClassification: classifyPathEnvironment("/tmp/a"),
})
const afterOutput = { title: "ok", output: "done", metadata: {} as Record<string, unknown> }
await hook["tool.execute.after"](input, afterOutput)
expect(afterOutput.output).toContain("[fsync-skipped]")
})
it("drains skips after start time and appends warning to output text", async () => {
const hook = createFsyncSkipWarningHook()
const input = { tool: "write", sessionID: "ses1", callID: "call-2" }
const beforeOutput = { args: {} as Record<string, unknown> }
const afterOutput = { title: "ok", output: "base", metadata: {} as Record<string, unknown> }
await hook["tool.execute.before"](input, beforeOutput)
await Bun.sleep(2)
recordFsyncSkip({
filePath: "/Users/x/OneDrive/a",
contextLabel: "atomicWrite:/Users/x/OneDrive/a",
errorCode: "EPERM",
message: "operation not permitted",
pathClassification: classifyPathEnvironment("/Users/x/OneDrive/a"),
})
await hook["tool.execute.after"](input, afterOutput)
expect(afterOutput.output).toContain("base\n\n---")
expect(afterOutput.output).toContain("OneDrive")
})
it("leaves output unchanged when no skips happen during window", async () => {
const hook = createFsyncSkipWarningHook()
const input = { tool: "write", sessionID: "ses1", callID: "call-3" }
const beforeOutput = { args: {} as Record<string, unknown> }
const afterOutput = { title: "ok", output: "base", metadata: {} as Record<string, unknown> }
await hook["tool.execute.before"](input, beforeOutput)
await hook["tool.execute.after"](input, afterOutput)
expect(afterOutput.output).toBe("base")
})
it("isolates multiple parallel calls by callID watermark", async () => {
const hook = createFsyncSkipWarningHook()
const beforeOutput = { args: {} as Record<string, unknown> }
const inputA = { tool: "write", sessionID: "ses1", callID: "call-A" }
const inputB = { tool: "write", sessionID: "ses1", callID: "call-B" }
await hook["tool.execute.before"](inputA, beforeOutput)
await Bun.sleep(2)
await hook["tool.execute.before"](inputB, beforeOutput)
await Bun.sleep(2)
recordFsyncSkip({
filePath: "/tmp/a",
contextLabel: "atomicWrite:/tmp/a",
errorCode: "EPERM",
message: "operation not permitted",
pathClassification: classifyPathEnvironment("/tmp/a"),
})
const outputA = { title: "ok", output: "A", metadata: {} as Record<string, unknown> }
const outputB = { title: "ok", output: "B", metadata: {} as Record<string, unknown> }
await hook["tool.execute.after"](inputA, outputA)
await hook["tool.execute.after"](inputB, outputB)
expect(outputA.output).toContain("[fsync-skipped]")
expect(outputB.output).toBe("B")
})
})
+50
View File
@@ -0,0 +1,50 @@
import { drainSkipsAfter } from "../../shared/fsync-skip-tracker"
import { formatFsyncSkipWarning } from "../../shared/fsync-skip-warning-formatter"
type ToolExecuteInput = {
tool: string
sessionID: string
callID: string
}
type ToolBeforeOutput = {
args: Record<string, unknown>
}
type ToolAfterOutput = {
title: string
output: string
metadata: unknown
}
export function createFsyncSkipWarningHook() {
const startTimesByCallId = new Map<string, number>()
const toolExecuteBefore = async (
input: ToolExecuteInput,
_output: ToolBeforeOutput,
): Promise<void> => {
startTimesByCallId.set(input.callID, Date.now())
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolAfterOutput,
): Promise<void> => {
if (typeof output.output !== "string") return
const startTimestamp = startTimesByCallId.get(input.callID) ?? 0
startTimesByCallId.delete(input.callID)
const skips = drainSkipsAfter(startTimestamp)
const warning = formatFsyncSkipWarning(skips)
if (warning.length === 0) return
output.output = `${output.output}\n\n${warning}`
}
return {
"tool.execute.before": toolExecuteBefore,
"tool.execute.after": toolExecuteAfter,
}
}
+4
View File
@@ -32,6 +32,8 @@ export { createNonInteractiveEnvHook } from "./non-interactive-env";
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
export { createTeamMailboxInjector } from "./team-mailbox-injector";
export { createTeamModeStatusInjector } from "./team-mode-status-injector";
export { createToolPairValidatorHook } from "./tool-pair-validator";
export { createCategorySkillReminderHook } from "./category-skill-reminder";
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
@@ -45,6 +47,7 @@ export { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad";
export { createTaskResumeInfoHook } from "./task-resume-info";
export { createStartWorkHook } from "./start-work";
export { createAtlasHook } from "./atlas";
export { createTeamToolGating } from "./team-tool-gating"
export { createDelegateTaskRetryHook } from "./delegate-task-retry";
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
@@ -62,3 +65,4 @@ export { createReadImageResizerHook } from "./read-image-resizer"
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
export { createLegacyPluginToastHook } from "./legacy-plugin-toast"
export { createFsyncSkipWarningHook } from "./fsync-skip-warning"
+22 -6
View File
@@ -1,10 +1,10 @@
# src/hooks/keyword-detector/ — Mode Keyword Injection
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
8 files + 3 mode subdirs (~1665 LOC). Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze) and injects mode-specific system prompts.
Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze, team) and injects mode-specific system prompts.
## KEYWORDS
@@ -13,6 +13,7 @@
| `ultrawork` / `ulw` | `/\b(ultrawork|ulw)\b/i` | Full orchestration mode — parallel agents, deep exploration, relentless execution |
| Search mode | `SEARCH_PATTERN` (from `search/`) | Web/doc search focus prompt injection |
| Analyze mode | `ANALYZE_PATTERN` (from `analyze/`) | Deep analysis mode prompt injection |
| Team mode | `TEAM_PATTERN` (from `team/`) | Forces orchestration via `team_*` tools when user invokes `team mode` / `팀 모드` / `팀으로`; instructs user to enable `team_mode.enabled` if tools are absent |
## STRUCTURE
@@ -31,10 +32,12 @@ keyword-detector/
│ ├── index.ts
│ ├── pattern.ts # SEARCH_PATTERN regex
│ └── message.ts # SEARCH_MESSAGE
── analyze/
── analyze/
│ ├── index.ts
│ └── default.ts # ANALYZE_PATTERN + ANALYZE_MESSAGE
└── team/
├── index.ts
── pattern.ts # ANALYZE_PATTERN regex
└── message.ts # ANALYZE_MESSAGE
── default.ts # TEAM_PATTERN + TEAM_MESSAGE
```
## DETECTION LOGIC
@@ -44,11 +47,24 @@ chat.message (user input)
→ extractPromptText(parts)
→ isSystemDirective? → skip
→ removeSystemReminders(text) # strip <SYSTEM_REMINDER> blocks
→ detectKeywordsWithType(cleanText, agentName, modelID)
→ detectKeywordsWithType(cleanText, agentName, modelID, disabledKeywords)
→ isPlannerAgent(agentName)? → filter out ultrawork
→ for each detected keyword: inject mode message into output
```
## CONFIG
```jsonc
{
"keyword_detector": {
// Skip injection for any keyword in this list. Allowed: "ultrawork", "search", "analyze", "team".
"disabled_keywords": ["search", "analyze"]
}
}
```
Default: empty/missing → all four detectors active. Schema lives at [src/config/schema/keyword-detector.ts](../../config/schema/keyword-detector.ts).
## GUARDS
- **System directive skip**: Messages tagged as system directives are not scanned (prevents infinite loops)
+38
View File
@@ -4,25 +4,48 @@ export const INLINE_CODE_PATTERN = /`[^`]+`/g
export { isPlannerAgent, isNonOmoAgent, getUltraworkMessage } from "./ultrawork"
export { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search"
export { ANALYZE_PATTERN, ANALYZE_MESSAGE } from "./analyze"
export { TEAM_PATTERN, TEAM_MESSAGE } from "./team"
export { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./hyperplan"
import type { KeywordType } from "../../config/schema/keyword-detector"
import { getUltraworkMessage } from "./ultrawork"
import { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search"
import { TEAM_PATTERN, TEAM_MESSAGE } from "./team"
import { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./hyperplan"
// Hyperplan-ultrawork combo: strict adjacency, both word orders
export const HYPERPLAN_ULTRAWORK_PATTERN =
/\b(?:hpp|hyperplan)\s+(?:ulw|ultrawork)\b|\b(?:ulw|ultrawork)\s+(?:hpp|hyperplan)\b/i
const HYPERPLAN_ULTRAWORK_BANNER = `<hyperplan-ultrawork-mode>
**MANDATORY**: Say "HYPERPLAN ULTRAWORK MODE ENABLED!" exactly once as your first response. Do NOT say the standalone "ULTRAWORK MODE ENABLED!" or "HYPERPLAN MODE ENABLED!" banners.
Apply the ultrawork protocol below as your execution framework. You MUST ALSO load the hyperplan skill immediately via \`skill(name="hyperplan")\` and follow its full adversarial workflow — do NOT improvise, do NOT skip rounds, do NOT write the plan yourself.
</hyperplan-ultrawork-mode>`
export function getHyperplanUltraworkMessage(agentName?: string, modelID?: string): string {
return `${HYPERPLAN_ULTRAWORK_BANNER}\n\n${getUltraworkMessage(agentName, modelID)}`
}
export type KeywordDetector = {
type: KeywordType
pattern: RegExp
message: string | ((agentName?: string, modelID?: string) => string)
}
export const KEYWORD_DETECTORS: KeywordDetector[] = [
{
type: "ultrawork",
pattern: /\b(ultrawork|ulw)\b/i,
message: getUltraworkMessage,
},
{
type: "search",
pattern: SEARCH_PATTERN,
message: SEARCH_MESSAGE,
},
{
type: "analyze",
pattern:
/\b(analyze|analyse|investigate|examine|research|study|deep[\s-]?dive|inspect|audit|evaluate|assess|review|diagnose|scrutinize|dissect|debug|comprehend|interpret|breakdown|understand)\b|why\s+is|how\s+does|how\s+to|분석|조사|파악|연구|검토|진단|이해|설명|원인|이유|뜯어봐|따져봐|평가|해석|디버깅|디버그|어떻게|왜|살펴|分析|調査|解析|検討|研究|診断|理解|説明|検証|精査|究明|デバッグ|なぜ|どう|仕組み|调查|检查|剖析|深入|诊断|解释|调试|为什么|原理|搞清楚|弄明白|phân tích|điều tra|nghiên cứu|kiểm tra|xem xét|chẩn đoán|giải thích|tìm hiểu|gỡ lỗi|tại sao/i,
message: `[analyze-mode]
@@ -41,4 +64,19 @@ SYNTHESIZE findings before proceeding.
MANDATORY delegate_task params: ALWAYS include load_skills and run_in_background when calling delegate_task. Evaluate available skills before dispatch - pass task-appropriate skills when relevant, pass [] ONLY when no skill matches the task domain.
Example: delegate_task(subagent_type="explore", prompt="...", run_in_background=true, load_skills=[])`,
},
{
type: "team",
pattern: TEAM_PATTERN,
message: TEAM_MESSAGE,
},
{
type: "hyperplan",
pattern: HYPERPLAN_PATTERN,
message: HYPERPLAN_MESSAGE,
},
{
type: "hyperplan-ultrawork",
pattern: HYPERPLAN_ULTRAWORK_PATTERN,
message: getHyperplanUltraworkMessage,
},
]
+31 -14
View File
@@ -1,3 +1,4 @@
import type { KeywordType } from "../../config/schema/keyword-detector"
import {
KEYWORD_DETECTORS,
CODE_BLOCK_PATTERN,
@@ -5,7 +6,7 @@ import {
} from "./constants"
export interface DetectedKeyword {
type: "ultrawork" | "search" | "analyze"
type: KeywordType
message: string
}
@@ -13,9 +14,12 @@ export function removeCodeBlocks(text: string): string {
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
}
/**
* Resolves message to string, handling both static strings and dynamic functions.
*/
const SLASH_COMMAND_LEAD_PATTERN = /^\s*\/[a-zA-Z][\w-]*(?:\s|$)/
export function looksLikeSlashCommand(text: string): boolean {
return SLASH_COMMAND_LEAD_PATTERN.test(text)
}
function resolveMessage(
message: string | ((agentName?: string, modelID?: string) => string),
agentName?: string,
@@ -24,22 +28,35 @@ function resolveMessage(
return typeof message === "function" ? message(agentName, modelID) : message
}
export function detectKeywords(text: string, agentName?: string, modelID?: string): string[] {
const textWithoutCode = removeCodeBlocks(text)
return KEYWORD_DETECTORS.filter(({ pattern }) =>
pattern.test(textWithoutCode)
).map(({ message }) => resolveMessage(message, agentName, modelID))
export function detectKeywords(
text: string,
agentName?: string,
modelID?: string,
disabledKeywords?: ReadonlyArray<KeywordType>,
): string[] {
return detectKeywordsWithType(text, agentName, modelID, disabledKeywords).map(
({ message }) => message,
)
}
export function detectKeywordsWithType(text: string, agentName?: string, modelID?: string): DetectedKeyword[] {
export function detectKeywordsWithType(
text: string,
agentName?: string,
modelID?: string,
disabledKeywords?: ReadonlyArray<KeywordType>,
): DetectedKeyword[] {
const textWithoutCode = removeCodeBlocks(text)
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
const disabled = new Set<KeywordType>(disabledKeywords ?? [])
// Intersection rule: combo requires BOTH base keywords enabled
if (disabled.has("ultrawork") || disabled.has("hyperplan")) {
disabled.add("hyperplan-ultrawork")
}
return KEYWORD_DETECTORS.map(({ type, pattern, message }) => ({
matches: pattern.test(textWithoutCode),
type: types[index],
type,
message: resolveMessage(message, agentName, modelID),
}))
.filter((result) => result.matches)
.filter((result) => result.matches && !disabled.has(result.type))
.map(({ type, message }) => ({ type, message }))
}
+64 -6
View File
@@ -1,5 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { detectKeywordsWithType, extractPromptText } from "./detector"
import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector"
import type { DetectedKeyword } from "./detector"
import { detectKeywordsWithType, extractPromptText, looksLikeSlashCommand } from "./detector"
import { isPlannerAgent, isNonOmoAgent } from "./constants"
import { log } from "../../shared"
import {
@@ -14,11 +16,19 @@ import {
import type { ContextCollector } from "../../features/context-injector"
import type { RalphLoopHook } from "../ralph-loop"
function suppressComboStandalones(detected: DetectedKeyword[]): DetectedKeyword[] {
const hasCombo = detected.some((k) => k.type === "hyperplan-ultrawork")
if (!hasCombo) return detected
return detected.filter((k) => k.type !== "ultrawork" && k.type !== "hyperplan")
}
export function createKeywordDetectorHook(
ctx: PluginInput,
_collector?: ContextCollector,
_ralphLoop?: Pick<RalphLoopHook, "startLoop">
_ralphLoop?: Pick<RalphLoopHook, "startLoop">,
config?: KeywordDetectorConfig,
) {
const disabledKeywords = config?.disabled_keywords
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
if (typeof message["variant"] === "string") {
return message["variant"]
@@ -48,6 +58,11 @@ export function createKeywordDetectorHook(
return
}
if (looksLikeSlashCommand(promptText)) {
log(`[keyword-detector] Skipping slash command invocation`, { sessionID: input.sessionID })
return
}
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
// Skip all keyword injection for non-OMO agents (e.g., OpenCode-Builder, Plan)
@@ -59,13 +74,16 @@ export function createKeywordDetectorHook(
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
const cleanText = removeSystemReminders(promptText)
const modelID = input.model?.modelID
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID)
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, disabledKeywords)
detectedKeywords = suppressComboStandalones(detectedKeywords)
if (isPlannerAgent(currentAgent)) {
const preFilterCount = detectedKeywords.length
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
detectedKeywords = detectedKeywords.filter(
(k) => k.type !== "ultrawork" && k.type !== "hyperplan" && k.type !== "hyperplan-ultrawork"
)
if (preFilterCount > detectedKeywords.length) {
log(`[keyword-detector] Filtered ultrawork keywords for planner agent`, { sessionID: input.sessionID, agent: currentAgent })
log(`[keyword-detector] Filtered ultrawork/hyperplan keywords for planner agent`, { sessionID: input.sessionID, agent: currentAgent })
}
}
@@ -83,7 +101,9 @@ export function createKeywordDetectorHook(
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
if (isNonMainSession) {
detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork")
detectedKeywords = detectedKeywords.filter(
(k) => k.type === "ultrawork" || k.type === "hyperplan-ultrawork"
)
if (detectedKeywords.length === 0) {
log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, {
sessionID: input.sessionID,
@@ -123,6 +143,44 @@ export function createKeywordDetectorHook(
}
const hasHyperplan = detectedKeywords.some((k) => k.type === "hyperplan")
if (hasHyperplan) {
log(`[keyword-detector] Hyperplan mode activated`, {
sessionID: input.sessionID,
})
ctx.client.tui
.showToast({
body: {
title: "Hyperplan Mode Activated",
message: "Adversarial planning engaged. 5 hostile members will cross-critique.",
variant: "success" as const,
duration: 3000,
},
})
.catch((err) =>
log(`[keyword-detector] Failed to show toast`, {
error: err,
sessionID: input.sessionID,
})
)
}
const hasHyperplanUltrawork = detectedKeywords.some((k) => k.type === "hyperplan-ultrawork")
if (hasHyperplanUltrawork) {
log(`[keyword-detector] Hyperplan Ultrawork mode activated`, { sessionID: input.sessionID })
ctx.client.tui
.showToast({
body: {
title: "Hyperplan Ultrawork Mode Activated",
message: "Ultrawork execution with adversarial hyperplan workflow.",
variant: "success" as const,
duration: 3000,
},
})
.catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
}
const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined)
if (textPartIndex === -1) {
log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID })
@@ -0,0 +1,261 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createKeywordDetectorHook } from "./index"
import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state"
import * as sharedModule from "../../shared"
import * as sessionState from "../../features/claude-code-session-state"
describe("keyword-detector hyperplan-ultrawork combo", () => {
let logSpy: ReturnType<typeof spyOn>
let getMainSessionSpy: ReturnType<typeof spyOn>
beforeEach(() => {
_resetForTesting()
logSpy = spyOn(sharedModule, "log").mockImplementation(() => {})
})
afterEach(() => {
logSpy?.mockRestore()
getMainSessionSpy?.mockRestore()
_resetForTesting()
})
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
const toastCalls = options.toastCalls ?? []
return {
client: {
tui: {
showToast: async (opts: { body: { title: string } }) => {
toastCalls.push(opts.body.title)
},
},
},
} as unknown as PluginInput
}
test("should inject combo message when user types 'hpp ulw' (forward order)", async () => {
// given - main session with adjacent forward-order combo keywords
const sessionID = "combo-forward-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw refactor the auth module" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - combo banner and embedded ultrawork content both present
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).toContain("<ultrawork-mode>")
expect(textPart!.text).toContain("refactor the auth module")
})
test("should inject combo message when user types 'ulw hpp' (reverse order)", async () => {
// given - main session with adjacent reverse-order combo keywords
const sessionID = "combo-reverse-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "ulw hpp ship this feature" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - combo fires identically regardless of word order
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).toContain("<ultrawork-mode>")
expect(textPart!.text).toContain("ship this feature")
})
test("should NOT trigger combo on non-adjacent 'hpp do ulw' but fire both standalones instead", async () => {
// given - keywords separated by another word block adjacency requirement
const sessionID = "combo-non-adjacent-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp do ulw stuff" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - combo absent, both standalone banners injected separately
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).toContain("<hyperplan-mode>")
expect(textPart!.text).toContain("<ultrawork-mode>")
})
test("should suppress standalone messages when combo fires (only ONE banner injected)", async () => {
// given - combo keywords that would also match standalone patterns
const sessionID = "combo-suppress-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw build" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - only combo banner present, standalone hyperplan suppressed, ultrawork content appears once via embed
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).not.toContain("<hyperplan-mode>")
const ultraworkMatches = textPart!.text!.match(/<ultrawork-mode>/g) ?? []
expect(ultraworkMatches).toHaveLength(1)
})
test("should fire combo toast and suppress standalone toasts", async () => {
// given - combo keywords with toast tracking
const sessionID = "combo-toast-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const toastCalls: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw do it" }],
}
// when - combo fires
await hook["chat.message"]({ sessionID }, output)
// then - only combo toast title is shown, standalone toasts suppressed
expect(toastCalls).toContain("Hyperplan Ultrawork Mode Activated")
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
expect(toastCalls).not.toContain("Hyperplan Mode Activated")
})
test("should disable combo only when disabled_keywords includes 'hyperplan-ultrawork' (standalones still fire)", async () => {
// given - combo keyword disabled but standalones remain enabled
const sessionID = "combo-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
{ disabled_keywords: ["hyperplan-ultrawork"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw work it" }],
}
// when - keyword detection runs with combo disabled
await hook["chat.message"]({ sessionID }, output)
// then - combo absent, both individual standalones still match and inject
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).toContain("<hyperplan-mode>")
expect(textPart!.text).toContain("<ultrawork-mode>")
})
test("should block combo via intersection rule when disabled_keywords includes 'ultrawork'", async () => {
// given - ultrawork standalone disabled, intersection rule cascades to combo
const sessionID = "combo-intersection-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const toastCalls: string[] = []
const hook = createKeywordDetectorHook(
createMockPluginInput({ toastCalls }),
undefined,
undefined,
{ disabled_keywords: ["ultrawork"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw plan stuff" }],
}
// when - combo would match but is blocked via intersection
await hook["chat.message"]({ sessionID }, output)
// then - no combo, no ultrawork content leaks; standalone hyperplan still fires
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).not.toContain("<ultrawork-mode>")
expect(textPart!.text).toContain("<hyperplan-mode>")
expect(toastCalls).not.toContain("Hyperplan Ultrawork Mode Activated")
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
})
test("should allow combo in non-main session (passes through like standalone ultrawork)", async () => {
// given - main session set, different (subagent) session triggers combo
const mainSessionID = "main-combo"
const subagentSessionID = "subagent-combo"
setMainSession(mainSessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw run this" }],
}
// when - subagent session triggers combo
await hook["chat.message"]({ sessionID: subagentSessionID }, output)
// then - combo banner reaches non-main session (whitelisted alongside standalone ultrawork)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).toContain("<ultrawork-mode>")
expect(textPart!.text).toContain("run this")
})
test("should filter combo when agent is prometheus (planner)", async () => {
// given - planner agent receives a combo prompt
const sessionID = "combo-prometheus-session"
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw plan stuff" }],
}
// when - planner-agent path filters all execution-mode keywords
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// then - text untouched: combo, ultrawork, and hyperplan all filtered for planner
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("hpp ulw plan stuff")
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).not.toContain("<ultrawork-mode>")
expect(textPart!.text).not.toContain("<hyperplan-mode>")
})
test("should reuse ultrawork variant: combo with GPT model embeds GPT ultrawork content", async () => {
// given - GPT-5.4 model selects the GPT ultrawork variant inside the combo banner
const sessionID = "combo-gpt-variant-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp ulw build feature" }],
}
// when - combo fires with GPT model resolved
await hook["chat.message"](
{ sessionID, agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-5.4" } },
output,
)
// then - combo banner present and GPT-variant ultrawork content embedded (output_verbosity_spec is GPT-only)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
expect(textPart!.text).toContain("<output_verbosity_spec>")
})
})
@@ -0,0 +1,291 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createKeywordDetectorHook } from "./index"
import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state"
import * as sharedModule from "../../shared"
import * as sessionState from "../../features/claude-code-session-state"
describe("keyword-detector hyperplan keyword", () => {
let logSpy: ReturnType<typeof spyOn>
let getMainSessionSpy: ReturnType<typeof spyOn>
beforeEach(() => {
_resetForTesting()
logSpy = spyOn(sharedModule, "log").mockImplementation(() => {})
})
afterEach(() => {
logSpy?.mockRestore()
getMainSessionSpy?.mockRestore()
_resetForTesting()
})
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
const toastCalls = options.toastCalls ?? []
return {
client: {
tui: {
showToast: async (opts: { body: { title: string } }) => {
toastCalls.push(opts.body.title)
},
},
},
} as PluginInput
}
test("should inject hyperplan message when user types 'hyperplan'", async () => {
// given - main session typing the full keyword
const sessionID = "hyperplan-full-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hyperplan refactor the auth module" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - hyperplan-mode wrapper and skill-loading instruction should be present
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-mode>")
expect(textPart!.text).toContain('skill(name="hyperplan")')
expect(textPart!.text).toContain("HYPERPLAN MODE ENABLED")
expect(textPart!.text).toContain("unspecified-low")
expect(textPart!.text).toContain("unspecified-high")
expect(textPart!.text).toContain("artistry")
expect(textPart!.text).toContain("ultrabrain")
expect(textPart!.text).toContain("deep")
expect(textPart!.text).toContain("only if")
expect(textPart!.text).toContain("enabled")
expect(textPart!.text).toContain("refactor the auth module")
expect(textPart!.text).toContain("---")
})
test("should inject hyperplan message when user types 'hpp' shorthand", async () => {
// given - main session typing the short keyword
const sessionID = "hyperplan-short-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp how should I structure this feature" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - hyperplan injection should fire
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-mode>")
expect(textPart!.text).toContain('skill(name="hyperplan")')
})
test("should inject hyperplan message case-insensitively", async () => {
// given - main session typing in mixed case
const sessionID = "hyperplan-case-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "HyperPlan something now" }],
}
// when - keyword detection runs with mixed case input
await hook["chat.message"]({ sessionID }, output)
// then - hyperplan should still fire
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-mode>")
})
test("should NOT trigger hyperplan when 'hpp' is a substring of another word", async () => {
// given - text contains 'hpp' only as part of larger string with no word boundary
const sessionID = "hyperplan-substring-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "myhppvar = 1" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - hyperplan should NOT trigger because 'hpp' lacks word boundaries
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("myhppvar = 1")
expect(textPart!.text).not.toContain("<hyperplan-mode>")
})
test("should fire 'Hyperplan Mode Activated' toast when keyword detected", async () => {
// given - main session and toast tracking
const sessionID = "hyperplan-toast-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const toastCalls: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hyperplan this task" }],
}
// when - hyperplan keyword fires
await hook["chat.message"]({ sessionID }, output)
// then - toast title should be present in tracked calls
expect(toastCalls).toContain("Hyperplan Mode Activated")
})
test("should NOT inject hyperplan when disabled_keywords includes 'hyperplan'", async () => {
// given - keyword detector with hyperplan disabled
const sessionID = "hyperplan-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const toastCalls: string[] = []
const hook = createKeywordDetectorHook(
createMockPluginInput({ toastCalls }),
undefined,
undefined,
{ disabled_keywords: ["hyperplan"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hyperplan refactor this" }],
}
// when - hyperplan keyword would normally fire
await hook["chat.message"]({ sessionID }, output)
// then - neither injection nor toast should occur
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("hyperplan refactor this")
expect(textPart!.text).not.toContain("<hyperplan-mode>")
expect(toastCalls).not.toContain("Hyperplan Mode Activated")
})
test("should filter hyperplan keyword in non-main session (only ultrawork allowed there)", async () => {
// given - main session set, different (subagent) session triggers hyperplan
const mainSessionID = "main-hyperplan"
const subagentSessionID = "subagent-hyperplan"
setMainSession(mainSessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hyperplan please" }],
}
// when - subagent session triggers hyperplan keyword
await hook["chat.message"]({ sessionID: subagentSessionID }, output)
// then - hyperplan injection should be skipped in non-main session
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("hyperplan please")
expect(textPart!.text).not.toContain("<hyperplan-mode>")
})
test("should skip hyperplan injection when agent is prometheus (planner)", async () => {
// given - hook running with prometheus agent and a prompt that only triggers hyperplan
const sessionID = "hyperplan-prometheus-session"
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hyperplan refactor stuff" }],
}
// when - hyperplan keyword detected with prometheus agent
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// then - hyperplan should be filtered out for planner agents
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("<hyperplan-mode>")
expect(textPart!.text).not.toContain('skill(name="hyperplan")')
expect(textPart!.text).toContain("hyperplan refactor stuff")
})
test("should NOT inject hyperplan when user invokes /hyperplan slash command", async () => {
// given - main session typing the slash command form
const sessionID = "hyperplan-slash-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const toastCalls: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "/hyperplan refactor the auth module" }],
}
// when - keyword detection runs on slash-command-prefixed text
await hook["chat.message"]({ sessionID }, output)
// then - the slash command path owns the message; keyword detector must not double-inject
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("/hyperplan refactor the auth module")
expect(textPart!.text).not.toContain("<hyperplan-mode>")
expect(toastCalls).not.toContain("Hyperplan Mode Activated")
})
test("should NOT inject hyperplan when user invokes /hpp shorthand slash command", async () => {
// given - main session and shorthand slash command
const sessionID = "hyperplan-slash-shorthand-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "/hpp investigate the build pipeline" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - keyword detector should yield to the slash command system
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("/hpp investigate the build pipeline")
expect(textPart!.text).not.toContain("<hyperplan-mode>")
})
test("should still inject hyperplan when slash appears mid-message (not a slash command)", async () => {
// given - text contains a slash later but does not start with one
const sessionID = "hyperplan-mid-slash-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hyperplan: refactor src/auth/handler.ts" }],
}
// when - keyword detection runs on free-form text that mentions hyperplan first
await hook["chat.message"]({ sessionID }, output)
// then - hyperplan should still fire (this is a real keyword invocation, not a slash command)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("<hyperplan-mode>")
})
test("should skip hyperplan injection when agent name contains 'planner' token", async () => {
// given - hook running with planner-named agent and a prompt that only triggers hpp
const sessionID = "hyperplan-planner-session"
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "hpp build the feature" }],
}
// when - hpp keyword detected with planner agent
await hook["chat.message"]({ sessionID, agent: "Plan Agent" }, output)
// then - hyperplan should be filtered out
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("<hyperplan-mode>")
expect(textPart!.text).not.toContain('skill(name="hyperplan")')
expect(textPart!.text).toContain("hpp build the feature")
})
})
@@ -0,0 +1,39 @@
/**
* Hyperplan keyword detector.
*
* Triggers when the user wants adversarial multi-agent planning via team-mode.
*
* Triggers (case-insensitive, word-bounded):
* - English: hyperplan, hpp
*
* The detector injects a thin wrapper that loads the `hyperplan` skill, which
* carries the full orchestration instructions for the 5-member adversarial team.
*/
export const HYPERPLAN_PATTERN = /\b(hyperplan|hpp)\b/i
export const HYPERPLAN_MESSAGE = `<hyperplan-mode>
**MANDATORY**: Say "HYPERPLAN MODE ENABLED!" as your first response, exactly once.
The user invoked **hyperplan mode** — adversarial multi-agent planning via team-mode.
LOAD THE HYPERPLAN SKILL IMMEDIATELY:
\`\`\`
skill(name="hyperplan")
\`\`\`
After loading, follow the skill's full workflow EXACTLY:
1. Acknowledge and capture the planning request
2. Spawn the adversarial team via \`team_create\` with category members \`unspecified-low\`, \`unspecified-high\`, \`ultrabrain\`, and \`artistry\`; include \`deep\` only if the category is enabled
3. Round 1 — Independent analysis (each member produces findings)
4. Round 2 — Cross-attack (each member ruthlessly attacks the other 4's findings)
5. Round 3 — Defend, refine, or concede
6. Distill defensible insights into a structured bundle (Lead does NOT write the plan)
7. MANDATORY: hand the bundle to the \`plan\` agent via \`task(subagent_type="plan", ...)\` — the plan agent owns sequencing, parallelization, and verification gates
8. Present the plan agent's output verbatim with provenance line, then clean up the team
Do NOT improvise. Do NOT skip rounds. Do NOT write the plan yourself in step 6 — the handoff to the plan agent in step 7 is non-negotiable. Be the lead orchestrator and let the adversarial members do the cross-critique.
If team-mode is unavailable (\`team_*\` tools missing), instruct the user to set \`team_mode.enabled: true\` in \`~/.config/opencode/oh-my-opencode.jsonc\` and restart opencode.
</hyperplan-mode>`
@@ -0,0 +1 @@
export { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./default"
+415
View File
@@ -860,3 +860,418 @@ describe("keyword-detector non-OMO agent skipping", () => {
expect(textPart!.text).not.toContain("[search-mode]")
})
})
describe("keyword-detector team mode", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
let logSpy: ReturnType<typeof spyOn>
let getMainSessionSpy: ReturnType<typeof spyOn>
beforeEach(() => {
_resetForTesting()
logCalls = []
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
logCalls.push({ msg, data })
})
})
afterEach(() => {
logSpy?.mockRestore()
getMainSessionSpy?.mockRestore()
_resetForTesting()
})
function createMockPluginInput() {
return {
client: {
tui: {
showToast: async () => {},
},
},
} as unknown as PluginInput
}
test("should inject team-mode message when user types 'team mode'", async () => {
// given - main session typing English 'team mode'
const collector = new ContextCollector()
const sessionID = "team-en-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "let's use team mode for this task" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode message should be prepended with team_* tool guidance
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[team-mode]")
expect(textPart!.text).toContain("team_create")
expect(textPart!.text).toContain("team_task_create")
expect(textPart!.text).toContain("team_send_message")
expect(textPart!.text).toContain("NEVER substitute with delegate_task")
expect(textPart!.text).toContain("for this task")
})
test("should inject team-mode message when user types '팀 모드' (Korean with space)", async () => {
// given - main session typing Korean '팀 모드'
const collector = new ContextCollector()
const sessionID = "team-ko-spaced-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "이거 팀 모드로 해줘" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode message should be prepended
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[team-mode]")
expect(textPart!.text).toContain("팀 모드로 해줘")
})
test("should inject team-mode message when user types '팀으로'", async () => {
// given - main session typing Korean '팀으로'
const collector = new ContextCollector()
const sessionID = "team-ko-eulo-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "팀으로 일하자" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode message should be prepended
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[team-mode]")
expect(textPart!.text).toContain("팀으로 일하자")
})
test("should NOT trigger team-mode on '스팀으로' (false-positive guard)", async () => {
// given - text contains '팀으로' as substring of another Korean word ('스팀으로')
const collector = new ContextCollector()
const sessionID = "false-positive-eulo-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "스팀으로 게임 켜줘" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode should NOT be triggered, text unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("스팀으로 게임 켜줘")
expect(textPart!.text).not.toContain("[team-mode]")
})
test("should NOT trigger team-mode on '스팀모드' (Hangul-prefix false-positive guard)", async () => {
// given - text contains '팀모드' as substring of another Korean word ('스팀모드')
const collector = new ContextCollector()
const sessionID = "false-positive-mode-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "스팀모드 활성화" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode should NOT be triggered
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("스팀모드 활성화")
expect(textPart!.text).not.toContain("[team-mode]")
})
test("should NOT trigger team-mode on bare 'team' without 'mode'", async () => {
// given - text contains 'team' but not 'team mode'
const collector = new ContextCollector()
const sessionID = "bare-team-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "join the team and start working" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode should NOT be triggered
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[team-mode]")
})
test("should filter team-mode keyword in non-main session (only ultrawork allowed there)", async () => {
// given - main session set, different (subagent) session triggers team mode
const mainSessionID = "main-team-mode"
const subagentSessionID = "subagent-team-mode"
setMainSession(mainSessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "team mode please" }],
}
// when - subagent session triggers team mode keyword
await hook["chat.message"]({ sessionID: subagentSessionID }, output)
// then - team-mode message should NOT be injected in subagent session
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("team mode please")
expect(textPart!.text).not.toContain("[team-mode]")
})
})
describe("keyword-detector disabled_keywords config", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
let logSpy: ReturnType<typeof spyOn>
let getMainSessionSpy: ReturnType<typeof spyOn>
beforeEach(() => {
_resetForTesting()
logCalls = []
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
logCalls.push({ msg, data })
})
})
afterEach(() => {
logSpy?.mockRestore()
getMainSessionSpy?.mockRestore()
_resetForTesting()
})
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
const toastCalls = options.toastCalls ?? []
return {
client: {
tui: {
showToast: async (opts: { body: { title: string } }) => {
toastCalls.push(opts.body.title)
},
},
},
} as unknown as PluginInput
}
test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => {
// given - keyword detector with search disabled
const sessionID = "search-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
{ disabled_keywords: ["search"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "search for the bug in the code" }],
}
// when - search keyword would normally trigger
await hook["chat.message"]({ sessionID }, output)
// then - search-mode injection should be skipped
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("search for the bug in the code")
expect(textPart!.text).not.toContain("[search-mode]")
})
test("should NOT inject analyze-mode when disabled_keywords includes 'analyze'", async () => {
// given - keyword detector with analyze disabled
const sessionID = "analyze-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
{ disabled_keywords: ["analyze"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "how to do this" }],
}
// when - analyze keyword would normally trigger
await hook["chat.message"]({ sessionID }, output)
// then - analyze-mode injection should be skipped
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("how to do this")
expect(textPart!.text).not.toContain("[analyze-mode]")
})
test("should NOT inject team-mode when disabled_keywords includes 'team'", async () => {
// given - keyword detector with team disabled
const sessionID = "team-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
{ disabled_keywords: ["team"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "let's use team mode for this" }],
}
// when - team keyword would normally trigger
await hook["chat.message"]({ sessionID }, output)
// then - team-mode injection should be skipped
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("let's use team mode for this")
expect(textPart!.text).not.toContain("[team-mode]")
})
test("should NOT inject ultrawork message AND not show toast when disabled_keywords includes 'ultrawork'", async () => {
// given - keyword detector with ultrawork disabled
const sessionID = "ultrawork-disabled-session"
const toastCalls: string[] = []
const hook = createKeywordDetectorHook(
createMockPluginInput({ toastCalls }),
undefined,
undefined,
{ disabled_keywords: ["ultrawork"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "ultrawork do this task" }],
}
// when - ultrawork keyword would normally trigger toast + injection
await hook["chat.message"]({ sessionID }, output)
// then - neither toast nor injection should occur
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("ultrawork do this task")
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
})
test("should disable multiple keywords simultaneously when listed together", async () => {
// given - keyword detector with both search and analyze disabled
const sessionID = "multi-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
{ disabled_keywords: ["search", "analyze"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "search and analyze the codebase" }],
}
// when - both search and analyze would normally fire
await hook["chat.message"]({ sessionID }, output)
// then - neither mode should inject
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("search and analyze the codebase")
expect(textPart!.text).not.toContain("[search-mode]")
expect(textPart!.text).not.toContain("[analyze-mode]")
})
test("should let other keywords through when only one is disabled", async () => {
// given - keyword detector with only search disabled, but message contains both search and analyze triggers
const sessionID = "partial-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
{ disabled_keywords: ["search"] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "search and analyze the codebase" }],
}
// when - both keywords match but only search is disabled
await hook["chat.message"]({ sessionID }, output)
// then - analyze should still inject, search should be skipped
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
expect(textPart!.text).toContain("[analyze-mode]")
expect(textPart!.text).toContain("search and analyze the codebase")
})
test("should behave normally (all keywords enabled) when config is undefined", async () => {
// given - keyword detector with no config (regression test for backward compat)
const sessionID = "no-config-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
undefined,
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "search for the answer" }],
}
// when - search keyword fires with no config
await hook["chat.message"]({ sessionID }, output)
// then - search-mode should inject as usual
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[search-mode]")
})
test("should behave normally when disabled_keywords is an empty array", async () => {
// given - keyword detector with empty disable list
const sessionID = "empty-disabled-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(
createMockPluginInput(),
undefined,
undefined,
{ disabled_keywords: [] },
)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "investigate this issue" }],
}
// when - analyze keyword fires with empty disable list
await hook["chat.message"]({ sessionID }, output)
// then - analyze-mode should still inject
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[analyze-mode]")
})
})
@@ -0,0 +1,17 @@
/**
* Team mode keyword detector.
*
* Triggers when the user explicitly invokes team-mode work:
* - English: team mode, team-mode, team_mode, teammode (case-insensitive)
* - Korean: 팀 모드, 팀모드, 팀으로
*
* The Korean variants use a negative lookbehind on Hangul syllables (가-힣)
* to prevent false positives like "스팀으로" matching "팀으로", or
* "스팀모드" matching "팀모드".
*/
export const TEAM_PATTERN =
/\bteam[\s_-]?mode\b|(?<![가-힣])(?:팀\s*모드|팀으로)/i
export const TEAM_MESSAGE = `[team-mode]
Team mode reference detected. If user wants team-mode work, MUST orchestrate via team_* tools (team_create -> team_task_create + team_send_message). NEVER substitute with delegate_task - it is not equivalent. If team_* tools are unavailable (team_mode disabled in config), instruct user to set team_mode.enabled=true and restart opencode.`
+1
View File
@@ -0,0 +1 @@
export { TEAM_PATTERN, TEAM_MESSAGE } from "./default"
+1 -1
View File
@@ -163,7 +163,7 @@ describe("model fallback hook", () => {
expect(secondOutput.message["model"]).toEqual({
providerID: "opencode-go",
modelID: "kimi-k2.5",
modelID: "kimi-k2.6",
})
expect(secondOutput.message["variant"]).toBeUndefined()
})
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/ralph-loop/ — Self-Referential Dev Loop
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
+49 -1
View File
@@ -304,6 +304,54 @@ describe("ralph-loop", () => {
expect(state?.iteration).toBe(2)
})
test("should settle idle before injecting continuation", async () => {
// given - active loop state with a configured idle settle delay
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// when - session goes idle
const eventPromise = hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
await Promise.resolve()
// then - continuation should not be injected in the same event-loop turn
expect(promptCalls.length).toBe(0)
await eventPromise
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-123")
})
test("#given hanging toast #when session idles #then continuation still injects", async () => {
// given - TUI toast never settles
const ctx = createMockPluginInput()
ctx.client.tui = {
showToast: () => new Promise(() => {}),
} as never
const hook = createRalphLoopHook(ctx, { idleSettleMs: 0 })
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// when - session goes idle
const result = await Promise.race([
hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
}).then(() => "resolved" as const),
new Promise<"timed-out">((resolvePromise) => setTimeout(() => resolvePromise("timed-out"), 50)),
])
// then - continuation is not blocked by toast delivery
expect(result).toBe("resolved")
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-123")
})
test("should skip continuation when background task is running", async () => {
// given - active loop state with a running background task
const hook = createRalphLoopHook(createMockPluginInput(), {
@@ -333,7 +381,7 @@ describe("ralph-loop", () => {
test("should stop loop when max iterations reached", async () => {
// given - loop at max iteration
const hook = createRalphLoopHook(createMockPluginInput())
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
const state = hook.getState()!
@@ -0,0 +1,33 @@
type SessionState = {
isRecovering?: boolean
}
export function createLoopSessionRecovery(options?: { recoveryWindowMs?: number }) {
const recoveryWindowMs = options?.recoveryWindowMs ?? 5000
const sessions = new Map<string, SessionState>()
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = {}
sessions.set(sessionID, state)
}
return state
}
return {
isRecovering(sessionID: string): boolean {
return getSessionState(sessionID).isRecovering === true
},
markRecovering(sessionID: string): void {
const state = getSessionState(sessionID)
state.isRecovering = true
setTimeout(() => {
state.isRecovering = false
}, recoveryWindowMs)
},
clear(sessionID: string): void {
sessions.delete(sessionID)
},
}
}
@@ -25,7 +25,7 @@ describe("ralph-loop non-abort error continuation", () => {
}
})
test("continues on next idle after non-abort session error", async () => {
test("continues immediately after non-abort session error", async () => {
// given - an active Ralph Loop receives a recoverable command error
const hook = createRalphLoopHook({
directory: testDirectory,
@@ -81,16 +81,258 @@ describe("ralph-loop non-abort error continuation", () => {
},
})
// when - OpenCode emits the idle event caused by that failed command
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - the loop should continue instead of skipping idle as recovery
// then - the loop should continue without waiting for a later idle event
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.sessionID).toBe("session-123")
expect(promptCalls[0]?.text).toContain("Keep working")
expect(messagesCalls.length).toBeGreaterThan(0)
expect(hook.getState()?.iteration).toBe(2)
})
test("continues ultrawork loop immediately after non-abort session error", async () => {
// given - an active ULW Loop receives a recoverable runtime error
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
return { data: [] }
},
promptAsync: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0]?.text ?? "",
})
return {}
},
prompt: async () => ({}),
},
tui: {
showToast: async () => ({}),
},
},
} as never)
hook.startLoop("session-123", "Keep ultraworking", {
messageCountAtStart: 0,
maxIterations: 5,
ultrawork: true,
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "RuntimeError" },
},
},
})
// then - the ULW continuation keeps the ultrawork directive
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.sessionID).toBe("session-123")
expect(promptCalls[0]?.text).toMatch(/^ultrawork /)
expect(promptCalls[0]?.text).toContain("Keep ultraworking")
expect(hook.getState()?.iteration).toBe(2)
})
test("continues after retry run activity when no stale idle arrived", async () => {
// given - an active loop retries a recoverable runtime error
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
return { data: [] }
},
promptAsync: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0]?.text ?? "",
})
return {}
},
prompt: async () => ({}),
},
tui: {
showToast: async () => ({}),
},
},
} as never)
hook.startLoop("session-123", "Keep working", {
messageCountAtStart: 0,
maxIterations: 5,
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "RuntimeError" },
},
},
})
// when - the retried run emits real assistant activity before any stale idle
await hook.event({
event: {
type: "message.part.delta",
properties: {
sessionID: "session-123",
messageID: "msg-1",
partID: "part-1",
field: "text",
delta: "working",
},
},
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - the real idle is allowed to continue the loop
expect(promptCalls).toHaveLength(2)
expect(hook.getState()?.iteration).toBe(3)
})
test("skips immediate runtime retry while background tasks are running", async () => {
// given - an active loop owns running background work
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
return { data: [] }
},
promptAsync: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0]?.text ?? "",
})
return {}
},
prompt: async () => ({}),
},
tui: {
showToast: async () => ({}),
},
},
} as never, {
backgroundManager: {
getTasksByParentSession: (sessionID: string) => sessionID === "session-123"
? [{ status: "running" }]
: [],
},
})
hook.startLoop("session-123", "Keep working", {
messageCountAtStart: 0,
maxIterations: 5,
})
// when - the same session reports a recoverable runtime error
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "RuntimeError" },
},
},
})
// then - Ralph waits for background work instead of starting overlapping continuation
expect(promptCalls).toHaveLength(0)
expect(hook.getState()?.iteration).toBe(1)
})
test("stops retrying runtime errors after max iterations", async () => {
// given - an active Ralph Loop has one retry remaining
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
return { data: [] }
},
promptAsync: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0]?.text ?? "",
})
return {}
},
prompt: async () => ({}),
},
tui: {
showToast: async () => ({}),
},
},
} as never)
hook.startLoop("session-123", "Keep working", {
messageCountAtStart: 0,
maxIterations: 2,
})
// when - the first runtime error consumes the final allowed attempt
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "RuntimeError" },
},
},
})
// when - another runtime error arrives after the retry budget is exhausted
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "RuntimeError" },
},
},
})
// then - the loop does not exceed the configured retry count
expect(promptCalls).toHaveLength(1)
expect(hook.getState()).toBeNull()
})
})
+206 -24
View File
@@ -20,16 +20,107 @@ type LoopStateController = {
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
}
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
function sleep(ms: number): Promise<void> {
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
}
function hasRunningBackgroundTasks(
backgroundManager: RalphLoopOptions["backgroundManager"],
sessionID: string,
): boolean {
return backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
}
function getInfoSessionID(props: Record<string, unknown> | undefined): string | undefined {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID
return typeof sessionID === "string" ? sessionID : undefined
}
function getRuntimeRetryActivitySessionID(
eventType: string,
props: Record<string, unknown> | undefined,
): string | undefined {
if (eventType === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const role = info?.role
return role === "assistant" ? getInfoSessionID(props) : undefined
}
if (eventType === "message.part.updated") {
if (typeof props?.sessionID === "string") return props.sessionID
return getInfoSessionID(props)
}
if (eventType === "message.part.delta") {
return typeof props?.sessionID === "string" ? props.sessionID : undefined
}
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
return typeof props?.sessionID === "string" ? props.sessionID : undefined
}
return undefined
}
function isAbortError(error: unknown): boolean {
return typeof error === "object"
&& error !== null
&& "name" in error
&& (error as { name?: unknown }).name === "MessageAbortedError"
}
function showToastBestEffort(
ctx: PluginInput,
body: { title: string; message: string; variant: "warning" | "info"; duration: number },
): void {
try {
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
} catch {
}
}
function showMaxIterationsToast(
ctx: PluginInput,
state: RalphLoopState,
): void {
showToastBestEffort(ctx, {
title: "Ralph Loop Stopped",
message: `Max iterations (${state.max_iterations}) reached without completion`,
variant: "warning",
duration: 5000,
})
}
function showIterationToast(
ctx: PluginInput,
state: RalphLoopState,
): void {
showToastBestEffort(ctx, {
title: "Ralph Loop",
message: `Iteration ${state.iteration}/${typeof state.max_iterations === "number" ? state.max_iterations : "unbounded"}`,
variant: "info",
duration: 2000,
})
}
export function createRalphLoopEventHandler(
ctx: PluginInput,
options: RalphLoopEventHandlerOptions,
) {
const inFlightSessions = new Set<string>()
const runtimeErrorRetriedSessions = new Map<string, number>()
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
if (runtimeRetryActivitySessionID) {
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
@@ -44,18 +135,14 @@ export function createRalphLoopEventHandler(
try {
const state = options.loopState.getState()
if (!state || !state.active) {
return
}
if (!state || !state.active) {
return
}
const hasRunningBackgroundTasks = options.backgroundManager
? options.backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
if (hasRunningBackgroundTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
const verificationSessionID = state.verification_pending
? state.verification_session_id
@@ -121,6 +208,7 @@ export function createRalphLoopEventHandler(
})
if (completionViaTranscript || completionViaApi) {
runtimeErrorRetriedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
@@ -160,6 +248,15 @@ export function createRalphLoopEventHandler(
return
}
if (runtimeErrorRetriedSessions.get(sessionID) === state.iteration) {
runtimeErrorRetriedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Skipped stale idle after runtime error retry`, {
sessionID,
iteration: state.iteration,
})
return
}
if (
typeof state.max_iterations === "number"
&& state.iteration >= state.max_iterations
@@ -171,9 +268,7 @@ export function createRalphLoopEventHandler(
})
options.loopState.clear()
await ctx.client.tui?.showToast?.({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
}).catch(() => {})
showMaxIterationsToast(ctx, state)
return
}
@@ -189,14 +284,8 @@ export function createRalphLoopEventHandler(
max: newState.max_iterations,
})
await ctx.client.tui?.showToast?.({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${typeof newState.max_iterations === "number" ? newState.max_iterations : "unbounded"}`,
variant: "info",
duration: 2000,
},
}).catch(() => {})
showIterationToast(ctx, newState)
await sleep(options.idleSettleMs)
try {
await continueIteration(ctx, newState, {
@@ -223,7 +312,100 @@ export function createRalphLoopEventHandler(
}
if (event.type === "session.error") {
handleErroredLoopSession(props, options.loopState)
const sessionID = props?.sessionID as string | undefined
const error = props?.error
if (!sessionID || isAbortError(error)) {
handleErroredLoopSession(props, options.loopState)
return
}
if (inFlightSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped runtime error retry: handler in flight`, { sessionID })
return
}
inFlightSessions.add(sessionID)
try {
const state = options.loopState.getState()
if (!state || !state.active) {
handleErroredLoopSession(props, options.loopState)
return
}
const verificationSessionID = state.verification_pending
? state.verification_session_id
: undefined
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
const matchesVerificationSession = verificationSessionID === sessionID
if (!matchesParentSession && !matchesVerificationSession) {
handleErroredLoopSession(props, options.loopState)
return
}
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
return
}
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
sessionID,
iteration: state.iteration,
error: String(error),
})
if (state.verification_pending) {
await handlePendingVerification(ctx, {
sessionID,
state,
verificationSessionID,
matchesParentSession,
matchesVerificationSession,
loopState: options.loopState,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
return
}
if (
typeof state.max_iterations === "number"
&& state.iteration >= state.max_iterations
) {
log(`[${HOOK_NAME}] Runtime error retry budget exhausted`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
options.loopState.clear()
showMaxIterationsToast(ctx, state)
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID })
return
}
showIterationToast(ctx, newState)
await sleep(options.idleSettleMs)
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
runtimeErrorRetriedSessions.set(sessionID, newState.iteration)
} catch (err) {
log(`[${HOOK_NAME}] Failed to retry after runtime error`, {
sessionID,
error: String(err),
})
}
} finally {
inFlightSessions.delete(sessionID)
}
}
}
}
+3
View File
@@ -22,6 +22,7 @@ export interface RalphLoopHook {
}
const DEFAULT_API_TIMEOUT = 5000 as const
const DEFAULT_IDLE_SETTLE_MS = 150 as const
function getMessageCountFromResponse(messagesResponse: unknown): number {
if (Array.isArray(messagesResponse)) {
@@ -44,6 +45,7 @@ export function createRalphLoopHook(
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const idleSettleMs = options?.idleSettleMs ?? DEFAULT_IDLE_SETTLE_MS
const checkSessionExists = options?.checkSessionExists
const backgroundManager = options?.backgroundManager
@@ -56,6 +58,7 @@ export function createRalphLoopHook(
const event = createRalphLoopEventHandler(ctx, {
directory: ctx.directory,
apiTimeoutMs: apiTimeout,
idleSettleMs,
getTranscriptPath,
checkSessionExists,
backgroundManager,
@@ -43,49 +43,52 @@ describe("ralph-loop reset strategy race condition", () => {
let selectSessionCalls = 0
const selectSessionDeferred = createDeferred()
const hook = createRalphLoopHook({
directory: process.cwd(),
client: {
session: {
prompt: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0].text,
})
return {}
const hook = createRalphLoopHook(
{
directory: process.cwd(),
client: {
session: {
prompt: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0].text,
})
return {}
},
promptAsync: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0].text,
})
return {}
},
create: async (options: {
body: { parentID?: string; title?: string }
query?: { directory?: string }
}) => {
createSessionCalls.push({ parentID: options.body.parentID })
return { data: { id: `new-session-${createSessionCalls.length}` } }
},
messages: async () => ({ data: [] }),
},
promptAsync: async (options: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
}) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0].text,
})
return {}
},
create: async (options: {
body: { parentID?: string; title?: string }
query?: { directory?: string }
}) => {
createSessionCalls.push({ parentID: options.body.parentID })
return { data: { id: `new-session-${createSessionCalls.length}` } }
},
messages: async () => ({ data: [] }),
},
tui: {
showToast: async () => ({}),
selectSession: async () => {
selectSessionCalls += 1
await selectSessionDeferred.promise
return {}
tui: {
showToast: async () => ({}),
selectSession: async () => {
selectSessionCalls += 1
await selectSessionDeferred.promise
return {}
},
},
},
},
} as unknown as Parameters<typeof createRalphLoopHook>[0])
} as unknown as Parameters<typeof createRalphLoopHook>[0],
{ idleSettleMs: 0 },
)
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
+1
View File
@@ -21,6 +21,7 @@ export interface RalphLoopOptions {
config?: RalphLoopConfig
getTranscriptPath?: (sessionId: string) => string
apiTimeout?: number
idleSettleMs?: number
checkSessionExists?: (sessionId: string) => Promise<boolean>
backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> }
}
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/rules-injector/ — Conditional Rules Injection
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/runtime-fallback/ — Reactive Provider Error Recovery
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -132,7 +132,8 @@ export function classifyErrorType(error: unknown): string | undefined {
/exhausted\s+your\s+capacity/i.test(message) ||
/out\s+of\s+credits?/i.test(message) ||
/payment.?required/i.test(message) ||
/usage\s+limit/i.test(message)
/usage\s+limit/i.test(message) ||
/credit\s+balance.*too\s+low/i.test(message)
) {
return "quota_exceeded"
}
@@ -28,6 +28,10 @@ export function findNextAvailableFallback(
): string | undefined {
for (let i = state.fallbackIndex + 1; i < fallbackModels.length; i++) {
const candidate = fallbackModels[i]
if (candidate === state.currentModel) {
log(`[${HOOK_NAME}] Skipping fallback model (same as current)`, { model: candidate, index: i })
continue
}
if (!isModelInCooldown(candidate, state, cooldownSeconds)) {
return candidate
}
@@ -139,6 +139,16 @@ describe("session-notification input-needed events", () => {
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
// when
await hook({
event: {
type: "session.deleted",
properties: {
info: { id: sessionID },
},
},
})
})
})
+22 -7
View File
@@ -33,6 +33,21 @@ export function getDefaultSoundPath(platform: Platform): string {
}
}
type ShellCommand = Promise<unknown> & {
quiet?: () => Promise<unknown>
nothrow?: () => ShellCommand
}
async function runQuietNothrow(command: ShellCommand): Promise<void> {
const safeCommand = typeof command.nothrow === "function" ? command.nothrow() : command
if (typeof safeCommand.quiet === "function") {
await safeCommand.quiet()
return
}
await safeCommand
}
export async function sendSessionNotification(
ctx: PluginInput,
platform: Platform,
@@ -72,14 +87,14 @@ export async function sendSessionNotification(
const escapedTitle = escapeAppleScriptText(title)
const escapedMessage = escapeAppleScriptText(message)
await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.nothrow().quiet()
await runQuietNothrow(ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`)
break
}
case "linux": {
const notifySendPath = await getNotifySendPath()
if (!notifySendPath) return
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.nothrow().quiet()
await runQuietNothrow(ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`)
break
}
case "win32": {
@@ -87,7 +102,7 @@ export async function sendSessionNotification(
if (!powershellPath) return
const toastScript = buildWindowsToastScript(title, message)
await ctx.$`${powershellPath} -Command ${toastScript}`.nothrow().quiet()
await runQuietNothrow(ctx.$`${powershellPath} -Command ${toastScript}`)
break
}
}
@@ -102,17 +117,17 @@ export async function playSessionNotificationSound(
case "darwin": {
const afplayPath = await getAfplayPath()
if (!afplayPath) return
ctx.$`${afplayPath} ${soundPath}`.nothrow().quiet()
await runQuietNothrow(ctx.$`${afplayPath} ${soundPath}`)
break
}
case "linux": {
const paplayPath = await getPaplayPath()
if (paplayPath) {
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet()
await runQuietNothrow(ctx.$`${paplayPath} ${soundPath} 2>/dev/null`)
} else {
const aplayPath = await getAplayPath()
if (aplayPath) {
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet()
await runQuietNothrow(ctx.$`${aplayPath} ${soundPath} 2>/dev/null`)
}
}
break
@@ -121,7 +136,7 @@ export async function playSessionNotificationSound(
const powershellPath = await getPowershellPath()
if (!powershellPath) return
const escaped = escapePowerShellSingleQuotedText(soundPath)
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`.nothrow().quiet()
await runQuietNothrow(ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`)
break
}
}
+88 -74
View File
@@ -8,29 +8,85 @@ const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const originalDateNow = Date.now
type MockPluginInput = Parameters<typeof createSessionNotification>[0]
type MockShellResult = {
stdout: Buffer
stderr: Buffer
exitCode: number
}
type MockShellChain = Promise<MockShellResult> & {
nothrow: () => MockShellChain
quiet: () => MockShellChain
text: () => Promise<string>
}
function formatShellCommand(cmd: TemplateStringsArray | string, values: readonly unknown[]): string {
if (typeof cmd === "string") return cmd
return cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
}
function createShellChain(result: MockShellResult, shouldReject = false): MockShellChain {
const promise = (shouldReject ? Promise.reject(Object.assign(new Error("command failed"), result)) : Promise.resolve(result)) as MockShellChain
const resolvedNothrow = Promise.resolve(result) as MockShellChain
promise.quiet = () => promise
promise.text = async () => ""
promise.nothrow = () => resolvedNothrow
resolvedNothrow.quiet = () => resolvedNothrow
resolvedNothrow.text = async () => ""
resolvedNothrow.nothrow = () => resolvedNothrow
return promise
}
function createShellMock(options: {
capture?: (commandString: string) => void
reject?: (commandString: string, values: readonly unknown[]) => boolean
} = {}) {
return (cmd: TemplateStringsArray | string, ...values: unknown[]): MockShellChain => {
const commandString = formatShellCommand(cmd, values)
options.capture?.(commandString)
return createShellChain(
{ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: options.reject?.(commandString, values) ? 1 : 0 },
options.reject?.(commandString, values) ?? false
)
}
}
function createMockInput(shell: ReturnType<typeof createShellMock>): MockPluginInput {
const input = {} as MockPluginInput
return Object.assign(input, {
$: shell,
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
project: "/tmp/test",
worktree: "/tmp/test",
serverUrl: "http://localhost",
})
}
describe("session-notification", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
function createMockPluginInput(): MockPluginInput {
return createMockInput(
createShellMock({
capture: (cmdStr) => {
// given - track notification commands (osascript, notify-send, powershell)
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
}
return { stdout: "", stderr: "", exitCode: 0 }
},
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
} as any
})
)
}
beforeEach(() => {
@@ -44,6 +100,7 @@ describe("session-notification", () => {
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "getCmuxPath").mockResolvedValue(null)
spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay")
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
@@ -389,19 +446,7 @@ describe("session-notification", () => {
function createSenderMockCtx() {
const notifyCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray | string, ...values: any[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
notifyCalls.push(cmdStr)
const result = { stdout: "", stderr: "", exitCode: 0 }
const promise = Promise.resolve(result) as any
promise.quiet = () => promise
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return promise
},
} as any
const mockCtx = createMockInput(createShellMock({ capture: (commandString) => notifyCalls.push(commandString) }))
return { mockCtx, notifyCalls }
}
@@ -454,28 +499,12 @@ describe("session-notification", () => {
// given - terminal-notifier exists but invocation fails
spyOn(sender, "sendSessionNotification").mockRestore()
const notifyCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
notifyCalls.push(cmdStr)
if (cmdStr.includes("terminal-notifier")) {
const err = Object.assign(new Error("terminal-notifier failed"), { stdout: "", stderr: "", exitCode: 1 })
const rejected = Promise.reject(err) as any
rejected.quiet = () => rejected
rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return rejected
}
const result = { stdout: "", stderr: "", exitCode: 0 }
const promise = Promise.resolve(result) as any
promise.quiet = () => promise
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return promise
},
} as any
const mockCtx = createMockInput(
createShellMock({
capture: (commandString) => notifyCalls.push(commandString),
reject: (commandString) => commandString.includes("terminal-notifier"),
})
)
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
@@ -493,27 +522,12 @@ describe("session-notification", () => {
// given - shell interpolation rejects array values
spyOn(sender, "sendSessionNotification").mockRestore()
const notifyCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
if (values.some(Array.isArray)) {
const err = Object.assign(new Error("array interpolation unsupported"), { stdout: "", stderr: "", exitCode: 1 })
const rejected = Promise.reject(err) as any
rejected.quiet = () => rejected
rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return rejected
}
const commandString = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
notifyCalls.push(commandString)
const result = { stdout: "", stderr: "", exitCode: 0 }
const promise = Promise.resolve(result) as any
promise.quiet = () => promise
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return promise
},
} as any
const mockCtx = createMockInput(
createShellMock({
capture: (commandString) => notifyCalls.push(commandString),
reject: (_commandString, values) => values.some(Array.isArray),
})
)
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/session-recovery/ — Auto Session Error Recovery
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
+3 -1
View File
@@ -123,7 +123,9 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
let success = false
if (errorType === "tool_result_missing") {
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg, resumeConfig)
} else if (errorType === "unavailable_tool") {
success = await recoverUnavailableTool(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") {
@@ -129,6 +129,63 @@ describe("recoverToolResultMissing", () => {
},
})
})
it("pins agent, model, and variant on promptAsync body when resumeConfig provides them", async () => {
// given
storedParts = [{
type: "tool",
id: "prt_stored_pin_call",
callID: "toolu_pin",
tool: "bash",
state: { input: {} },
}]
const { client, promptAsync } = createMockClient()
const resumeConfig = {
sessionID: "ses_pin",
agent: "Hephaestus",
model: { providerID: "openai", modelID: "gpt-5.3-codex", variant: "max" },
}
// when
const result = await recoverToolResultMissing(client, "ses_pin", failedAssistantMsg, resumeConfig)
// then
expect(result).toBe(true)
expect(promptAsync).toHaveBeenCalledTimes(1)
const call = promptAsync.mock.calls[0]?.[0] as {
body: {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
parts: unknown[]
}
}
expect(call.body.agent).toBe("Hephaestus")
expect(call.body.model).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
expect(call.body.variant).toBe("max")
})
it("leaves body unchanged when no resumeConfig is provided", async () => {
// given
storedParts = [{
type: "tool",
id: "prt_stored_nopin_call",
callID: "toolu_nopin",
tool: "bash",
state: { input: {} },
}]
const { client, promptAsync } = createMockClient()
// when
const result = await recoverToolResultMissing(client, "ses_nopin", failedAssistantMsg)
// then
expect(result).toBe(true)
const call = promptAsync.mock.calls[0]?.[0] as { body: Record<string, unknown> }
expect(call.body).not.toHaveProperty("agent")
expect(call.body).not.toHaveProperty("model")
expect(call.body).not.toHaveProperty("variant")
})
})
export {}
@@ -1,5 +1,5 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import type { MessageData, ResumeConfig } from "./types"
import { readParts } from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
@@ -70,7 +70,8 @@ async function readPartsFromSDKFallback(
export async function recoverToolResultMissing(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
failedAssistantMsg: MessageData,
resumeConfig?: ResumeConfig
): Promise<boolean> {
let parts = failedAssistantMsg.parts || []
if (parts.length === 0 && failedAssistantMsg.info?.id) {
@@ -93,9 +94,20 @@ export async function recoverToolResultMissing(
content: "Operation cancelled by user (ESC pressed)",
}))
const launchAgent = resumeConfig?.agent
const launchModel = resumeConfig?.model
? { providerID: resumeConfig.model.providerID, modelID: resumeConfig.model.modelID }
: undefined
const launchVariant = resumeConfig?.model?.variant
const promptInput = {
path: { id: sessionID },
body: { parts: toolResultParts },
body: {
parts: toolResultParts,
...(launchAgent ? { agent: launchAgent } : {}),
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
},
}
try {
+5
View File
@@ -0,0 +1,5 @@
export const DEFAULT_SESSION_IDLE_SETTLE_MS = 150
export function settleAfterSessionIdle(ms = DEFAULT_SESSION_IDLE_SETTLE_MS): Promise<void> {
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
}
+11 -4
View File
@@ -7,6 +7,7 @@ import {
getPlanName,
getPlanProgress,
readBoulderState,
resolveBoulderPlanPath,
writeBoulderState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
@@ -150,7 +151,8 @@ function buildExistingSessionContext(params: {
directory: string
}): string {
const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params
const progress = getPlanProgress(existingState.active_plan)
const planPath = resolveBoulderPlanPath(directory, existingState)
const progress = getPlanProgress(planPath)
if (progress.isComplete) {
return `
## Previous Work Complete
@@ -186,7 +188,7 @@ Looking for new plans...`
**Status**: RESUMING existing work
**Plan**: ${existingState.plan_name}
**Path**: ${existingState.active_plan}
**Path**: ${planPath}
**Progress**: ${progress.completed}/${progress.total} tasks completed
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
**Started**: ${existingState.started_at}
@@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.`
}
function shouldDiscoverPlans(
directory: string,
existingState: ReturnType<typeof readBoulderState>,
explicitPlanName: string | null,
): boolean {
return (!existingState && !explicitPlanName)
|| (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|| (
existingState !== null
&& !explicitPlanName
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
)
}
function buildPlanDiscoveryContext(params: {
@@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: {
})
}
if (shouldDiscoverPlans(existingState, explicitPlanName)) {
if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) {
return buildPlanDiscoveryContext({
contextInfo,
sessionId,
+35 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { createStartWorkHook } from "./index"
@@ -1013,5 +1013,39 @@ You are starting a Sisyphus work session.
expect(output.parts[0].text).toContain("subagent")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
test("should show worktree plan progress and path when the mirrored plan exists", async () => {
// given
const mainPlanPath = join(testDir, ".sisyphus", "plans", "resume-worktree-plan.md")
const worktreeDir = join(testDir, "..", `resume-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "resume-worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task 1\n- [ ] Worktree task 2\n")
writeBoulderState(testDir, {
active_plan: mainPlanPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "resume-worktree-plan",
worktree_path: worktreeDir,
})
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: createStartWorkPrompt() }],
}
try {
// when
await hook["chat.message"]({ sessionID: "session-worktree-progress" }, output)
// then
expect(output.parts[0].text).toContain(worktreePlanPath)
expect(output.parts[0].text).toContain("1/2 tasks completed")
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
})
})
})
@@ -46,8 +46,8 @@ describe("stop-continuation-guard", () => {
id,
status,
description: `${id} description`,
parentSessionID: "parent-session",
parentMessageID: "parent-message",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
prompt: "prompt",
agent: "sisyphus-junior",
}
@@ -0,0 +1,277 @@
import { afterEach, describe, expect, it } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import {
clearTeamSessionRegistry,
registerTeamSession,
} from "../../features/team-mode/team-session-registry"
import { sendMessage } from "../../features/team-mode/team-mailbox/send"
import type { RuntimeState } from "../../features/team-mode/types"
import { saveRuntimeState } from "../../features/team-mode/team-state-store/store"
import { createTeamMailboxInjector } from "./hook"
function createRuntimeState(sessionID: string, teamRunId = randomUUID()): RuntimeState {
return {
version: 1,
teamRunId,
teamName: "team-alpha",
specSource: "project",
createdAt: 1,
status: "active",
leadSessionId: "lead-session",
members: [
{
name: "member-a",
sessionId: sessionID,
agentType: "general-purpose",
status: "running",
lastInjectedTurnMarker: undefined,
pendingInjectedMessageIds: [],
},
],
shutdownRequests: [],
bounds: {
maxMembers: 8,
maxParallelMembers: 4,
maxMessagesPerRun: 10000,
maxWallClockMinutes: 120,
maxMemberTurns: 500,
},
}
}
async function createTemporaryBaseDir(): Promise<string> {
return await mkdtemp(path.join(tmpdir(), "team-mailbox-injector-"))
}
async function seedRuntimeState(baseDir: string, runtimeState: RuntimeState): Promise<void> {
const config = TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
await mkdir(path.join(baseDir, "runtime", runtimeState.teamRunId), { recursive: true })
await saveRuntimeState(runtimeState, config)
}
function createHook(baseDir: string) {
return createTeamMailboxInjector(
{},
TeamModeConfigSchema.parse({ enabled: true, base_dir: baseDir }),
)
}
function createOutput(sessionID: string): {
messages: Array<{
info: { role: string; sessionID: string }
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
}>
} {
return {
messages: [
{
info: {
role: "user",
sessionID,
},
parts: [{ type: "text", text: "original message" }],
},
],
}
}
describe("createTeamMailboxInjector", () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
clearTeamSessionRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
})
it("returns the input unchanged for a non-member session", async () => {
// given
const baseDir = await createTemporaryBaseDir()
temporaryDirectories.push(baseDir)
const hook = createHook(baseDir)
const output = createOutput("session-non-member")
const originalMessages = structuredClone(output.messages)
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-non-member" },
output,
)
// then
expect(output.messages).toEqual(originalMessages)
})
it("prepends an envelope as a user-role message for a member session", async () => {
// given
const baseDir = await createTemporaryBaseDir()
temporaryDirectories.push(baseDir)
const hook = createHook(baseDir)
const runtimeState = createRuntimeState("session-member")
await seedRuntimeState(baseDir, runtimeState)
await sendMessage({
version: 1,
messageId: randomUUID(),
from: "lead",
to: "member-a",
kind: "message",
body: "hello",
timestamp: 1,
}, runtimeState.teamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
const output = createOutput("session-member")
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-member" },
output,
)
// then
expect(output.messages).toHaveLength(2)
expect(output.messages[0]).toEqual({
info: {
role: "user",
sessionID: "session-member",
},
parts: [
{
type: "text",
text: expect.stringContaining('<peer_message from="lead"'),
synthetic: true,
},
],
})
})
it("does not inject twice for the same turn marker", async () => {
// given
const baseDir = await createTemporaryBaseDir()
temporaryDirectories.push(baseDir)
const hook = createHook(baseDir)
const runtimeState = createRuntimeState("session-member")
await seedRuntimeState(baseDir, runtimeState)
await sendMessage({
version: 1,
messageId: randomUUID(),
from: "lead",
to: "member-a",
kind: "message",
body: "hello",
timestamp: 1,
}, runtimeState.teamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
const firstOutput = createOutput("session-member")
const secondOutput = createOutput("session-member")
const originalSecondMessages = structuredClone(secondOutput.messages)
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-member" },
firstOutput,
)
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-member" },
secondOutput,
)
// then
expect(firstOutput.messages).toHaveLength(2)
expect(secondOutput.messages).toEqual(originalSecondMessages)
})
it("injects mailbox messages during the spawn race when the registry has the fresh member session but disk state is stale", async () => {
// given
const baseDir = await createTemporaryBaseDir()
temporaryDirectories.push(baseDir)
const hook = createHook(baseDir)
const teamRunId = randomUUID()
const staleRuntimeState: RuntimeState = {
...createRuntimeState("stale-session", teamRunId),
members: [
{
name: "member-a",
agentType: "general-purpose",
status: "running",
lastInjectedTurnMarker: undefined,
pendingInjectedMessageIds: [],
},
],
}
await seedRuntimeState(baseDir, staleRuntimeState)
await sendMessage({
version: 1,
messageId: randomUUID(),
from: "lead",
to: "member-a",
kind: "message",
body: "fresh registry hello",
timestamp: 1,
}, teamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
registerTeamSession("session-member", {
teamRunId,
memberName: "member-a",
role: "member",
})
const output = createOutput("session-member")
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-member" },
output,
)
// then
expect(output.messages).toHaveLength(2)
expect(output.messages[0]?.parts[0]?.text).toContain("fresh registry hello")
})
it("falls back to disk lookup when the registry points the session at the wrong teamRunId", async () => {
// given
const baseDir = await createTemporaryBaseDir()
temporaryDirectories.push(baseDir)
const hook = createHook(baseDir)
const correctTeamRunId = randomUUID()
const wrongTeamRunId = randomUUID()
await seedRuntimeState(baseDir, createRuntimeState("session-member", correctTeamRunId))
await seedRuntimeState(baseDir, createRuntimeState("other-session", wrongTeamRunId))
await sendMessage({
version: 1,
messageId: randomUUID(),
from: "lead",
to: "member-a",
kind: "message",
body: "message for the correct team",
timestamp: 1,
}, correctTeamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
await sendMessage({
version: 1,
messageId: randomUUID(),
from: "lead",
to: "member-a",
kind: "message",
body: "message for the wrong team",
timestamp: 2,
}, wrongTeamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
registerTeamSession("session-member", {
teamRunId: wrongTeamRunId,
memberName: "member-a",
role: "member",
})
const output = createOutput("session-member")
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-member" },
output,
)
// then
expect(output.messages).toHaveLength(2)
const injectedText = output.messages[0]?.parts[0]?.text ?? ""
expect(injectedText).toContain("message for the correct team")
expect(injectedText).not.toContain("message for the wrong team")
})
})
+144
View File
@@ -0,0 +1,144 @@
import type { TeamModeConfig } from "../../config/schema/team-mode"
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
import type { PluginContext } from "../../plugin/types"
import type { ExecutorContext } from "../../tools/delegate-task/executor-types"
import { pollAndBuildInjection } from "../../features/team-mode/team-mailbox/poll"
import { log } from "../../shared/logger"
type HookContext = ExecutorContext | PluginContext | Record<string, never>
type TransformPart = {
type: string
text?: string
synthetic?: boolean
[key: string]: unknown
}
type TransformMessageInfo = {
role: string
sessionID?: string
[key: string]: unknown
}
type MessageWithParts = {
info: TransformMessageInfo
parts: TransformPart[]
}
type TeamMailboxInjectorInput = {
sessionID?: string
[key: string]: unknown
}
type TeamMailboxInjectorOutput = {
messages: MessageWithParts[]
}
export type TeamMailboxInjectorHook = {
"experimental.chat.messages.transform"?: (
input: TeamMailboxInjectorInput,
output: TeamMailboxInjectorOutput,
) => Promise<void>
}
function resolveSessionID(
input: TeamMailboxInjectorInput,
messages: MessageWithParts[],
): string | undefined {
if (typeof input.sessionID === "string" && input.sessionID.length > 0) {
return input.sessionID
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const sessionID = messages[index]?.info.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) {
return sessionID
}
}
return undefined
}
function buildTurnMarker(sessionID: string, messages: MessageWithParts[]): string {
return `${sessionID}#${messages.length}`
}
function findLastUserMessageIndex(messages: MessageWithParts[]): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.info.role === "user") {
return index
}
}
return -1
}
function createInjectedMessage(
sessionID: string,
content: string,
): MessageWithParts {
return {
info: {
role: "user",
sessionID,
},
parts: [{ type: "text", text: content, synthetic: true }],
}
}
export function createTeamMailboxInjector(
_ctx: HookContext,
config: TeamModeConfig,
): TeamMailboxInjectorHook {
return {
"experimental.chat.messages.transform": async (
input,
output,
): Promise<void> => {
if (!config.enabled || output.messages.length === 0) {
return
}
const sessionID = resolveSessionID(input, output.messages)
if (sessionID === undefined) {
return
}
try {
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team mailbox injector")
if (runtimeMember === null) {
return
}
const turnMarker = buildTurnMarker(sessionID, output.messages)
const result = await pollAndBuildInjection(
sessionID,
runtimeMember.memberName,
runtimeMember.teamRunId,
config,
turnMarker,
)
if (!result.injected || result.content === undefined) {
return
}
const lastUserMessageIndex = findLastUserMessageIndex(output.messages)
const injectedMessage = createInjectedMessage(sessionID, result.content)
if (lastUserMessageIndex === -1) {
output.messages.unshift(injectedMessage)
return
}
output.messages.splice(lastUserMessageIndex, 0, injectedMessage)
} catch (error) {
log("[team-mailbox-injector] Failed to inject team mailbox messages", {
error: error instanceof Error ? error.message : String(error),
sessionID,
})
}
},
}
}
+2
View File
@@ -0,0 +1,2 @@
export { createTeamMailboxInjector } from "./hook"
export type { TeamMailboxInjectorHook } from "./hook"
@@ -0,0 +1,97 @@
import { describe, expect, it } from "bun:test"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import { createTeamModeStatusInjector } from "./hook"
function createOutput(sessionID: string): {
messages: Array<{
info: { role: string; sessionID: string }
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
}>
} {
return {
messages: [
{
info: {
role: "user",
sessionID,
},
parts: [{ type: "text", text: "original message" }],
},
],
}
}
describe("createTeamModeStatusInjector", () => {
it("injects a one-time team mode enabled message before the latest user message", async () => {
// given
const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: true }))
const output = createOutput("session-team-mode")
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-team-mode" },
output,
)
// then
expect(output.messages).toHaveLength(2)
expect(output.messages[0]).toEqual({
info: {
role: "user",
sessionID: "session-team-mode",
},
parts: [
{
type: "text",
text: expect.stringContaining("Team mode is ENABLED for this session."),
synthetic: true,
},
],
})
expect(output.messages[1]?.parts[0]?.text).toBe("original message")
})
it("does not inject again when the team mode status was already added", async () => {
// given
const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: true }))
const firstOutput = createOutput("session-team-mode")
const secondOutput = createOutput("session-team-mode")
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-team-mode" },
firstOutput,
)
secondOutput.messages = structuredClone(firstOutput.messages)
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-team-mode" },
secondOutput,
)
// then
expect(firstOutput.messages).toHaveLength(2)
expect(secondOutput.messages).toHaveLength(2)
expect(
secondOutput.messages.filter((message) =>
message.parts.some((part) => part.text?.includes("<team_mode_status enabled=\"true\">")),
),
).toHaveLength(1)
})
it("does nothing when team mode is disabled", async () => {
// given
const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: false }))
const output = createOutput("session-team-mode")
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-team-mode" },
output,
)
// then
expect(output.messages).toHaveLength(1)
expect(output.messages[0]?.parts[0]?.text).toBe("original message")
})
})
+126
View File
@@ -0,0 +1,126 @@
import type { TeamModeConfig } from "../../config/schema/team-mode"
type TransformPart = {
type: string
text?: string
synthetic?: boolean
[key: string]: unknown
}
type TransformMessageInfo = {
role: string
sessionID?: string
[key: string]: unknown
}
type MessageWithParts = {
info: TransformMessageInfo
parts: TransformPart[]
}
type TeamModeStatusInjectorInput = {
sessionID?: string
[key: string]: unknown
}
type TeamModeStatusInjectorOutput = {
messages: MessageWithParts[]
}
export type TeamModeStatusInjectorHook = {
"experimental.chat.messages.transform"?: (
input: TeamModeStatusInjectorInput,
output: TeamModeStatusInjectorOutput,
) => Promise<void>
}
const TEAM_MODE_STATUS_MARKER = "<team_mode_status enabled=\"true\">"
function resolveSessionID(
input: TeamModeStatusInjectorInput,
messages: MessageWithParts[],
): string | undefined {
if (typeof input.sessionID === "string" && input.sessionID.length > 0) {
return input.sessionID
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const sessionID = messages[index]?.info.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) {
return sessionID
}
}
return undefined
}
function findLastUserMessageIndex(messages: MessageWithParts[]): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.info.role === "user") {
return index
}
}
return -1
}
function hasInjectedTeamModeStatus(messages: MessageWithParts[]): boolean {
return messages.some((message) =>
message.parts.some(
(part) => part.synthetic === true && part.type === "text" && part.text?.includes(TEAM_MODE_STATUS_MARKER),
),
)
}
function buildTeamModeStatusContent(): string {
return `${TEAM_MODE_STATUS_MARKER}
Team mode is ENABLED for this session.
If the team_* tools are present, that is authoritative proof that team mode is active.
Do not inspect ~/.config/opencode or project config files to verify team mode.
If you need usage guidance, load the team-mode skill. Otherwise use the team_* tools directly.
</team_mode_status>`
}
function createInjectedMessage(sessionID: string): MessageWithParts {
return {
info: {
role: "user",
sessionID,
},
parts: [{ type: "text", text: buildTeamModeStatusContent(), synthetic: true }],
}
}
export function createTeamModeStatusInjector(
config: TeamModeConfig,
): TeamModeStatusInjectorHook {
return {
"experimental.chat.messages.transform": async (
input,
output,
): Promise<void> => {
if (!config.enabled || output.messages.length === 0) {
return
}
if (hasInjectedTeamModeStatus(output.messages)) {
return
}
const sessionID = resolveSessionID(input, output.messages)
if (sessionID === undefined) {
return
}
const lastUserMessageIndex = findLastUserMessageIndex(output.messages)
const injectedMessage = createInjectedMessage(sessionID)
if (lastUserMessageIndex === -1) {
output.messages.unshift(injectedMessage)
return
}
output.messages.splice(lastUserMessageIndex, 0, injectedMessage)
},
}
}
@@ -0,0 +1 @@
export { createTeamModeStatusInjector } from "./hook"
@@ -0,0 +1,494 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import type { TeamModeConfig } from "../../config/schema/team-mode"
import * as ackModule from "../../features/team-mode/team-mailbox/ack"
import { sendMessage } from "../../features/team-mode/team-mailbox/send"
import {
clearTeamSessionRegistry,
registerTeamSession,
} from "../../features/team-mode/team-session-registry"
import { getInboxDir, resolveBaseDir } from "../../features/team-mode/team-registry/paths"
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
import type { RuntimeState } from "../../features/team-mode/types"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import {
clearAllSessionPromptParams,
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
import { createTeamIdleWakeHint } from "./team-idle-wake-hint"
type WakeHintPromptInput = {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
temperature?: number
topP?: number
maxOutputTokens?: number
options?: Record<string, unknown>
}
query: { directory: string }
}
const temporaryDirectories: string[] = []
async function createTemporaryBaseDir(): Promise<string> {
const baseDir = await mkdtemp(path.join(tmpdir(), "team-idle-wake-hint-"))
temporaryDirectories.push(baseDir)
return baseDir
}
function createConfig(baseDir: string): TeamModeConfig {
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
}
function createRuntimeState(teamRunId: string, pendingInjectedMessageIds: string[] = []): RuntimeState {
return {
version: 1,
teamRunId,
teamName: "team-alpha",
specSource: "project",
createdAt: 1,
status: "active",
leadSessionId: "lead-session",
members: [
{
name: "worker",
sessionId: "member-session",
agentType: "general-purpose",
status: "idle",
pendingInjectedMessageIds,
},
],
shutdownRequests: [],
bounds: {
maxMembers: 8,
maxParallelMembers: 4,
maxMessagesPerRun: 10000,
maxWallClockMinutes: 120,
maxMemberTurns: 500,
},
}
}
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
await saveRuntimeState(runtimeState, config)
}
async function seedUnreadMessage(
teamRunId: string,
config: TeamModeConfig,
messageId: string,
body: string,
timestamp: number,
): Promise<void> {
await sendMessage({
version: 1,
messageId,
from: "lead",
to: "worker",
kind: "message",
body,
timestamp,
}, teamRunId, config, { isLead: true, activeMembers: ["worker"] })
}
afterEach(async () => {
clearTeamSessionRegistry()
SessionCategoryRegistry.clear()
clearAllSessionPromptParams()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
})
describe("createTeamIdleWakeHint", () => {
test("settles idle before sending the wake hint", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
await seedUnreadMessage(teamRunId, config, randomUUID(), "first message body", 100)
const promptAsyncSpy = mock(async (_input: WakeHintPromptInput) => ({}))
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config, { idleSettleMs: 50 })
// when
const startedAt = Date.now()
const eventPromise = handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
await Promise.resolve()
// then
expect(promptAsyncSpy).not.toHaveBeenCalled()
await eventPromise
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45)
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
})
test("sends a trigger-only wake hint when new unread mail exists", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
await seedUnreadMessage(teamRunId, config, randomUUID(), "first message body", 100)
await seedUnreadMessage(teamRunId, config, randomUUID(), "second message body", 200)
const promptInputs: Array<WakeHintPromptInput> = []
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
promptInputs.push(input)
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
const promptInput = promptInputs[0]
if (promptInput === undefined) {
throw new Error("expected wake hint prompt input")
}
expect(promptInput.path).toEqual({ id: "member-session" })
expect(promptInput.body.parts[0]?.text).toContain("2 new team messages")
expect(promptInput.body.parts[0]?.text).not.toContain("first message body")
expect(promptInput.body.parts[0]?.text).not.toContain("second message body")
})
test("pins the recipient's resolved subagent_type and model on the wake-hint promptAsync", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
const runtimeState = createRuntimeState(teamRunId)
const worker = runtimeState.members[0]
if (!worker) throw new Error("worker member missing from fixture")
worker.subagent_type = "atlas"
worker.model = { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" }
await seedRuntimeState(runtimeState, config)
await seedUnreadMessage(teamRunId, config, randomUUID(), "hello", 100)
const promptInputs: Array<WakeHintPromptInput> = []
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
promptInputs.push(input)
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
const promptInput = promptInputs[0]
if (promptInput === undefined) {
throw new Error("expected wake hint prompt input")
}
expect(promptInput.body.agent).toBe("atlas")
expect(promptInput.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
expect(promptInput.body.variant).toBe("high")
})
test("reapplies category routing and advanced prompt params on wake hints", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
const runtimeState = createRuntimeState(teamRunId)
const worker = runtimeState.members[0]
if (!worker) throw new Error("worker member missing from fixture")
worker.subagent_type = "Sisyphus-Junior"
worker.category = "quick"
worker.model = {
providerID: "openai",
modelID: "gpt-5.4",
variant: "medium",
reasoningEffort: "high",
temperature: 0.2,
top_p: 0.8,
maxTokens: 4096,
thinking: { type: "enabled", budgetTokens: 2048 },
}
await seedRuntimeState(runtimeState, config)
await seedUnreadMessage(teamRunId, config, randomUUID(), "hello", 100)
const promptInputs: Array<WakeHintPromptInput> = []
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
promptInputs.push(input)
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
const promptInput = promptInputs[0]
if (promptInput === undefined) {
throw new Error("expected wake hint prompt input")
}
expect(promptInput.body.agent).toBe("Sisyphus-Junior")
expect(promptInput.body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptInput.body.variant).toBe("medium")
expect(promptInput.body.temperature).toBe(0.2)
expect(promptInput.body.topP).toBe(0.8)
expect(promptInput.body.maxOutputTokens).toBe(4096)
expect(promptInput.body.options).toEqual({
reasoningEffort: "high",
thinking: { type: "enabled", budgetTokens: 2048 },
})
expect(SessionCategoryRegistry.get("member-session")).toBe("quick")
expect(getSessionPromptParams("member-session")).toEqual({
temperature: 0.2,
topP: 0.8,
maxOutputTokens: 4096,
options: {
reasoningEffort: "high",
thinking: { type: "enabled", budgetTokens: 2048 },
},
})
})
test("omits agent and model on the wake-hint promptAsync when the member has none recorded", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
await seedUnreadMessage(teamRunId, config, randomUUID(), "hello", 100)
const promptInputs: Array<WakeHintPromptInput> = []
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
promptInputs.push(input)
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
const promptInput = promptInputs[0]
if (promptInput === undefined) {
throw new Error("expected wake hint prompt input")
}
expect(promptInput.body.agent).toBeUndefined()
expect(promptInput.body.model).toBeUndefined()
expect(promptInput.body.variant).toBeUndefined()
})
test("acks pending messages on idle, moves files to processed, and clears pending ids", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
const messageIds = [randomUUID(), randomUUID(), randomUUID()]
await seedRuntimeState(createRuntimeState(teamRunId, messageIds), config)
await seedUnreadMessage(teamRunId, config, messageIds[0], "one", 100)
await seedUnreadMessage(teamRunId, config, messageIds[1], "two", 200)
await seedUnreadMessage(teamRunId, config, messageIds[2], "three", 300)
const ackSpy = spyOn(ackModule, "ackMessages")
const promptAsyncSpy = mock(async (_input: {
path: { id: string }
body: { parts: Array<{ type: "text"; text: string }> }
query: { directory: string }
}) => {
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(ackSpy).toHaveBeenCalledTimes(1)
expect(ackSpy).toHaveBeenCalledWith(teamRunId, "worker", messageIds, config)
expect(promptAsyncSpy).not.toHaveBeenCalled()
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.pendingInjectedMessageIds).toEqual([])
const inboxEntries = await readdir(getInboxDir(resolveBaseDir(config), teamRunId, "worker"))
expect(inboxEntries).toContain("processed")
const processedEntries = await readdir(path.join(getInboxDir(resolveBaseDir(config), teamRunId, "worker"), "processed"))
expect(processedEntries.sort()).toEqual(messageIds.map((messageId) => `${messageId}.json`).sort())
})
test("sends a wake hint during the spawn race when the registry tracks the fresh member session before disk state persists it", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
const staleRuntimeState: RuntimeState = {
...createRuntimeState(teamRunId),
members: [
{
name: "worker",
agentType: "general-purpose",
status: "idle",
pendingInjectedMessageIds: [],
},
],
}
await seedRuntimeState(staleRuntimeState, config)
await seedUnreadMessage(teamRunId, config, randomUUID(), "fresh registry wake hint", 100)
registerTeamSession("member-session", {
teamRunId,
memberName: "worker",
role: "member",
})
const promptInputs: Array<WakeHintPromptInput> = []
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
promptInputs.push(input)
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
const promptInput = promptInputs[0]
if (promptInput === undefined) {
throw new Error("expected wake hint prompt input")
}
expect(promptInput.body.parts[0]?.text).toContain("1 new team messages")
})
test("falls back to disk lookup when the registry points the member session at the wrong teamRunId", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const correctTeamRunId = randomUUID()
const wrongTeamRunId = randomUUID()
const correctRuntimeState = createRuntimeState(correctTeamRunId)
const correctWorker = correctRuntimeState.members[0]
if (correctWorker === undefined) {
throw new Error("worker member missing from correct fixture")
}
correctWorker.subagent_type = "atlas"
await seedRuntimeState(correctRuntimeState, config)
await seedRuntimeState({
...createRuntimeState(wrongTeamRunId),
members: [
{
name: "worker",
sessionId: "other-session",
agentType: "general-purpose",
status: "idle",
pendingInjectedMessageIds: [],
},
],
}, config)
await seedUnreadMessage(correctTeamRunId, config, randomUUID(), "first correct message", 100)
await seedUnreadMessage(correctTeamRunId, config, randomUUID(), "second correct message", 200)
await seedUnreadMessage(wrongTeamRunId, config, randomUUID(), "wrong team message", 300)
registerTeamSession("member-session", {
teamRunId: wrongTeamRunId,
memberName: "worker",
role: "member",
})
const promptInputs: Array<WakeHintPromptInput> = []
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
promptInputs.push(input)
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
const promptInput = promptInputs[0]
if (promptInput === undefined) {
throw new Error("expected wake hint prompt input")
}
expect(promptInput.body.parts[0]?.text).toContain("2 new team messages")
expect(promptInput.body.agent).toBe("atlas")
})
})
@@ -0,0 +1,126 @@
import type { TeamModeConfig } from "../../config/schema/team-mode"
import { ackMessages } from "../../features/team-mode/team-mailbox/ack"
import { listUnreadMessages } from "../../features/team-mode/team-mailbox/inbox"
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
import {
applyMemberSessionRouting,
buildMemberPromptBody,
} from "../../features/team-mode/member-session-routing"
import { log } from "../../shared/logger"
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
type PromptAsyncInput = {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
}
query: { directory: string }
}
type TeamIdleWakeHintContext = {
directory: string
client: {
session: {
promptAsync?: (input: PromptAsyncInput) => Promise<unknown>
}
}
}
type HookInput = { event: { type: string; properties?: unknown } }
export type HookImpl = (input: HookInput) => Promise<void>
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
function getIdleSessionID(properties: unknown): string | undefined {
const record = properties as { sessionID?: string } | undefined
return record?.sessionID
}
function buildWakeHint(unreadCount: number): string {
return `You have ${unreadCount} new team messages. They will be injected on your next turn.`
}
export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: TeamModeConfig, options?: TeamIdleWakeHintOptions): HookImpl {
return async ({ event }: HookInput): Promise<void> => {
if (event.type !== "session.idle") return
const sessionID = getIdleSessionID(event.properties)
if (!sessionID) return
try {
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team idle wake hint")
if (runtimeMember === null) {
return
}
const runtimeState = await loadRuntimeState(runtimeMember.teamRunId, config)
const memberEntry = runtimeState.members.find((member) => member.name === runtimeMember.memberName)
if (!memberEntry || memberEntry.agentType === "leader") {
return
}
const pendingInjectedMessageIds = [...memberEntry.pendingInjectedMessageIds]
if (pendingInjectedMessageIds.length > 0) {
await ackMessages(runtimeState.teamRunId, memberEntry.name, pendingInjectedMessageIds, config)
await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
...currentRuntimeState,
members: currentRuntimeState.members.map((member) => (
member.name === memberEntry.name
? { ...member, pendingInjectedMessageIds: [] }
: member
)),
}), config)
}
const unreadMessages = await listUnreadMessages(runtimeState.teamRunId, memberEntry.name, config)
if (unreadMessages.length === 0) {
log("team idle handled without wake hint", {
event: "team-mode-idle-ack-only",
teamRunId: runtimeState.teamRunId,
memberName: memberEntry.name,
sessionID,
ackedCount: pendingInjectedMessageIds.length,
})
return
}
if (typeof ctx.client.session.promptAsync !== "function") {
log("team idle wake hint skipped without promptAsync", {
event: "team-mode-idle-wake-hint-skipped",
teamRunId: runtimeState.teamRunId,
memberName: memberEntry.name,
sessionID,
unreadCount: unreadMessages.length,
})
return
}
applyMemberSessionRouting(sessionID, memberEntry)
await settleAfterSessionIdle(options?.idleSettleMs)
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
query: { directory: ctx.directory },
})
log("team idle wake hint sent", {
event: "team-mode-idle-wake-hint",
teamRunId: runtimeState.teamRunId,
memberName: memberEntry.name,
sessionID,
unreadCount: unreadMessages.length,
ackedCount: pendingInjectedMessageIds.length,
})
} catch (error) {
log("team idle wake hint failed", {
event: "team-mode-idle-wake-hint-error",
sessionID,
error: error instanceof Error ? error.message : String(error),
})
}
}
}
@@ -0,0 +1,169 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import type { TeamModeConfig } from "../../config/schema/team-mode"
import * as deleteTeamModule from "../../features/team-mode/team-runtime/delete-team"
import {
clearTeamSessionRegistry,
registerTeamSession,
} from "../../features/team-mode/team-session-registry"
import type { RuntimeState } from "../../features/team-mode/types"
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
import { createTeamLeadOrphanHandler } from "./team-lead-orphan-handler"
const temporaryDirectories: string[] = []
async function createTemporaryBaseDir(): Promise<string> {
const baseDir = await mkdtemp(path.join(tmpdir(), "team-lead-orphan-handler-"))
temporaryDirectories.push(baseDir)
return baseDir
}
function createConfig(baseDir: string): TeamModeConfig {
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
}
function createRuntimeState(teamRunId: string): RuntimeState {
return {
version: 1,
teamRunId,
teamName: "team-alpha",
specSource: "project",
createdAt: 1,
status: "active",
leadSessionId: "lead-session",
members: [
{
name: "worker",
sessionId: "member-session",
agentType: "general-purpose",
status: "running",
pendingInjectedMessageIds: [],
},
],
shutdownRequests: [],
bounds: {
maxMembers: 8,
maxParallelMembers: 4,
maxMessagesPerRun: 10000,
maxWallClockMinutes: 120,
maxMemberTurns: 500,
},
}
}
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
await saveRuntimeState(runtimeState, config)
}
afterEach(async () => {
mock.restore()
clearTeamSessionRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
})
describe("createTeamLeadOrphanHandler", () => {
test("#given the deleted session matches the lead #when the orphan handler runs #then it marks the team orphaned and force-deletes the team", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam")
deleteTeamSpy.mockResolvedValue({ removedLayout: true, removedWorktrees: [] })
const handler = createTeamLeadOrphanHandler(config)
// when
await handler({
event: {
type: "session.deleted",
properties: { info: { id: "lead-session" } },
},
})
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.status).toBe("orphaned")
expect(deleteTeamSpy).toHaveBeenCalledTimes(1)
expect(deleteTeamSpy).toHaveBeenCalledWith(teamRunId, config, undefined, undefined, { force: true })
})
test("#given the registry tracks a fresh lead session before disk state persists it #when the orphan handler runs #then it still marks the team orphaned and force-deletes it", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState({
...createRuntimeState(teamRunId),
leadSessionId: undefined,
}, config)
registerTeamSession("lead-session", {
teamRunId,
memberName: "lead",
role: "lead",
})
const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam")
deleteTeamSpy.mockResolvedValue({ removedLayout: false, removedWorktrees: [] })
const handler = createTeamLeadOrphanHandler(config)
// when
await handler({
event: {
type: "session.deleted",
properties: { info: { id: "lead-session" } },
},
})
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.status).toBe("orphaned")
expect(deleteTeamSpy).toHaveBeenCalledTimes(1)
expect(deleteTeamSpy).toHaveBeenCalledWith(teamRunId, config, undefined, undefined, { force: true })
})
test("#given the registry points the lead session at the wrong teamRunId #when the orphan handler runs #then it falls back to disk lookup, orphans the correct team, and force-deletes it", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const correctTeamRunId = randomUUID()
const wrongTeamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(correctTeamRunId), config)
await seedRuntimeState({
...createRuntimeState(wrongTeamRunId),
leadSessionId: "other-lead-session",
}, config)
registerTeamSession("lead-session", {
teamRunId: wrongTeamRunId,
memberName: "lead",
role: "lead",
})
const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam")
deleteTeamSpy.mockResolvedValue({ removedLayout: false, removedWorktrees: [] })
const handler = createTeamLeadOrphanHandler(config)
// when
await handler({
event: {
type: "session.deleted",
properties: { info: { id: "lead-session" } },
},
})
// then
const correctRuntimeState = await loadRuntimeState(correctTeamRunId, config)
const wrongRuntimeState = await loadRuntimeState(wrongTeamRunId, config)
expect(correctRuntimeState.status).toBe("orphaned")
expect(wrongRuntimeState.status).toBe("active")
expect(deleteTeamSpy).toHaveBeenCalledTimes(1)
expect(deleteTeamSpy).toHaveBeenCalledWith(correctTeamRunId, config, undefined, undefined, { force: true })
})
})
@@ -0,0 +1,108 @@
import type { TeamModeConfig } from "../../config/schema/team-mode"
import type { BackgroundManager } from "../../features/background-agent/manager"
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import type { TmuxSessionManager } from "../../features/tmux-subagent/manager"
import { log } from "../../shared/logger"
type HookInput = { event: { type: string; properties?: unknown } }
export type HookImpl = (input: HookInput) => Promise<void>
function getDeletedSessionID(properties: unknown): string | undefined {
const record = properties as { info?: { id?: string } } | undefined
return record?.info?.id
}
async function findLeadTeamRunId(
deletedSessionID: string,
config: TeamModeConfig,
): Promise<string | null> {
const registryEntry = lookupTeamSession(deletedSessionID)
if (registryEntry?.role === "lead") {
try {
const runtimeState = await loadRuntimeState(registryEntry.teamRunId, config)
if (runtimeState.leadSessionId === undefined || runtimeState.leadSessionId === deletedSessionID) {
return runtimeState.teamRunId
}
} catch (error) {
log("team lead orphan handler registry lookup failed", {
event: "team-mode-lead-orphan-handler-registry-error",
teamRunId: registryEntry.teamRunId,
deletedSessionID,
error: error instanceof Error ? error.message : String(error),
})
}
}
const activeTeams = await listActiveTeams(config)
for (const activeTeam of activeTeams) {
try {
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config)
if (runtimeState.leadSessionId === deletedSessionID) {
return runtimeState.teamRunId
}
} catch (error) {
log("team lead orphan handler skipped runtime", {
event: "team-mode-lead-orphan-handler-runtime-error",
teamRunId: activeTeam.teamRunId,
deletedSessionID,
error: error instanceof Error ? error.message : String(error),
})
}
}
return null
}
export function createTeamLeadOrphanHandler(
config: TeamModeConfig,
tmuxMgr?: TmuxSessionManager,
bgMgr?: BackgroundManager,
): HookImpl {
return async ({ event }: HookInput): Promise<void> => {
if (event.type !== "session.deleted") return
const deletedSessionID = getDeletedSessionID(event.properties)
if (!deletedSessionID) return
try {
const teamRunId = await findLeadTeamRunId(deletedSessionID, config)
if (teamRunId === null) {
return
}
const runtimeState = await loadRuntimeState(teamRunId, config)
const nextRuntimeState = await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
...currentRuntimeState,
status: "orphaned",
}), config)
log("team lead session deleted", {
event: "team-mode-lead-orphaned",
teamRunId: runtimeState.teamRunId,
teamName: runtimeState.teamName,
deletedSessionID,
previousStatus: runtimeState.status,
nextStatus: nextRuntimeState.status,
})
try {
const { deleteTeam } = await import("../../features/team-mode/team-runtime/delete-team")
await deleteTeam(teamRunId, config, tmuxMgr, bgMgr, { force: true })
} catch (deleteError) {
log("team lead orphan cleanup failed (non-fatal)", {
event: "team-mode-lead-orphan-cleanup-error",
teamRunId,
error: deleteError instanceof Error ? deleteError.message : String(deleteError),
})
}
} catch (error) {
log("team lead orphan handler failed", {
event: "team-mode-lead-orphan-handler-error",
deletedSessionID,
error: error instanceof Error ? error.message : String(error),
})
}
}
}
@@ -0,0 +1,172 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import type { TeamModeConfig } from "../../config/schema/team-mode"
import {
clearTeamSessionRegistry,
registerTeamSession,
} from "../../features/team-mode/team-session-registry"
import type { RuntimeState } from "../../features/team-mode/types"
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
import { createTeamMemberErrorHandler } from "./team-member-error-handler"
const temporaryDirectories: string[] = []
async function createTemporaryBaseDir(): Promise<string> {
const baseDir = await mkdtemp(path.join(tmpdir(), "team-member-error-handler-"))
temporaryDirectories.push(baseDir)
return baseDir
}
function createConfig(baseDir: string): TeamModeConfig {
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
}
function createRuntimeState(teamRunId: string): RuntimeState {
return {
version: 1,
teamRunId,
teamName: "team-alpha",
specSource: "project",
createdAt: 1,
status: "active",
leadSessionId: "lead-session",
members: [
{
name: "worker",
sessionId: "member-session",
agentType: "general-purpose",
status: "running",
pendingInjectedMessageIds: [],
},
],
shutdownRequests: [],
bounds: {
maxMembers: 8,
maxParallelMembers: 4,
maxMessagesPerRun: 10000,
maxWallClockMinutes: 120,
maxMemberTurns: 500,
},
}
}
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
await saveRuntimeState(runtimeState, config)
}
afterEach(async () => {
clearTeamSessionRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
})
describe("createTeamMemberErrorHandler", () => {
test("marks the matching member errored without changing team status", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
const handler = createTeamMemberErrorHandler(config)
// when
await handler({
event: {
type: "session.error",
properties: { sessionID: "member-session", error: new Error("boom") },
},
})
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.status).toBe("active")
expect(runtimeState.members[0]?.status).toBe("errored")
})
test("marks the member errored during the spawn race when the registry tracks the fresh session before disk state persists it", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState({
...createRuntimeState(teamRunId),
members: [
{
name: "worker",
agentType: "general-purpose",
status: "running",
pendingInjectedMessageIds: [],
},
],
}, config)
registerTeamSession("member-session", {
teamRunId,
memberName: "worker",
role: "member",
})
const handler = createTeamMemberErrorHandler(config)
// when
await handler({
event: {
type: "session.error",
properties: { sessionID: "member-session", error: new Error("boom") },
},
})
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.status).toBe("active")
expect(runtimeState.members[0]?.status).toBe("errored")
})
test("falls back to disk lookup when the registry points the member session at the wrong teamRunId", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const correctTeamRunId = randomUUID()
const wrongTeamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(correctTeamRunId), config)
await seedRuntimeState({
...createRuntimeState(wrongTeamRunId),
members: [
{
name: "worker",
sessionId: "other-session",
agentType: "general-purpose",
status: "running",
pendingInjectedMessageIds: [],
},
],
}, config)
registerTeamSession("member-session", {
teamRunId: wrongTeamRunId,
memberName: "worker",
role: "member",
})
const handler = createTeamMemberErrorHandler(config)
// when
await handler({
event: {
type: "session.error",
properties: { sessionID: "member-session", error: new Error("boom") },
},
})
// then
const correctRuntimeState = await loadRuntimeState(correctTeamRunId, config)
const wrongRuntimeState = await loadRuntimeState(wrongTeamRunId, config)
expect(correctRuntimeState.members[0]?.status).toBe("errored")
expect(wrongRuntimeState.members[0]?.status).toBe("running")
})
})
@@ -0,0 +1,53 @@
import type { TeamModeConfig } from "../../config/schema/team-mode"
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import { log } from "../../shared/logger"
type HookInput = { event: { type: string; properties?: unknown } }
export type HookImpl = (input: HookInput) => Promise<void>
function getErroredSessionID(properties: unknown): string | undefined {
const record = properties as { sessionID?: string } | undefined
return record?.sessionID
}
export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl {
return async ({ event }: HookInput): Promise<void> => {
if (event.type !== "session.error") return
const erroredSessionID = getErroredSessionID(event.properties)
if (!erroredSessionID) return
try {
const runtimeMember = await findResolvedMemberSession(erroredSessionID, config, "team member error handler")
if (runtimeMember === null) {
return
}
const runtimeState = await loadRuntimeState(runtimeMember.teamRunId, config)
await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
...currentRuntimeState,
members: currentRuntimeState.members.map((member) => (
member.name === runtimeMember.memberName
? { ...member, status: "errored" }
: member
)),
}), config)
log("team member session errored", {
event: "team-mode-member-errored",
teamRunId: runtimeState.teamRunId,
teamName: runtimeState.teamName,
memberName: runtimeMember.memberName,
sessionID: erroredSessionID,
runtimeStatus: runtimeState.status,
})
} catch (error) {
log("team member error handler failed", {
event: "team-mode-member-error-handler-error",
sessionID: erroredSessionID,
error: error instanceof Error ? error.message : String(error),
})
}
}
}
@@ -0,0 +1,221 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import type { TeamModeConfig } from "../../config/schema/team-mode"
import {
clearTeamSessionRegistry,
registerTeamSession,
} from "../../features/team-mode/team-session-registry"
import type { RuntimeState, RuntimeStateMember } from "../../features/team-mode/types"
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
import { createTeamMemberStatusHandler } from "./team-member-status-handler"
const temporaryDirectories: string[] = []
async function createTemporaryBaseDir(): Promise<string> {
const baseDir = await mkdtemp(path.join(tmpdir(), "team-member-status-handler-"))
temporaryDirectories.push(baseDir)
return baseDir
}
function createConfig(baseDir: string): TeamModeConfig {
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
}
function buildMember(overrides?: Partial<RuntimeStateMember>): RuntimeStateMember {
return {
name: "worker",
sessionId: "member-session",
agentType: "general-purpose",
status: "running",
pendingInjectedMessageIds: [],
...overrides,
}
}
function createRuntimeState(teamRunId: string, member: RuntimeStateMember = buildMember()): RuntimeState {
return {
version: 1,
teamRunId,
teamName: "team-alpha",
specSource: "project",
createdAt: 1,
status: "active",
leadSessionId: "lead-session",
members: [member],
shutdownRequests: [],
bounds: {
maxMembers: 8,
maxParallelMembers: 4,
maxMessagesPerRun: 10000,
maxWallClockMinutes: 120,
maxMemberTurns: 500,
},
}
}
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
await saveRuntimeState(runtimeState, config)
}
afterEach(async () => {
clearTeamSessionRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
})
describe("createTeamMemberStatusHandler", () => {
test("transitions a running member to idle when its session becomes idle", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "running" })), config)
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("idle")
})
test("leaves an already-idle member untouched on a subsequent session.idle", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "idle" })), config)
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("idle")
})
test("never overrides a terminal errored status on session.idle", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "errored" })), config)
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("errored")
})
test("marks a running member completed when its session is deleted", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "running" })), config)
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.deleted", properties: { info: { id: "member-session" } } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("completed")
})
test("marks an idle member completed when its session is deleted", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "idle" })), config)
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.deleted", properties: { info: { id: "member-session" } } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("completed")
})
test("preserves a terminal errored status even when the session is deleted", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "errored" })), config)
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.deleted", properties: { info: { id: "member-session" } } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("errored")
})
test("ignores session.idle events for sessions that are not team members", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.idle", properties: { sessionID: "unknown-session" } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("running")
})
test("ignores session.deleted events when the deleted session is the team lead", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
registerTeamSession("lead-session", { teamRunId, memberName: "lead", role: "lead" })
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.deleted", properties: { info: { id: "lead-session" } } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("running")
})
test("uses the in-memory registry to recognize a fresh session during the spawn race", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ sessionId: undefined, status: "running" })), config)
registerTeamSession("member-session", { teamRunId, memberName: "worker", role: "member" })
const handler = createTeamMemberStatusHandler(config)
// when
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
// then
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.status).toBe("idle")
})
})
@@ -0,0 +1,93 @@
import type { TeamModeConfig } from "../../config/schema/team-mode"
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import type { RuntimeStateMember } from "../../features/team-mode/types"
import { log } from "../../shared/logger"
type HookInput = { event: { type: string; properties?: unknown } }
export type HookImpl = (input: HookInput) => Promise<void>
type MemberStatus = RuntimeStateMember["status"]
const IDLE_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running"])
const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running", "idle", "pending"])
function getSessionIDFromIdleEvent(properties: unknown): string | undefined {
const record = properties as { sessionID?: string } | undefined
return record?.sessionID
}
function getSessionIDFromDeletedEvent(properties: unknown): string | undefined {
const record = properties as { info?: { id?: string } } | undefined
return record?.info?.id
}
async function transitionMemberStatus(
runtimeMember: { teamRunId: string; memberName: string },
allowedSources: ReadonlySet<MemberStatus>,
nextStatus: MemberStatus,
config: TeamModeConfig,
sessionID: string,
eventLabel: string,
): Promise<void> {
const runtimeState = await loadRuntimeState(runtimeMember.teamRunId, config)
const currentEntry = runtimeState.members.find((member) => member.name === runtimeMember.memberName)
if (currentEntry === undefined) return
if (!allowedSources.has(currentEntry.status)) return
await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
...currentRuntimeState,
members: currentRuntimeState.members.map((member) => (
member.name === runtimeMember.memberName
? { ...member, status: nextStatus }
: member
)),
}), config)
log(`team member ${eventLabel}`, {
event: `team-mode-member-${eventLabel}`,
teamRunId: runtimeState.teamRunId,
teamName: runtimeState.teamName,
memberName: runtimeMember.memberName,
sessionID,
previousStatus: currentEntry.status,
nextStatus,
})
}
export function createTeamMemberStatusHandler(config: TeamModeConfig): HookImpl {
return async ({ event }: HookInput): Promise<void> => {
if (event.type === "session.idle") {
const sessionID = getSessionIDFromIdleEvent(event.properties)
if (!sessionID) return
try {
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team member status handler")
if (runtimeMember === null) return
await transitionMemberStatus(runtimeMember, IDLE_TRANSITION_SOURCE_STATUSES, "idle", config, sessionID, "idled")
} catch (error) {
log("team member status handler failed on session.idle", {
event: "team-mode-member-status-handler-error",
sessionID,
error: error instanceof Error ? error.message : String(error),
})
}
return
}
if (event.type === "session.deleted") {
const sessionID = getSessionIDFromDeletedEvent(event.properties)
if (!sessionID) return
try {
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team member status handler")
if (runtimeMember === null) return
await transitionMemberStatus(runtimeMember, COMPLETED_TRANSITION_SOURCE_STATUSES, "completed", config, sessionID, "completed")
} catch (error) {
log("team member status handler failed on session.deleted", {
event: "team-mode-member-status-handler-error",
sessionID,
error: error instanceof Error ? error.message : String(error),
})
}
}
}
}
+276
View File
@@ -0,0 +1,276 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { TeamModeConfig } from "../../config/schema/team-mode"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import {
clearTeamSessionRegistry,
registerTeamSession,
} from "../../features/team-mode/team-session-registry"
import type { RuntimeState } from "../../features/team-mode/types"
import { saveRuntimeState } from "../../features/team-mode/team-state-store/store"
import { createTeamToolGating } from "./hook"
function createConfig(overrides?: Partial<TeamModeConfig>, baseDir = "/tmp/team-mode"): TeamModeConfig {
return {
enabled: true,
tmux_visualization: false,
max_parallel_members: 4,
max_members: 8,
max_messages_per_run: 10_000,
max_wall_clock_minutes: 120,
max_member_turns: 500,
base_dir: baseDir,
message_payload_max_bytes: 32_768,
recipient_unread_max_bytes: 262_144,
mailbox_poll_interval_ms: 3_000,
...overrides,
}
}
function createRuntimeState(): RuntimeState {
return {
version: 1,
teamRunId: "11111111-1111-4111-8111-111111111111",
teamName: "team-alpha",
specSource: "project",
createdAt: 1,
status: "active",
leadSessionId: "lead-session",
members: [
{ name: "m1", sessionId: "member-session-1", agentType: "general-purpose", status: "running", pendingInjectedMessageIds: [] },
{ name: "m2", sessionId: "member-session-2", agentType: "general-purpose", status: "running", pendingInjectedMessageIds: [] },
],
shutdownRequests: [],
bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10_000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
}
}
async function seedTeams(baseDir: string, ...runtimeStates: RuntimeState[]): Promise<void> {
const config = TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
await Promise.all(runtimeStates.map(async (runtimeState) => {
await mkdir(path.join(baseDir, "runtime", runtimeState.teamRunId), { recursive: true })
await saveRuntimeState(runtimeState, config)
}))
}
async function runHook(tool: string, sessionID: string, args: Record<string, unknown>, config?: Partial<TeamModeConfig>, baseDir = "/tmp/team-mode"): Promise<void> {
const hook = createTeamToolGating({ directory: baseDir } as PluginInput, createConfig(config, baseDir))
await hook["tool.execute.before"]?.({ tool, sessionID, callID: "call-1" }, { args })
}
describe("createTeamToolGating", () => {
const temporaryDirectories: string[] = []
beforeEach(() => {
temporaryDirectories.length = 0
clearTeamSessionRegistry()
})
afterEach(async () => {
clearTeamSessionRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
})
test("allows a fresh session to call team_create", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_create", "fresh-session", {}, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("allows team_list from a fresh session", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_list", "fresh-session", {}, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("rejects team_create when the caller is already a team member", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_create", "member-session-1", {}, undefined, baseDir)
// then
await expect(result).rejects.toThrow("team_create denied: session is already a participant of team 11111111-1111-4111-8111-111111111111")
})
test("allows the target member to self-approve shutdown", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_approve_shutdown", "member-session-1", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("allows the lead to force-approve shutdown", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_approve_shutdown", "lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("rejects a non-target member from approving shutdown", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_approve_shutdown", "member-session-2", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
// then
await expect(result).rejects.toThrow("team_approve_shutdown: caller must be target member or team lead")
})
test("allows delegate-task for team members without a run-wide budget", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("delegate-task", "member-session-1", {}, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("allows team_delete for the lead of the target team", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_delete", "lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("no-ops for unrelated tools without querying team state", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("write", "fresh-session", {}, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("allows team_send_message during the spawn race when runtime state lacks the member's sessionId but the registry already has it", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
const staleRuntimeState: RuntimeState = {
...createRuntimeState(),
members: [
{ name: "m1", agentType: "general-purpose", status: "pending", pendingInjectedMessageIds: [] },
{ name: "m2", agentType: "general-purpose", status: "pending", pendingInjectedMessageIds: [] },
],
}
await seedTeams(baseDir, staleRuntimeState)
registerTeamSession("just-spawned-session", {
teamRunId: "11111111-1111-4111-8111-111111111111",
memberName: "m1",
role: "member",
})
// when
const result = runHook("team_send_message", "just-spawned-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("allows team_send_message from a lead whose session is tracked only in the registry", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
const staleRuntimeState: RuntimeState = {
...createRuntimeState(),
leadSessionId: undefined,
members: [
{ name: "lead", agentType: "leader", status: "pending", pendingInjectedMessageIds: [] },
],
}
await seedTeams(baseDir, staleRuntimeState)
registerTeamSession("caller-lead-session", {
teamRunId: "11111111-1111-4111-8111-111111111111",
memberName: "lead",
role: "lead",
})
// when
const result = runHook("team_send_message", "caller-lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
// then
await expect(result).resolves.toBeUndefined()
})
test("rejects team_send_message when the session is not in the registry and not in runtime state", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_send_message", "unknown-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
// then
await expect(result).rejects.toThrow("team-mode tool team_send_message denied: not a participant of team 11111111-1111-4111-8111-111111111111")
})
test("rejects team_send_message when the registry only has the caller for a different team than the requested teamRunId", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
const emptyState: RuntimeState = { ...createRuntimeState(), members: [] }
await seedTeams(baseDir, emptyState)
registerTeamSession("cross-team-session", {
teamRunId: "22222222-2222-4222-8222-222222222222",
memberName: "other-team-member",
role: "member",
})
// when
const result = runHook("team_send_message", "cross-team-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
// then
await expect(result).rejects.toThrow("denied: not a participant of team 11111111-1111-4111-8111-111111111111")
})
})
+149
View File
@@ -0,0 +1,149 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { TeamModeConfig } from "../../config/schema/team-mode"
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
import type { RuntimeState } from "../../features/team-mode/types"
import {
listActiveTeams,
loadRuntimeState,
} from "../../features/team-mode/team-state-store"
const ACTIVE_RUNTIME_STATUSES = new Set<RuntimeState["status"]>(["creating", "active", "shutdown_requested"])
const UNIVERSAL_TOOL_NAMES = new Set([
"team_send_message",
"team_task_create",
"team_task_list",
"team_task_update",
"team_task_get",
"team_status",
])
type TeamParticipant =
| { role: "neither" }
| { role: "lead"; teamRunId: string }
| { role: "member"; teamRunId: string; memberName: string }
function getStringArg(args: Record<string, unknown>, key: string): string | undefined {
const value = args[key]
return typeof value === "string" ? value : undefined
}
function resolveParticipantFromRegistry(sessionID: string): TeamParticipant | undefined {
const entry = lookupTeamSession(sessionID)
if (!entry) return undefined
if (entry.role === "lead") {
return { role: "lead", teamRunId: entry.teamRunId }
}
return { role: "member", teamRunId: entry.teamRunId, memberName: entry.memberName }
}
async function resolveParticipant(sessionID: string, config: TeamModeConfig): Promise<TeamParticipant> {
const fromRegistry = resolveParticipantFromRegistry(sessionID)
if (fromRegistry) {
return fromRegistry
}
const activeTeams = await listActiveTeams(config)
for (const activeTeam of activeTeams) {
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config)
if (!ACTIVE_RUNTIME_STATUSES.has(runtimeState.status)) {
continue
}
if (runtimeState.leadSessionId === sessionID) {
return { role: "lead", teamRunId: runtimeState.teamRunId }
}
const matchedMember = runtimeState.members.find((member) => member.sessionId === sessionID)
if (matchedMember) {
return {
role: "member",
teamRunId: runtimeState.teamRunId,
memberName: matchedMember.name,
}
}
}
return { role: "neither" }
}
function isLeadOfTargetTeam(participant: TeamParticipant, teamRunId: string | undefined): boolean {
return participant.role === "lead" && participant.teamRunId === teamRunId
}
function isTargetMember(participant: TeamParticipant, teamRunId: string | undefined, memberName: string | undefined): boolean {
return participant.role === "member"
&& participant.teamRunId === teamRunId
&& participant.memberName === memberName
}
export function createTeamToolGating(_ctx: PluginInput, config: TeamModeConfig | undefined): Hooks {
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> },
): Promise<void> => {
if (!config?.enabled) {
return
}
const toolName = input.tool
if (!toolName.startsWith("team_") && toolName !== "delegate-task") {
return
}
const participant = await resolveParticipant(input.sessionID, config)
if (toolName === "delegate-task") {
return
}
if (toolName === "team_create") {
if (participant.role !== "neither") {
throw new Error(`team_create denied: session is already a participant of team ${participant.teamRunId}`)
}
return
}
const teamRunId = getStringArg(output.args, "teamRunId")
const memberName = getStringArg(output.args, "memberName")
if (toolName === "team_delete" || toolName === "team_shutdown_request") {
if (!isLeadOfTargetTeam(participant, teamRunId)) {
throw new Error(`${toolName} is lead-only`)
}
return
}
if (toolName === "team_approve_shutdown" || toolName === "team_reject_shutdown") {
if (!isLeadOfTargetTeam(participant, teamRunId) && !isTargetMember(participant, teamRunId, memberName)) {
throw new Error(`${toolName}: caller must be target member or team lead`)
}
return
}
if (toolName === "team_list") {
return
}
if (UNIVERSAL_TOOL_NAMES.has(toolName)) {
if (
(participant.role === "lead" || participant.role === "member")
&& participant.teamRunId === teamRunId
) {
return
}
throw new Error(
teamRunId === undefined
? `team-mode tool ${toolName} requires teamRunId argument`
: `team-mode tool ${toolName} denied: not a participant of team ${teamRunId}`,
)
}
},
}
}
+1
View File
@@ -0,0 +1 @@
export { createTeamToolGating } from "./hook"
@@ -1,6 +1,6 @@
# src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -79,7 +79,7 @@ export async function injectContinuation(args: {
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
: false
if (hasRunningBgTasks) {
@@ -13,6 +13,45 @@ import { handleSessionIdle } from "./idle-event"
import { handleNonIdleEvent } from "./non-idle-events"
import { isTokenLimitError } from "./token-limit-detection"
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined
}
function getStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key]
return typeof value === "string" && value.length > 0 ? value : undefined
}
function extractSessionErrorInfo(error: unknown): { name?: string; message?: string } | undefined {
if (!error) return undefined
if (typeof error === "string") return { message: error }
if (error instanceof Error) return { name: error.name, message: error.message }
const root = asRecord(error)
if (!root) return { message: String(error) }
const data = asRecord(root.data)
const nestedError = asRecord(root.error)
const dataError = asRecord(data?.error)
const name = getStringField(root, "name")
?? getStringField(data, "name")
?? getStringField(nestedError, "name")
?? getStringField(dataError, "name")
const messageParts = [
getStringField(root, "message"),
getStringField(data, "message"),
getStringField(nestedError, "message"),
getStringField(dataError, "message"),
getStringField(root, "code"),
getStringField(nestedError, "code"),
getStringField(dataError, "code"),
].filter((message): message is string => typeof message === "string")
return { name, message: messageParts.join(" ") || undefined }
}
export function createTodoContinuationHandler(args: {
ctx: PluginInput
sessionStateStore: SessionStateStore
@@ -35,7 +74,8 @@ export function createTodoContinuationHandler(args: {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const error = props?.error as { name?: string; message?: string } | undefined
const error = extractSessionErrorInfo(props?.error)
let shouldCancelCountdown = false
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
const state = sessionStateStore.getState(sessionID)
state.wasCancelled = true
@@ -45,14 +85,18 @@ export function createTodoContinuationHandler(args: {
state.awaitingPostInjectionProgressCheck = false
state.stagnationCount = 0
state.consecutiveFailures = 0
shouldCancelCountdown = true
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
} else if (isTokenLimitError(error)) {
const state = sessionStateStore.getState(sessionID)
state.tokenLimitDetected = true
shouldCancelCountdown = true
log(`[${HOOK_NAME}] Token limit error detected via session.error`, { sessionID, errorName: error?.name, errorMessage: error?.message })
}
sessionStateStore.cancelCountdown(sessionID)
if (shouldCancelCountdown) {
sessionStateStore.cancelCountdown(sessionID)
}
log(`[${HOOK_NAME}] session.error`, { sessionID })
return
}
@@ -71,7 +71,7 @@ export async function handleSessionIdle(args: {
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
: false
if (hasRunningBgTasks) {
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "."
type PromptCall = {
sessionID: string
text: string
}
type PromptInput = {
path: { id: string }
body: { parts: Array<{ text: string }> }
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function createPluginInput(promptCalls: PromptCall[]): Parameters<typeof createTodoContinuationEnforcer>[0] {
return {
directory: "/tmp/opencode-overload-continuation-test",
client: {
session: {
todo: async () => ({
data: [
{ id: "1", content: "Keep working", status: "pending", priority: "high" },
],
}),
messages: async () => ({ data: [] }),
promptAsync: async (input: PromptInput) => {
promptCalls.push({
sessionID: input.path.id,
text: input.body.parts[0]?.text ?? "",
})
return {}
},
},
tui: {
showToast: async () => ({}),
},
},
} as Parameters<typeof createTodoContinuationEnforcer>[0]
}
describe("todo-continuation-enforcer OpenCode overload errors", () => {
test(
"#given countdown is armed #when OpenCode reports server_is_overloaded #then continuation still injects",
async () => {
// given
const sessionID = "main-opencode-overload"
const promptCalls: PromptCall[] = []
_resetForTesting()
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createPluginInput(promptCalls))
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: {
type: "error",
sequence_number: 2,
error: {
type: "service_unavailable_error",
code: "server_is_overloaded",
message: "Our servers are currently overloaded. Please try again later.",
param: null,
},
},
},
},
})
await wait(2500)
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.sessionID).toBe(sessionID)
expect(promptCalls[0]?.text).toContain("TODO CONTINUATION")
},
{ timeout: 10000 },
)
})
@@ -40,9 +40,9 @@ function createBackgroundManager(tasks: BackgroundTask[]) {
function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "bg-1",
parentSessionID: "main-1",
parentMessageID: "msg-1",
sessionId: "bg-1",
parentSessionId: "main-1",
parentMessageId: "msg-1",
description: "unstable task",
prompt: "run work",
agent: "test-agent",
@@ -63,6 +63,41 @@ describe("unstable-agent-babysitter hook", () => {
_resetForTesting()
})
test("settles idle before injecting a reminder", async () => {
// #given
setMainSession("main-1")
const promptCalls: Array<{ input: unknown }> = []
const ctx = createMockPluginInput({
messagesBySession: {
"main-1": [
{ info: { agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-4" } } },
],
"bg-1": [
{ info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] },
],
},
promptCalls,
})
const backgroundManager = createBackgroundManager([createTask()])
const hook = createUnstableAgentBabysitterHook(ctx, {
backgroundManager,
config: { timeout_ms: 120000 },
idleSettleMs: 50,
})
// #when
const startedAt = Date.now()
const eventPromise = hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
await Promise.resolve()
// #then
expect(promptCalls.length).toBe(0)
await eventPromise
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45)
expect(promptCalls.length).toBe(1)
})
test("fires reminder for hung gemini task", async () => {
// #given
setMainSession("main-1")
@@ -11,6 +11,7 @@ import {
isUnstableTask,
THINKING_SUMMARY_MAX_CHARS,
} from "./task-message-analyzer"
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
const HOOK_NAME = "unstable-agent-babysitter"
const DEFAULT_TIMEOUT_MS = 120000
@@ -54,6 +55,7 @@ type BabysitterContext = {
type BabysitterOptions = {
backgroundManager: Pick<BackgroundManager, "getTasksByParentSession">
config?: BabysittingConfig
idleSettleMs?: number
}
@@ -212,6 +214,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
? { providerID: model.providerID, modelID: model.modelID }
: undefined
const launchVariant = model?.variant
await settleAfterSessionIdle(options.idleSettleMs)
await ctx.client.session.promptAsync({
path: { id: mainSessionID },
+10 -5
View File
@@ -3,9 +3,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
import { log } from "../../shared"
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
import { evictLeastRecentlyUsedSession, touchSession, trimSessionReadSet } from "./session-read-permissions"
export type GuardArgs = {
filePath?: string
@@ -16,7 +14,11 @@ export type GuardArgs = {
const MAX_TRACKED_SESSIONS = 256
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
type WriteExistingFileGuardOptions = {
maxTrackedSessions?: number
maxTrackedPathsPerSession?: number
}
export function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -73,9 +75,11 @@ export function isOverwriteEnabled(value: boolean | string | undefined): boolean
return false
}
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: WriteExistingFileGuardOptions): Hooks {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const maxTrackedSessions = options?.maxTrackedSessions ?? MAX_TRACKED_SESSIONS
const maxTrackedPathsPerSession = options?.maxTrackedPathsPerSession ?? MAX_TRACKED_PATHS_PER_SESSION
let canonicalSessionRoot: string | undefined
function getCanonicalSessionRoot(): string {
@@ -95,7 +99,8 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
readPermissionsBySession,
sessionLastAccess,
getCanonicalSessionRoot,
maxTrackedSessions: MAX_TRACKED_SESSIONS,
maxTrackedSessions,
maxTrackedPathsPerSession,
})
},
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
import { tmpdir } from "node:os"
import { dirname, join, resolve } from "node:path"
import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
import { createWriteExistingFileGuardHook } from "./index"
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
@@ -56,7 +55,7 @@ describe("createWriteExistingFileGuardHook", () => {
}
const emitSessionDeleted = async (sessionID: string): Promise<void> => {
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } } as never)
}
beforeEach(() => {
@@ -432,6 +431,11 @@ describe("createWriteExistingFileGuardHook", () => {
test("#given session reads beyond path cap #when writing oldest and newest #then only newest is authorized", async () => {
const sessionID = "ses_path_cap"
const maxTrackedPathsPerSession = 4
hook = createWriteExistingFileGuardHook(
{ directory: tempDir } as never,
{ maxTrackedPathsPerSession },
)
const oldestFile = createFile("path-cap/0.txt")
let newestFile = oldestFile
@@ -441,7 +445,7 @@ describe("createWriteExistingFileGuardHook", () => {
outputArgs: { filePath: oldestFile },
})
for (let index = 1; index <= MAX_TRACKED_PATHS_PER_SESSION; index += 1) {
for (let index = 1; index <= maxTrackedPathsPerSession; index += 1) {
newestFile = createFile(`path-cap/${index}.txt`)
await invoke({
tool: "read",
@@ -5,37 +5,35 @@ import { join } from "node:path"
const realFs = await import("node:fs")
const existsSyncMock = mock(realFs.existsSync)
const realpathNativeMock = mock(realFs.realpathSync.native)
mock.module("fs", () => ({
...realFs,
existsSync: existsSyncMock,
realpathSync: {
...realFs.realpathSync,
native: realpathNativeMock,
},
}))
const { createWriteExistingFileGuardHook } = await import("./index")
describe("createWriteExistingFileGuardHook", () => {
let tempDir = ""
let existsSyncMock: ReturnType<typeof mock<typeof realFs.existsSync>>
let realpathNativeMock: ReturnType<typeof mock<typeof realFs.realpathSync.native>>
beforeEach(() => {
// given
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-lazy-"))
mkdirSync(tempDir, { recursive: true })
existsSyncMock.mockClear()
realpathNativeMock.mockClear()
})
afterEach(() => {
mock.restore()
rmSync(tempDir, { recursive: true, force: true })
})
test("#given hook factory #when created #then defers fs canonical path calls until first tool invocation", async () => {
// given
existsSyncMock = mock(realFs.existsSync)
realpathNativeMock = mock(realFs.realpathSync.native)
mock.module("fs", () => ({
...realFs,
existsSync: existsSyncMock,
realpathSync: {
...realFs.realpathSync,
native: realpathNativeMock,
},
}))
const { createWriteExistingFileGuardHook } = await import(`./hook?test=${crypto.randomUUID()}`)
const existingFile = join(tempDir, "existing.txt")
writeFileSync(existingFile, "content")
@@ -44,6 +44,7 @@ function registerReadPermission(params: {
readPermissionsBySession: Map<string, Set<string>>
sessionLastAccess: Map<string, number>
maxTrackedSessions: number
maxTrackedPathsPerSession: number
}): void {
const readSet = ensureSessionReadSet(params)
if (readSet.has(params.canonicalPath)) {
@@ -51,7 +52,7 @@ function registerReadPermission(params: {
}
readSet.add(params.canonicalPath)
trimSessionReadSet(readSet, MAX_TRACKED_PATHS_PER_SESSION)
trimSessionReadSet(readSet, params.maxTrackedPathsPerSession)
}
function consumeReadPermission(params: {
@@ -92,8 +93,18 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
sessionLastAccess: Map<string, number>
getCanonicalSessionRoot: () => string
maxTrackedSessions: number
maxTrackedPathsPerSession?: number
}): Promise<void> {
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, getCanonicalSessionRoot, maxTrackedSessions } = params
const {
ctx,
input,
output,
readPermissionsBySession,
sessionLastAccess,
getCanonicalSessionRoot,
maxTrackedSessions,
maxTrackedPathsPerSession = MAX_TRACKED_PATHS_PER_SESSION,
} = params
const toolName = input.tool?.toLowerCase()
if (toolName !== "write" && toolName !== "read") {
return
@@ -124,6 +135,7 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
readPermissionsBySession,
sessionLastAccess,
maxTrackedSessions,
maxTrackedPathsPerSession,
})
return
}