Compare commits

...

70 Commits

Author SHA1 Message Date
YeonGyu-Kim
96d27ff56b Merge pull request #2134 from code-yeongyu/fix/issue-2064-config-overwrite
fix(config): preserve existing user config when writing new defaults
2026-02-26 21:06:17 +09:00
YeonGyu-Kim
017c18c1b3 Merge pull request #2138 from code-yeongyu/fix/issue-2062-compaction-timeout
fix(compaction): add timeout and cleanup to prevent indefinite hangs on rate limit
2026-02-26 21:06:05 +09:00
YeonGyu-Kim
fb194fc944 Merge pull request #2147 from code-yeongyu/fix/issue-2117-preserve-formatter-config
fix(config): preserve formatter config from opencode settings
2026-02-26 21:05:46 +09:00
YeonGyu-Kim
10c25d1d47 Merge pull request #2144 from code-yeongyu/fix/issue-2087-look-at-hang
fix(look-at): add timeout to sync model retry to prevent process hang
2026-02-26 21:05:43 +09:00
YeonGyu-Kim
86fcade9a4 Merge pull request #2142 from code-yeongyu/fix/issue-1922-retain-agent-keys
fix(agents): retain original agent keys in remapAgentKeysToDisplayNames to prevent crash
2026-02-26 21:04:32 +09:00
YeonGyu-Kim
5bc3a9e0db Merge pull request #2137 from code-yeongyu/fix/issue-2051-diff-context-limit
fix(hashline-edit): limit diff context to 3 lines to prevent oversized hunks
2026-02-26 21:04:29 +09:00
YeonGyu-Kim
810ebec1cd Merge pull request #2136 from code-yeongyu/fix/issue-2044-atlas-task-tool
fix(atlas): allow task and call_omo_agent tools for subagent dispatch
2026-02-26 21:04:26 +09:00
YeonGyu-Kim
8f7ed2988a Merge pull request #2135 from code-yeongyu/fix/issue-2115-background-output-block
fix(background-task): make background_output block=true actually wait for task completion
2026-02-26 21:04:23 +09:00
YeonGyu-Kim
7ff8352a0a fix(config): preserve formatter config from opencode settings
Closes #2117
2026-02-26 21:01:31 +09:00
YeonGyu-Kim
1e060e9028 fix(look-at): add timeout to sync model retry to prevent process hang
Closes #2087
2026-02-26 20:59:53 +09:00
YeonGyu-Kim
df02c73a54 fix(agents): retain original agent keys in remapAgentKeysToDisplayNames to prevent crash
Closes #1922
2026-02-26 20:58:47 +09:00
YeonGyu-Kim
52658ac1c4 fix(config): preserve existing user config when writing new defaults
Closes #2064
2026-02-26 20:58:07 +09:00
YeonGyu-Kim
fab820e919 fix(compaction): add timeout and ensure cleanup to prevent indefinite hangs on rate limit
Closes #2062
2026-02-26 20:58:01 +09:00
YeonGyu-Kim
6f54404a51 fix(hephaestus): add explicit auto-commit instructions to agent prompt
Closes #2102
2026-02-26 20:57:58 +09:00
YeonGyu-Kim
a3169c9287 fix(hashline-edit): limit diff context to 3 lines to prevent oversized hunks
Closes #2051
2026-02-26 20:57:47 +09:00
YeonGyu-Kim
0639ce8df7 fix(atlas): allow task and call_omo_agent tools for subagent dispatch
Closes #2044
2026-02-26 20:55:20 +09:00
YeonGyu-Kim
685b8023dd fix(background-task): make background_output block=true actually wait for task completion
Closes #2115
2026-02-26 20:55:11 +09:00
YeonGyu-Kim
c505989ad4 Merge pull request #2095 from code-yeongyu/fix/issue-1934-exit-code-130-timeout
fix(run): add event watchdog and secondary timeout to prevent infinite hang in CI
2026-02-26 20:48:46 +09:00
YeonGyu-Kim
088984a8d4 fix: remove Current date from env context since OpenCode already provides it
date is already injected by OpenCode's system.ts. omo-env now contains only
Timezone and Locale, which are stable across requests and never break cache.
2026-02-26 20:22:17 +09:00
YeonGyu-Kim
0b69a6c507 fix(atlas): replace permanent failure lockout with 5-minute backoff
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:20:45 +09:00
YeonGyu-Kim
5fe1640f2a fix(boulder): count indented checkboxes in plan progress
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:20:28 +09:00
YeonGyu-Kim
ad01f60e99 fix: remove seconds-precision time from env context to stop breaking token cache
Current time with HH:MM:SS changed every second, invalidating the prompt cache
on every request. Date-level precision is sufficient; timezone and locale are
stable. Removes Current time field entirely from createEnvContext output.
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
87d6b2b519 feat(agents): simplify GPT detection to name-based check, add hephaestus providers (venice uses gpt-5.3-codex)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
b7b6721796 refactor(think-mode): migrate hook from chat.params to chat.message and remove thinking config injection
Drop provider-specific thinking config injection (THINKING_CONFIGS, getThinkingConfig,
resolveProvider) and instead rely on the provider to handle thinking based on the variant field.
Hook now fires on chat.message using model from input rather than from the message object.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
0c59d2dbe7 refactor(ultrawork): remove thinking config injection from model override
Delegate thinking config control to the provider layer rather than
injecting it manually in ultrawork model override.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
52d366e866 feat(start-work): update template with --worktree flag documentation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
9cd6fc6135 feat(atlas): inject worktree_path into boulder continuation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
f872f5e171 feat(start-work): add --worktree flag support in hook
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
f500fb0286 feat(start-work): add --worktree flag parsing from user request
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
9a94e12065 feat(start-work): add worktree path detection
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:08:44 +09:00
YeonGyu-Kim
808a50d808 feat(boulder-state): add worktree_path field to BoulderState
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 20:08:44 +09:00
github-actions[bot]
a263188abd @maou-shonen has signed the CLA in code-yeongyu/oh-my-opencode#2131 2026-02-26 09:50:58 +00:00
github-actions[bot]
155ed5248d @imwxc has signed the CLA in code-yeongyu/oh-my-opencode#2129 2026-02-26 09:22:45 +00:00
github-actions[bot]
ed5a2fe393 @spacecowboy0416 has signed the CLA in code-yeongyu/oh-my-opencode#2126 2026-02-26 06:05:38 +00:00
github-actions[bot]
cd504a2694 @zhzy0077 has signed the CLA in code-yeongyu/oh-my-opencode#2125 2026-02-26 04:45:36 +00:00
github-actions[bot]
e556c4a5c8 @SupenBysz has signed the CLA in code-yeongyu/oh-my-opencode#2119 2026-02-25 22:01:04 +00:00
github-actions[bot]
be7f408049 @east-shine has signed the CLA in code-yeongyu/oh-my-opencode#2113 2026-02-25 08:19:44 +00:00
YeonGyu-Kim
2ab40124ee Merge pull request #2111 from code-yeongyu/fix/background-notification-idle-queue
fix(background-agent): queue notifications for idle parent sessions
2026-02-25 16:30:09 +09:00
YeonGyu-Kim
840c612be8 fix(background-agent): queue notifications for idle parent sessions
When a background task completes and the parent session is waiting for
user input, promptAsync() fails with an aborted error. Previously the
notification was silently dropped — lost forever.

Fix: queue the notification text in-memory on the BackgroundManager
when promptAsync fails with an aborted/idle error. On the user's next
message to that session, the queued notifications are injected into the
chat context before the agent sees the message.

- BackgroundManager: add pendingNotifications map + queuePendingNotification()
  and injectPendingNotificationsIntoChatMessage() methods
- background-notification hook: add chat.message handler that calls injection
- chat-message.ts: wire backgroundNotificationHook.chat.message into the
  message processing chain
- Add tests covering queue-on-abort and next-message delivery
2026-02-25 16:26:31 +09:00
YeonGyu-Kim
235bb58779 Merge pull request #2110 from code-yeongyu/fix/boulder-continuation-agent-check
fix(atlas): boulder continuation deadlock after /start-work + 30s→5s cooldown
2026-02-25 16:22:58 +09:00
YeonGyu-Kim
ace1790c72 test(atlas): update agent check tests to match fixed behavior
- Rename test to 'should inject when last agent is sisyphus and boulder targets atlas
  explicitly' and flip expectation to toHaveBeenCalled() - the old assertion was
  testing the buggy deadlock behavior
- Add 'should not inject when last agent is non-sisyphus and does not match boulder
  agent' to verify hephaestus (unrelated agents) are still correctly skipped
2026-02-25 16:18:59 +09:00
YeonGyu-Kim
31eb7f5d28 Merge pull request #2108 from code-yeongyu/fix/issue-2100-reset-strategy-race-condition
fix(ralph-loop): fix race condition in --strategy=reset
2026-02-25 16:16:53 +09:00
YeonGyu-Kim
6b5622c62f Merge pull request #2107 from code-yeongyu/fix/issue-2054-hephaestus-model-opt-out
fix(no-hephaestus-non-gpt): add opt-out for model enforcement
2026-02-25 16:16:50 +09:00
YeonGyu-Kim
cf0d157673 Merge pull request #2106 from code-yeongyu/fix/issue-2049-ultrawork-thinking-config
fix(ultrawork-model-override): fix thinking config when upgrading variant
2026-02-25 16:16:48 +09:00
YeonGyu-Kim
adf62267aa fix(agents/utils.test): correct hephaestus github-copilot provider test expectation
The test 'hephaestus is created when github-copilot provider is connected'
had incorrect expectation. github-copilot does not provide gpt-5.3-codex,
so hephaestus should NOT be created when only github-copilot is connected.

This test was causing CI flakiness due to incorrect assertion and
missing readConnectedProvidersCache mock (state pollution between tests).

Also adds cacheSpy mock for proper isolation.
2026-02-25 14:17:36 +09:00
YeonGyu-Kim
9f64e2a869 fix(agents/utils.test): correct hephaestus github-copilot provider test expectation
The test 'hephaestus is created when github-copilot provider is connected'
had incorrect expectation. github-copilot does not provide gpt-5.3-codex,
so hephaestus should NOT be created when only github-copilot is connected.

This test was causing CI flakiness due to incorrect assertion and
missing readConnectedProvidersCache mock (state pollution between tests).

Also adds cacheSpy mock for proper isolation.
2026-02-25 14:17:34 +09:00
YeonGyu-Kim
e00f461eb1 fix(agents/utils.test): correct hephaestus github-copilot provider test expectation
The test 'hephaestus is created when github-copilot provider is connected'
had incorrect expectation. github-copilot does not provide gpt-5.3-codex,
so hephaestus should NOT be created when only github-copilot is connected.

This test was causing CI flakiness due to incorrect assertion and
missing readConnectedProvidersCache mock (state pollution between tests).

Also adds cacheSpy mock for proper isolation.
2026-02-25 14:17:33 +09:00
YeonGyu-Kim
da6c54ed93 Revert "fix(model-requirements): add github-copilot to hephaestus requiresProvider"
This reverts commit 2acf6fa124.
2026-02-25 14:16:26 +09:00
YeonGyu-Kim
1d99fdf843 Revert "fix(model-requirements): add github-copilot to hephaestus requiresProvider"
This reverts commit 7e5872935a.
2026-02-25 14:16:26 +09:00
YeonGyu-Kim
de70c3a332 Revert "fix(model-requirements): add github-copilot to hephaestus requiresProvider"
This reverts commit 6458fe9fce.
2026-02-25 14:16:25 +09:00
YeonGyu-Kim
5e07dfe19b fix(atlas): allow Sisyphus as last agent when boulder targets atlas explicitly
The boulder continuation in event-handler.ts skipped injection whenever
the last agent was 'sisyphus' and the boulder state had agent='atlas'
set explicitly. The allowSisyphusWhenDefaultAtlas guard required
boulderAgentWasNotExplicitlySet=true, but start-work-hook.ts always
calls createBoulderState(..., 'atlas') which sets the agent explicitly.

This created a chicken-and-egg deadlock: boulder continuation needs
atlas to be the last agent, but the continuation itself is what switches
to atlas. With /start-work, the first iteration was always blocked.

Fix: drop the boulderAgentWasNotExplicitlySet constraint so Sisyphus is
always allowed when the boulder targets atlas (whether explicit or default).

Also reduce todo-continuation-enforcer CONTINUATION_COOLDOWN_MS from
30s to 5s to match atlas hook cooldown and recover interruptions faster.
2026-02-25 14:16:17 +09:00
YeonGyu-Kim
2acf6fa124 fix(model-requirements): add github-copilot to hephaestus requiresProvider
Hephaestus requires GPT models, which can be provided by github-copilot.
The requiresProvider list was missing github-copilot, causing hephaestus
to not be created when github-copilot was the only GPT provider connected.

This also fixes a flaky CI test that documented this expected behavior.
2026-02-25 14:12:52 +09:00
YeonGyu-Kim
7e5872935a fix(model-requirements): add github-copilot to hephaestus requiresProvider
Hephaestus requires GPT models, which can be provided by github-copilot.
The requiresProvider list was missing github-copilot, causing hephaestus
to not be created when github-copilot was the only GPT provider connected.

This also fixes a flaky CI test that documented this expected behavior.
2026-02-25 14:12:45 +09:00
YeonGyu-Kim
6458fe9fce fix(model-requirements): add github-copilot to hephaestus requiresProvider
Hephaestus requires GPT models, which can be provided by github-copilot.
The requiresProvider list was missing github-copilot, causing hephaestus
to not be created when github-copilot was the only GPT provider connected.

This also fixes a flaky CI test that documented this expected behavior.
2026-02-25 14:12:43 +09:00
YeonGyu-Kim
640d9fb773 Merge pull request #2109 from code-yeongyu/fix/issue-1815-1733-prompt-token-count
fix(delegate-task): prevent prompt context overflow with token counting
2026-02-25 14:09:17 +09:00
YeonGyu-Kim
fc1b6e4917 fix(delegate-task): add token counting and truncation to prevent context overflow
Fixes #1815, #1733
2026-02-25 14:03:47 +09:00
YeonGyu-Kim
a0e57c13c3 fix(ralph-loop): prevent race condition in reset strategy between session ID update and TUI switch
Fixes #2100
2026-02-25 14:01:27 +09:00
YeonGyu-Kim
997db0e05b fix(no-hephaestus-non-gpt): add allow_non_gpt_model config opt-out
Fixes #2054
2026-02-25 14:01:26 +09:00
YeonGyu-Kim
565ab8c13a fix(ultrawork-model-override): set thinking config object instead of variant string
Fixes #2049
2026-02-25 14:01:03 +09:00
github-actions[bot]
15519b9580 @Pantoria has signed the CLA in code-yeongyu/oh-my-opencode#1983 2026-02-24 17:12:43 +00:00
YeonGyu-Kim
b174513725 Merge pull request #2099 from code-yeongyu/fix/gpt-5-3-codex-github-copilot-provider
fix: remove github-copilot from gpt-5.3-codex provider list
2026-02-25 00:33:27 +09:00
YeonGyu-Kim
465f5e13a8 fix: remove github-copilot from gpt-5.3-codex provider list
gpt-5.3-codex is not available on GitHub Copilot. The fallback chains
incorrectly listed github-copilot as a valid provider for this model,
causing the doctor to report 'configured model github-copilot/gpt-5.3-codex
is not valid' for Hephaestus agent.

Affected agents: hephaestus (requiresProvider + fallbackChain)
Affected categories: ultrabrain, deep, unspecified-low

Copilot users can still use Hephaestus via openai or opencode providers.

Fixes #2047
2026-02-25 00:29:00 +09:00
YeonGyu-Kim
73453a7191 docs(agents): update hook counts 44→46, add hashline-edit documentation
- Update root AGENTS.md: hook count 44→46, commit fcb90d92, generated 2026-02-24
- Update src/AGENTS.md: core hooks 35→37, session hooks 21→23
- Update src/hooks/AGENTS.md: 46 hooks total, add modelFallback/noSisyphusGpt/noHephaestusNonGpt/runtimeFallback, jsonErrorRecovery moved to tool-guard (tier 2)
- Create src/tools/hashline-edit/AGENTS.md (93 lines): documents three-op model, LINE#ID format, execution pipeline
- Refresh timestamps: 2026-02-21→2026-02-24 on 28 files
- Update plugin/AGENTS.md hook composition counts

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-25 00:02:05 +09:00
YeonGyu-Kim
fcb90d92a4 refactor(hashline-edit): replace custom diff with diff library
🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-24 22:30:06 +09:00
github-actions[bot]
ddf426c4b3 @PHP-Expert has signed the CLA in code-yeongyu/oh-my-opencode#2098 2026-02-24 13:27:28 +00:00
sisyphus-dev-ai
a882e6f027 chore: changes by sisyphus-dev-ai 2026-02-24 13:21:54 +00:00
YeonGyu-Kim
dab2f90051 test(run): make completion metadata timing assertion deterministic
Avoid Date.now call-order flakiness by pinning the mocked current time and setting the message start time explicitly in the test setup.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 21:43:52 +09:00
YeonGyu-Kim
99f4c7e222 fix(hooks): stabilize session notification checks in parallel tests
Use sender-module indirection and an optional main-session filter guard to keep notification assertions deterministic across concurrent test execution.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 21:43:47 +09:00
CloudWaddie
54d0dcde48 fix: address code review feedback on PR #1988
- Fix operator precedence bug in hasActiveWork boolean expression
- Reuse getMainSessionStatus result from watchdog to avoid duplicate API calls
- Add flag to only check secondary timeout once to avoid unnecessary API traffic
2026-02-24 21:32:07 +09:00
CloudWaddie
159ade05cc fix(run): add event watchdog and secondary timeout for hasReceivedMeaningfulWork
Implements fixes from issue #1880 and #1934 to prevent exit code 130 timeout in CI environments:

- Add lastEventTimestamp to EventState for tracking when events were last received
- Add event watchdog: if no events for 30s, verify session status via direct API call
- Add secondary timeout: after 60s without meaningful work events, check for active children/todos and assume work is in progress

This prevents the poll loop from waiting for full 600s timeout when:
1. Event stream drops silently (common in CI with network instability)
2. Main session delegates to children without producing meaningful work on main session
2026-02-24 21:32:07 +09:00
119 changed files with 2963 additions and 1439 deletions

61
.issue-comment-2064.md Normal file
View File

@@ -0,0 +1,61 @@
[sisyphus-bot]
## Confirmed Bug
We have identified the root cause of this issue. The bug is in the config writing logic during installation.
### Root Cause
**File:** `src/cli/config-manager/write-omo-config.ts` (line 46)
```typescript
const merged = deepMergeRecord(existing, newConfig)
```
When a user runs `oh-my-opencode install` (even just to update settings), the installer:
1. Reads the existing config (with user's custom model settings)
2. Generates a **new** config based on detected provider availability
3. Calls `deepMergeRecord(existing, newConfig)`
4. Writes the result back
**The problem:** `deepMergeRecord` overwrites values in `existing` with values from `newConfig`. This means your custom `"model": "openai/gpt-5.2-codex"` gets overwritten by the generated default model (e.g., `anthropic/claude-opus-4-6` if Claude is available).
### Why This Happens
Looking at `deepMergeRecord` (line 24-25):
```typescript
} else if (sourceValue !== undefined) {
result[key] = sourceValue as TTarget[keyof TTarget]
}
```
Any defined value in the source (generated config) overwrites the target (user's config).
### Fix Approach
The merge direction should be reversed to respect user overrides:
```typescript
const merged = deepMergeRecord(newConfig, existing)
```
This ensures:
- User's explicit settings take precedence
- Only new/undefined keys get populated from generated defaults
- Custom model choices are preserved
### SEVERITY: HIGH
- **Impact:** User configuration is overwritten without consent
- **Affected Files:**
- `src/cli/config-manager/write-omo-config.ts`
- `src/cli/config-manager/deep-merge-record.ts`
- **Trigger:** Running `oh-my-opencode install` (even for unrelated updates)
### Workaround (Until Fix)
Backup your config before running install:
```bash
cp ~/.config/opencode/oh-my-opencode.jsonc ~/.config/opencode/oh-my-opencode.jsonc.backup
```
We're working on a fix that will preserve your explicit model configurations.

View File

@@ -1,10 +1,10 @@
# oh-my-opencode — OpenCode Plugin # oh-my-opencode — OpenCode Plugin
**Generated:** 2026-02-21 | **Commit:** 86e3c7d1 | **Branch:** dev **Generated:** 2026-02-24 | **Commit:** fcb90d92 | **Branch:** dev
## OVERVIEW ## OVERVIEW
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 44 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC. OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC.
## STRUCTURE ## STRUCTURE
@@ -14,14 +14,14 @@ oh-my-opencode/
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface │ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4) │ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior) │ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
│ ├── hooks/ # 44 hooks across 39 directories + 6 standalone files | `hooks/`                # 46 hooks across 39 directories + 6 standalone files
│ ├── tools/ # 26 tools across 15 directories │ ├── tools/ # 26 tools across 15 directories
│ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.) │ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
│ ├── shared/ # 100+ utility files in 13 categories │ ├── shared/ # 100+ utility files in 13 categories
│ ├── config/ # Zod v4 schema system (22+ files) │ ├── config/ # Zod v4 schema system (22+ files)
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js) │ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app) │ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
│ ├── plugin/ # 8 OpenCode hook handlers + 44 hook composition │ ├── plugin/ # 8 OpenCode hook handlers + 46 hook composition
│ └── plugin-handlers/ # 6-phase config loading pipeline │ └── plugin-handlers/ # 6-phase config loading pipeline
├── packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries ├── packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries
└── local-ignore/ # Dev-only test fixtures └── local-ignore/ # Dev-only test fixtures
@@ -34,7 +34,7 @@ OhMyOpenCodePlugin(ctx)
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate ├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler ├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools) ├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
├─→ createHooks() # 3-tier: Core(35) + Continuation(7) + Skill(2) = 44 hooks ├─→ createHooks() # 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface └─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
``` ```
@@ -87,7 +87,7 @@ Fields: agents (14 overridable, 21 fields each), categories (8 built-in + custom
- **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes) - **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes)
- **Factory pattern**: `createXXX()` for all tools, hooks, agents - **Factory pattern**: `createXXX()` for all tools, hooks, agents
- **Hook tiers**: Session (22) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2) - **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2)
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all` - **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
- **Model resolution**: 3-step: override → category-default → provider-fallback → system-default - **Model resolution**: 3-step: override → category-default → provider-fallback → system-default
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys - **Config format**: JSONC with comments, Zod v4 validation, snake_case keys

View File

@@ -960,6 +960,9 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"allow_non_gpt_model": {
"type": "boolean"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -3248,6 +3251,11 @@
"prompt_append": { "prompt_append": {
"type": "string" "type": "string"
}, },
"max_prompt_tokens": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"is_unstable_agent": { "is_unstable_agent": {
"type": "boolean" "type": "boolean"
}, },

View File

@@ -14,6 +14,7 @@
"@opencode-ai/sdk": "^1.1.19", "@opencode-ai/sdk": "^1.1.19",
"commander": "^14.0.2", "commander": "^14.0.2",
"detect-libc": "^2.0.0", "detect-libc": "^2.0.0",
"diff": "^8.0.3",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -28,13 +29,13 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.1", "oh-my-opencode-darwin-arm64": "3.8.5",
"oh-my-opencode-darwin-x64": "3.8.1", "oh-my-opencode-darwin-x64": "3.8.5",
"oh-my-opencode-linux-arm64": "3.8.1", "oh-my-opencode-linux-arm64": "3.8.5",
"oh-my-opencode-linux-arm64-musl": "3.8.1", "oh-my-opencode-linux-arm64-musl": "3.8.5",
"oh-my-opencode-linux-x64": "3.8.1", "oh-my-opencode-linux-x64": "3.8.5",
"oh-my-opencode-linux-x64-musl": "3.8.1", "oh-my-opencode-linux-x64-musl": "3.8.5",
"oh-my-opencode-windows-x64": "3.8.1", "oh-my-opencode-windows-x64": "3.8.5",
}, },
}, },
}, },
@@ -138,6 +139,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -228,19 +231,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vbtS0WUFOZpufKzlX2G83fIDry3rpiXej8zNuXNCkx7hF34rK04rj0zeBH9dL+kdNV0Ys0Wl1rR1Mjto28UcAw=="], "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bbLu1We9NNhYAVp9Q/FK8dYFlYLp2PKfvdBCr+O6QjNRixdjp8Ru4RK7i9mKg0ybYBUzzCcbbC2Cc1o8orkhBA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gLz6dLNg9hr7roqBjaqlxta6+XYCs032/FiE0CiwypIBtYOq5EAgDVJ95JY5DQ2M+3Un028d50yMfwsfNfGlSw=="], "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N9GcmzYgL87UybSaMGiHc5lwT5Mxg1tyB502el5syouN39wfeUYoj37SonENrMUTiEfn75Lwv/5cSLCesSubpA=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-teAIuHlR5xOAoUmA+e0bGzy3ikgIr+nCdyOPwHYm8jIp0aBUWAqbcdoQLeNTgenWpoM8vhHk+2xh4WcCeQzjEA=="], "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ki4a7s1DD5z5wEKmzcchqAKOIpw0LsBvyF8ieqNLS5Xl8PWE0gAZ7rqjlXC54NTubpexVH6lO2yenFJsk2Zk9A=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VzBEq1H5dllEloouIoLdbw1icNUW99qmvErFrNj66mX42DNXK+f1zTtvBG8U6eeFfUBRRJoUjdCsvO65f8BkFA=="], "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9+6hU3z503fBzuV0VjxIkTKFElbKacHijFcdKAussG6gPFLWmCRWtdowzEDwUfAoIsoHHH7FBwvh5waGp/ZksA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8hDcb8s+wdQpQObSmiyaaTV0P/js2Bs9Lu+HmzrkKjuMLXXj/Gk7K0kKWMoEnMbMGfj86GfBHHIWmu9juI/SjA=="], "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DmnMK/PgvdcCYL+OQE5iZWgi/vmjm0sIPQVQgSUbWn3izcUF7C5DtlxqaU2cKxNZwrhDTlJdLWxmJqgLmLqd9A=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-idyH5bdYn7wrLkIkYr83omN83E2BjA/9DUHCX2we8VXbhDVbBgmMpUg8B8nKnd5NK/SyLHgRs5QqQJw8XBC0cQ=="], "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jhCNStljsyapVq9X7PaHSOcWxxEA4BUcIibvoPs/xc7fVP8D47p651LzIRsM6STn6Bx684mlYbxxX1P/0QPKNg=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-O30L1PUF9aq1vSOyadcXQOLnDFSTvYn6cGd5huh0LAK/us0hGezoahtXegMdFtDXPIIREJlkRQhyJiafza7YgA=="], "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lcPBp9NCNQ6TnqzsN9p/K+xKwOzBoIPw7HncxmrXSberZ3uHy0K9uNraQ7fqnXIKWqQiK4kSwWfSHpmhbaHiNg=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -60,6 +60,7 @@
"@opencode-ai/sdk": "^1.1.19", "@opencode-ai/sdk": "^1.1.19",
"commander": "^14.0.2", "commander": "^14.0.2",
"detect-libc": "^2.0.0", "detect-libc": "^2.0.0",
"diff": "^8.0.3",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",

View File

@@ -1703,6 +1703,70 @@
"created_at": "2026-02-23T19:27:59Z", "created_at": "2026-02-23T19:27:59Z",
"repoId": 1108837393, "repoId": 1108837393,
"pullRequestNo": 2080 "pullRequestNo": 2080
},
{
"name": "PHP-Expert",
"id": 12047666,
"comment_id": 3951828700,
"created_at": "2026-02-24T13:27:18Z",
"repoId": 1108837393,
"pullRequestNo": 2098
},
{
"name": "Pantoria",
"id": 37699442,
"comment_id": 3953543578,
"created_at": "2026-02-24T17:12:31Z",
"repoId": 1108837393,
"pullRequestNo": 1983
},
{
"name": "east-shine",
"id": 20237288,
"comment_id": 3957576758,
"created_at": "2026-02-25T08:19:34Z",
"repoId": 1108837393,
"pullRequestNo": 2113
},
{
"name": "SupenBysz",
"id": 3314033,
"comment_id": 3962352704,
"created_at": "2026-02-25T22:00:54Z",
"repoId": 1108837393,
"pullRequestNo": 2119
},
{
"name": "zhzy0077",
"id": 8717471,
"comment_id": 3964015975,
"created_at": "2026-02-26T04:45:23Z",
"repoId": 1108837393,
"pullRequestNo": 2125
},
{
"name": "spacecowboy0416",
"id": 239068998,
"comment_id": 3964320737,
"created_at": "2026-02-26T06:05:27Z",
"repoId": 1108837393,
"pullRequestNo": 2126
},
{
"name": "imwxc",
"id": 49653609,
"comment_id": 3965127447,
"created_at": "2026-02-26T09:00:16Z",
"repoId": 1108837393,
"pullRequestNo": 2129
},
{
"name": "maou-shonen",
"id": 22576780,
"comment_id": 3965445132,
"created_at": "2026-02-26T09:50:46Z",
"repoId": 1108837393,
"pullRequestNo": 2131
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
# src/ — Plugin Source # src/ — Plugin Source
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW
@@ -14,7 +14,7 @@ Root source directory. Entry point `index.ts` orchestrates 4-step initialization
| `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation | | `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation |
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler | | `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry | | `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry |
| `create-hooks.ts` | 3-tier hook composition: Core(35) + Continuation(7) + Skill(2) | | `create-hooks.ts` | 3-tier hook composition: Core(37) + Continuation(7) + Skill(2) |
| `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface | | `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface |
## CONFIG LOADING ## CONFIG LOADING
@@ -32,9 +32,9 @@ loadPluginConfig(directory, ctx)
``` ```
createHooks() createHooks()
├─→ createCoreHooks() # 35 hooks ├─→ createCoreHooks() # 37 hooks
│ ├─ createSessionHooks() # 21: contextWindowMonitor, thinkMode, ralphLoop, sessionRecovery, jsonErrorRecovery, sisyphusGptHephaestusReminder, anthropicEffort... │ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort...
│ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, hashlineEditDiffEnhancer... │ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer...
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator │ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard... ├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard...
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand └─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand

View File

@@ -1,6 +1,6 @@
# src/agents/ — 11 Agent Definitions # src/agents/ — 11 Agent Definitions
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -17,7 +17,6 @@ import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynam
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder" import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema" import type { CategoryConfig } from "../../config/schema"
import { mergeCategories } from "../../shared/merge-categories" import { mergeCategories } from "../../shared/merge-categories"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default" import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt" import { getGptAtlasPrompt } from "./gpt"
@@ -100,11 +99,6 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
} }
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig { export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
])
const baseConfig = { const baseConfig = {
description: description:
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)", "Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
@@ -113,7 +107,6 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
temperature: 0.1, temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx), prompt: buildDynamicOrchestratorPrompt(ctx),
color: "#10B981", color: "#10B981",
...restrictions,
} }
return baseConfig as AgentConfig return baseConfig as AgentConfig

View File

@@ -0,0 +1,41 @@
/// <reference types="bun-types" />
import { describe, test, expect } from "bun:test"
import { createEnvContext } from "./env-context"
describe("createEnvContext", () => {
test("returns omo-env block with timezone and locale", () => {
// #given - no setup needed
// #when
const result = createEnvContext()
// #then
expect(result).toContain("<omo-env>")
expect(result).toContain("</omo-env>")
expect(result).toContain("Timezone:")
expect(result).toContain("Locale:")
expect(result).not.toContain("Current date:")
})
test("does not include time with seconds precision to preserve token cache", () => {
// #given - seconds-precision time changes every second, breaking cache on every request
// #when
const result = createEnvContext()
// #then - no HH:MM:SS pattern anywhere in the output
expect(result).not.toMatch(/\d{1,2}:\d{2}:\d{2}/)
})
test("does not include date or time fields since OpenCode already provides them", () => {
// #given - OpenCode's system.ts already injects date, platform, working directory
// #when
const result = createEnvContext()
// #then - only timezone and locale remain; both are stable across requests
expect(result).not.toContain("Current date:")
expect(result).not.toContain("Current time:")
})
})

View File

@@ -1,32 +1,15 @@
/** /**
* Creates OmO-specific environment context (time, timezone, locale). * Creates OmO-specific environment context (timezone, locale).
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts, * Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
* so we only include fields that OpenCode doesn't provide to avoid duplication. * so we only include fields that OpenCode doesn't provide to avoid duplication.
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379 * See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
*/ */
export function createEnvContext(): string { export function createEnvContext(): string {
const now = new Date()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const locale = Intl.DateTimeFormat().resolvedOptions().locale const locale = Intl.DateTimeFormat().resolvedOptions().locale
const dateStr = now.toLocaleDateString(locale, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
const timeStr = now.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
return ` return `
<omo-env> <omo-env>
Current date: ${dateStr}
Current time: ${timeStr}
Timezone: ${timezone} Timezone: ${timezone}
Locale: ${locale} Locale: ${locale}
</omo-env>` </omo-env>`

View File

@@ -448,6 +448,21 @@ ${oracleSection}
4. **Run build** if applicable — exit code 0 required 4. **Run build** if applicable — exit code 0 required
5. **Tell user** what you verified and the results — keep it clear and helpful 5. **Tell user** what you verified and the results — keep it clear and helpful
### Auto-Commit Policy (MANDATORY for implementation/fix work)
1. **Auto-commit after implementation is complete** when the task includes feature/fix code changes
2. **Commit ONLY after verification gates pass**:
- \`lsp_diagnostics\` clean on all modified files
- Related tests pass
- Typecheck/build pass when applicable
3. **If any gate fails, DO NOT commit** — fix issues first, re-run verification, then commit
4. **Use Conventional Commits format** with meaningful intent-focused messages:
- \`feat(scope): add ...\` for new functionality
- \`fix(scope): resolve ...\` for bug fixes
- \`refactor(scope): simplify ...\` for internal restructuring
5. **Do not make placeholder commits** (\`wip\`, \`temp\`, \`update\`) or commit unverified code
6. **If user explicitly says not to commit**, skip commit and report that changes are left uncommitted
- **File edit** — \`lsp_diagnostics\` clean - **File edit** — \`lsp_diagnostics\` clean
- **Build** — Exit code 0 - **Build** — Exit code 0
- **Tests** — Pass (or pre-existing failures noted) - **Tests** — Pass (or pre-existing failures noted)

View File

@@ -4,6 +4,7 @@ import { createLibrarianAgent } from "./librarian"
import { createExploreAgent } from "./explore" import { createExploreAgent } from "./explore"
import { createMomusAgent } from "./momus" import { createMomusAgent } from "./momus"
import { createMetisAgent } from "./metis" import { createMetisAgent } from "./metis"
import { createAtlasAgent } from "./atlas"
const TEST_MODEL = "anthropic/claude-sonnet-4-5" const TEST_MODEL = "anthropic/claude-sonnet-4-5"
@@ -96,4 +97,18 @@ describe("read-only agent tool restrictions", () => {
} }
}) })
}) })
describe("Atlas", () => {
test("allows delegation tools for orchestration", () => {
// given
const agent = createAtlasAgent({ model: TEST_MODEL })
// when
const permission = (agent.permission ?? {}) as Record<string, string>
// then
expect(permission["task"]).toBeUndefined()
expect(permission["call_omo_agent"]).toBeUndefined()
})
})
}) })

View File

@@ -2,11 +2,17 @@ import { describe, test, expect } from "bun:test";
import { isGptModel, isGeminiModel } from "./types"; import { isGptModel, isGeminiModel } from "./types";
describe("isGptModel", () => { describe("isGptModel", () => {
test("standard openai provider models", () => { test("standard openai provider gpt models", () => {
expect(isGptModel("openai/gpt-5.2")).toBe(true); expect(isGptModel("openai/gpt-5.2")).toBe(true);
expect(isGptModel("openai/gpt-4o")).toBe(true); expect(isGptModel("openai/gpt-4o")).toBe(true);
expect(isGptModel("openai/o1")).toBe(true); });
expect(isGptModel("openai/o3-mini")).toBe(true);
test("o-series models are not gpt by name", () => {
expect(isGptModel("openai/o1")).toBe(false);
expect(isGptModel("openai/o3-mini")).toBe(false);
expect(isGptModel("litellm/o1")).toBe(false);
expect(isGptModel("litellm/o3-mini")).toBe(false);
expect(isGptModel("litellm/o4-mini")).toBe(false);
}); });
test("github copilot gpt models", () => { test("github copilot gpt models", () => {
@@ -17,9 +23,6 @@ describe("isGptModel", () => {
test("litellm proxied gpt models", () => { test("litellm proxied gpt models", () => {
expect(isGptModel("litellm/gpt-5.2")).toBe(true); expect(isGptModel("litellm/gpt-5.2")).toBe(true);
expect(isGptModel("litellm/gpt-4o")).toBe(true); expect(isGptModel("litellm/gpt-4o")).toBe(true);
expect(isGptModel("litellm/o1")).toBe(true);
expect(isGptModel("litellm/o3-mini")).toBe(true);
expect(isGptModel("litellm/o4-mini")).toBe(true);
}); });
test("other proxied gpt models", () => { test("other proxied gpt models", () => {
@@ -27,6 +30,11 @@ describe("isGptModel", () => {
expect(isGptModel("custom-provider/gpt-5.2")).toBe(true); expect(isGptModel("custom-provider/gpt-5.2")).toBe(true);
}); });
test("venice provider gpt models", () => {
expect(isGptModel("venice/gpt-5.2")).toBe(true);
expect(isGptModel("venice/gpt-4o")).toBe(true);
});
test("gpt4 prefix without hyphen (legacy naming)", () => { test("gpt4 prefix without hyphen (legacy naming)", () => {
expect(isGptModel("litellm/gpt4o")).toBe(true); expect(isGptModel("litellm/gpt4o")).toBe(true);
expect(isGptModel("ollama/gpt4")).toBe(true); expect(isGptModel("ollama/gpt4")).toBe(true);

View File

@@ -70,14 +70,9 @@ function extractModelName(model: string): string {
return model.includes("/") ? model.split("/").pop() ?? model : model return model.includes("/") ? model.split("/").pop() ?? model : model
} }
const GPT_MODEL_PREFIXES = ["gpt-", "gpt4", "o1", "o3", "o4"]
export function isGptModel(model: string): boolean { export function isGptModel(model: string): boolean {
if (model.startsWith("openai/") || model.startsWith("github-copilot/gpt-"))
return true
const modelName = extractModelName(model).toLowerCase() const modelName = extractModelName(model).toLowerCase()
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix)) return modelName.includes("gpt")
} }
const GEMINI_PROVIDERS = ["google/", "google-vertex/"] const GEMINI_PROVIDERS = ["google/", "google-vertex/"]

View File

@@ -589,20 +589,22 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
} }
}) })
test("hephaestus is created when github-copilot provider is connected", async () => { test("hephaestus IS created when github-copilot is connected with a GPT model", async () => {
// #given - github-copilot provider has models available // #given - github-copilot provider has gpt-5.3-codex available
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["github-copilot/gpt-5.3-codex"]) new Set(["github-copilot/gpt-5.3-codex"])
) )
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
try { try {
// #when // #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {}) const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
// #then // #then - github-copilot is now a valid provider for hephaestus
expect(agents.hephaestus).toBeDefined() expect(agents.hephaestus).toBeDefined()
} finally { } finally {
fetchSpy.mockRestore() fetchSpy.mockRestore()
cacheSpy.mockRestore()
} }
}) })

View File

@@ -1,6 +1,6 @@
# src/cli/ — CLI: install, run, doctor, mcp-oauth # src/cli/ — CLI: install, run, doctor, mcp-oauth
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -750,10 +750,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"explore": { "explore": {
"model": "github-copilot/gpt-5-mini", "model": "github-copilot/gpt-5-mini",
}, },
"hephaestus": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"librarian": { "librarian": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
}, },
@@ -786,16 +782,12 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "high", "variant": "high",
}, },
"deep": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gpt-5.3-codex", "model": "github-copilot/gemini-3-pro-preview",
"variant": "xhigh", "variant": "high",
}, },
"unspecified-high": { "unspecified-high": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
@@ -824,10 +816,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"explore": { "explore": {
"model": "github-copilot/gpt-5-mini", "model": "github-copilot/gpt-5-mini",
}, },
"hephaestus": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"librarian": { "librarian": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
}, },
@@ -860,16 +848,12 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "high", "variant": "high",
}, },
"deep": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gpt-5.3-codex", "model": "github-copilot/gemini-3-pro-preview",
"variant": "xhigh", "variant": "high",
}, },
"unspecified-high": { "unspecified-high": {
"model": "github-copilot/claude-opus-4.6", "model": "github-copilot/claude-opus-4.6",
@@ -1285,7 +1269,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "opencode/claude-haiku-4-5", "model": "opencode/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "github-copilot/gpt-5.3-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
@@ -1321,14 +1305,14 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"variant": "high", "variant": "high",
}, },
"deep": { "deep": {
"model": "github-copilot/gpt-5.3-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gpt-5.3-codex", "model": "opencode/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {

View File

@@ -1,6 +1,6 @@
# src/cli/config-manager/ — CLI Installation Utilities # src/cli/config-manager/ — CLI Installation Utilities
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../../shared/jsonc-parser"
import type { InstallConfig } from "../types"
import { resetConfigContext } from "./config-context"
import { generateOmoConfig } from "./generate-omo-config"
import { writeOmoConfig } from "./write-omo-config"
const installConfig: InstallConfig = {
hasClaude: true,
isMax20: true,
hasOpenAI: true,
hasGemini: true,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
function getRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
describe("writeOmoConfig", () => {
let testConfigDir = ""
let testConfigPath = ""
beforeEach(() => {
testConfigDir = join(tmpdir(), `omo-write-config-${Date.now()}-${Math.random().toString(36).slice(2)}`)
testConfigPath = join(testConfigDir, "oh-my-opencode.json")
mkdirSync(testConfigDir, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = testConfigDir
resetConfigContext()
})
afterEach(() => {
rmSync(testConfigDir, { recursive: true, force: true })
resetConfigContext()
delete process.env.OPENCODE_CONFIG_DIR
})
it("preserves existing user values while adding new defaults", () => {
// given
const existingConfig = {
agents: {
sisyphus: {
model: "custom/provider-model",
},
},
disabled_hooks: ["comment-checker"],
}
writeFileSync(testConfigPath, JSON.stringify(existingConfig, null, 2) + "\n", "utf-8")
const generatedDefaults = generateOmoConfig(installConfig)
// when
const result = writeOmoConfig(installConfig)
// then
expect(result.success).toBe(true)
const savedConfig = parseJsonc<Record<string, unknown>>(readFileSync(testConfigPath, "utf-8"))
const savedAgents = getRecord(savedConfig.agents)
const savedSisyphus = getRecord(savedAgents.sisyphus)
expect(savedSisyphus.model).toBe("custom/provider-model")
expect(savedConfig.disabled_hooks).toEqual(["comment-checker"])
for (const defaultKey of Object.keys(generatedDefaults)) {
expect(savedConfig).toHaveProperty(defaultKey)
}
})
})

View File

@@ -43,7 +43,7 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
return { success: true, configPath: omoConfigPath } return { success: true, configPath: omoConfigPath }
} }
const merged = deepMergeRecord(existing, newConfig) const merged = deepMergeRecord(newConfig, existing)
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n") writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) { } catch (parseErr) {
if (parseErr instanceof SyntaxError) { if (parseErr instanceof SyntaxError) {

View File

@@ -17,9 +17,9 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
}, },
hephaestus: { hephaestus: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
], ],
requiresProvider: ["openai", "github-copilot", "opencode"], requiresProvider: ["openai", "opencode"],
}, },
oracle: { oracle: {
fallbackChain: [ fallbackChain: [
@@ -100,14 +100,14 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
}, },
ultrabrain: { ultrabrain: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" }, { providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
], ],
}, },
deep: { deep: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
], ],
@@ -131,7 +131,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
"unspecified-low": { "unspecified-low": {
fallbackChain: [ fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
], ],
}, },

View File

@@ -421,16 +421,15 @@ describe("generateModelConfig", () => {
expect(result.agents?.hephaestus?.variant).toBe("medium") expect(result.agents?.hephaestus?.variant).toBe("medium")
}) })
test("Hephaestus is created when Copilot is available (github-copilot provider connected)", () => { test("Hephaestus is NOT created when only Copilot is available (gpt-5.3-codex unavailable on github-copilot)", () => {
// #given // #given
const config = createConfig({ hasCopilot: true }) const config = createConfig({ hasCopilot: true })
// #when // #when
const result = generateModelConfig(config) const result = generateModelConfig(config)
// #then // #then - hephaestus is omitted because gpt-5.3-codex is not available on github-copilot
expect(result.agents?.hephaestus?.model).toBe("github-copilot/gpt-5.3-codex") expect(result.agents?.hephaestus).toBeUndefined()
expect(result.agents?.hephaestus?.variant).toBe("medium")
}) })
test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => { test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => {

View File

@@ -1,6 +1,6 @@
# src/cli/run/ — Non-Interactive Session Launcher # src/cli/run/ — Non-Interactive Session Launcher
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, spyOn } from "bun:test" const { describe, it, expect, spyOn } = require("bun:test")
import type { RunContext } from "./types" import type { RunContext } from "./types"
import { createEventState } from "./events" import { createEventState } from "./events"
import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers" import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers"
@@ -235,9 +235,7 @@ describe("handleMessagePartUpdated", () => {
it("prints completion metadata once when assistant text part is completed", () => { it("prints completion metadata once when assistant text part is completed", () => {
// given // given
const nowSpy = spyOn(Date, "now") const nowSpy = spyOn(Date, "now").mockReturnValue(3400)
nowSpy.mockReturnValueOnce(1000)
nowSpy.mockReturnValueOnce(3400)
const ctx = createMockContext("ses_main") const ctx = createMockContext("ses_main")
const state = createEventState() const state = createEventState()
@@ -259,6 +257,7 @@ describe("handleMessagePartUpdated", () => {
} as any, } as any,
state, state,
) )
state.messageStartedAtById["msg_1"] = 1000
// when // when
handleMessagePartUpdated( handleMessagePartUpdated(

View File

@@ -7,6 +7,8 @@ export interface EventState {
currentTool: string | null currentTool: string | null
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */ /** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
hasReceivedMeaningfulWork: boolean hasReceivedMeaningfulWork: boolean
/** Timestamp of the last received event (for watchdog detection) */
lastEventTimestamp: number
/** Count of assistant messages for the main session */ /** Count of assistant messages for the main session */
messageCount: number messageCount: number
/** Current agent name from the latest assistant message */ /** Current agent name from the latest assistant message */
@@ -54,6 +56,7 @@ export function createEventState(): EventState {
lastPartText: "", lastPartText: "",
currentTool: null, currentTool: null,
hasReceivedMeaningfulWork: false, hasReceivedMeaningfulWork: false,
lastEventTimestamp: Date.now(),
messageCount: 0, messageCount: 0,
currentAgent: null, currentAgent: null,
currentModel: null, currentModel: null,

View File

@@ -35,6 +35,9 @@ export async function processEvents(
logEventVerbose(ctx, payload) logEventVerbose(ctx, payload)
} }
// Update last event timestamp for watchdog detection
state.lastEventTimestamp = Date.now()
handleSessionError(ctx, payload, state) handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state) handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state) handleSessionStatus(ctx, payload, state)

View File

@@ -8,11 +8,15 @@ const DEFAULT_POLL_INTERVAL_MS = 500
const DEFAULT_REQUIRED_CONSECUTIVE = 1 const DEFAULT_REQUIRED_CONSECUTIVE = 1
const ERROR_GRACE_CYCLES = 3 const ERROR_GRACE_CYCLES = 3
const MIN_STABILIZATION_MS = 1_000 const MIN_STABILIZATION_MS = 1_000
const DEFAULT_EVENT_WATCHDOG_MS = 30_000 // 30 seconds
const DEFAULT_SECONDARY_MEANINGFUL_WORK_TIMEOUT_MS = 60_000 // 60 seconds
export interface PollOptions { export interface PollOptions {
pollIntervalMs?: number pollIntervalMs?: number
requiredConsecutive?: number requiredConsecutive?: number
minStabilizationMs?: number minStabilizationMs?: number
eventWatchdogMs?: number
secondaryMeaningfulWorkTimeoutMs?: number
} }
export async function pollForCompletion( export async function pollForCompletion(
@@ -28,9 +32,15 @@ export async function pollForCompletion(
options.minStabilizationMs ?? MIN_STABILIZATION_MS options.minStabilizationMs ?? MIN_STABILIZATION_MS
const minStabilizationMs = const minStabilizationMs =
rawMinStabilizationMs > 0 ? rawMinStabilizationMs : MIN_STABILIZATION_MS rawMinStabilizationMs > 0 ? rawMinStabilizationMs : MIN_STABILIZATION_MS
const eventWatchdogMs =
options.eventWatchdogMs ?? DEFAULT_EVENT_WATCHDOG_MS
const secondaryMeaningfulWorkTimeoutMs =
options.secondaryMeaningfulWorkTimeoutMs ??
DEFAULT_SECONDARY_MEANINGFUL_WORK_TIMEOUT_MS
let consecutiveCompleteChecks = 0 let consecutiveCompleteChecks = 0
let errorCycleCount = 0 let errorCycleCount = 0
let firstWorkTimestamp: number | null = null let firstWorkTimestamp: number | null = null
let secondaryTimeoutChecked = false
const pollStartTimestamp = Date.now() const pollStartTimestamp = Date.now()
while (!abortController.signal.aborted) { while (!abortController.signal.aborted) {
@@ -59,7 +69,37 @@ export async function pollForCompletion(
errorCycleCount = 0 errorCycleCount = 0
} }
const mainSessionStatus = await getMainSessionStatus(ctx) // Watchdog: if no events received for N seconds, verify session status via API
let mainSessionStatus: "idle" | "busy" | "retry" | null = null
if (eventState.lastEventTimestamp !== null) {
const timeSinceLastEvent = Date.now() - eventState.lastEventTimestamp
if (timeSinceLastEvent > eventWatchdogMs) {
// Events stopped coming - verify actual session state
console.log(
pc.yellow(
`\n No events for ${Math.round(
timeSinceLastEvent / 1000
)}s, verifying session status...`
)
)
// Force check session status directly
mainSessionStatus = await getMainSessionStatus(ctx)
if (mainSessionStatus === "idle") {
eventState.mainSessionIdle = true
} else if (mainSessionStatus === "busy" || mainSessionStatus === "retry") {
eventState.mainSessionIdle = false
}
// Reset timestamp to avoid repeated checks
eventState.lastEventTimestamp = Date.now()
}
}
// Only call getMainSessionStatus if watchdog didn't already check
if (mainSessionStatus === null) {
mainSessionStatus = await getMainSessionStatus(ctx)
}
if (mainSessionStatus === "busy" || mainSessionStatus === "retry") { if (mainSessionStatus === "busy" || mainSessionStatus === "retry") {
eventState.mainSessionIdle = false eventState.mainSessionIdle = false
} else if (mainSessionStatus === "idle") { } else if (mainSessionStatus === "idle") {
@@ -81,6 +121,50 @@ export async function pollForCompletion(
consecutiveCompleteChecks = 0 consecutiveCompleteChecks = 0
continue continue
} }
// Secondary timeout: if we've been polling for reasonable time but haven't
// received meaningful work via events, check if there's active work via API
// Only check once to avoid unnecessary API calls every poll cycle
if (
Date.now() - pollStartTimestamp > secondaryMeaningfulWorkTimeoutMs &&
!secondaryTimeoutChecked
) {
secondaryTimeoutChecked = true
// Check if session actually has pending work (children, todos, etc.)
const childrenRes = await ctx.client.session.children({
path: { id: ctx.sessionID },
query: { directory: ctx.directory },
})
const children = normalizeSDKResponse(childrenRes, [] as unknown[])
const todosRes = await ctx.client.session.todo({
path: { id: ctx.sessionID },
query: { directory: ctx.directory },
})
const todos = normalizeSDKResponse(todosRes, [] as unknown[])
const hasActiveChildren =
Array.isArray(children) && children.length > 0
const hasActiveTodos =
Array.isArray(todos) &&
todos.some(
(t: unknown) =>
(t as { status?: string })?.status !== "completed" &&
(t as { status?: string })?.status !== "cancelled"
)
const hasActiveWork = hasActiveChildren || hasActiveTodos
if (hasActiveWork) {
// Assume meaningful work is happening even without events
eventState.hasReceivedMeaningfulWork = true
console.log(
pc.yellow(
`\n No meaningful work events for ${Math.round(
secondaryMeaningfulWorkTimeoutMs / 1000
)}s but session has active work - assuming in progress`
)
)
}
}
} else { } else {
// Track when first meaningful work was received // Track when first meaningful work was received
if (firstWorkTimestamp === null) { if (firstWorkTimestamp === null) {

View File

@@ -1,6 +1,6 @@
# src/config/ — Zod v4 Schema System # src/config/ — Zod v4 Schema System
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -59,7 +59,9 @@ export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(), build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(), plan: AgentOverrideConfigSchema.optional(),
sisyphus: AgentOverrideConfigSchema.optional(), sisyphus: AgentOverrideConfigSchema.optional(),
hephaestus: AgentOverrideConfigSchema.optional(), hephaestus: AgentOverrideConfigSchema.extend({
allow_non_gpt_model: z.boolean().optional(),
}).optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(), "sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(), "OpenCode-Builder": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(), prometheus: AgentOverrideConfigSchema.optional(),

View File

@@ -20,6 +20,7 @@ export const CategoryConfigSchema = z.object({
textVerbosity: z.enum(["low", "medium", "high"]).optional(), textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(), tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(), prompt_append: z.string().optional(),
max_prompt_tokens: z.number().int().positive().optional(),
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
is_unstable_agent: z.boolean().optional(), is_unstable_agent: z.boolean().optional(),
/** Disable this category. Disabled categories are excluded from task delegation. */ /** Disable this category. Disabled categories are excluded from task delegation. */

View File

@@ -1,6 +1,6 @@
# src/features/ — 19 Feature Modules # src/features/ — 19 Feature Modules
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,6 +1,6 @@
# src/features/background-agent/ — Core Orchestration Engine # src/features/background-agent/ — Core Orchestration Engine
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -191,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
} }
function getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
return (manager as unknown as { pendingNotifications: Map<string, string[]> }).pendingNotifications
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> { function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
} }
@@ -1057,6 +1061,49 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
manager.shutdown() manager.shutdown()
}) })
test("should queue notification when promptAsync aborts while parent is idle", async () => {
//#given
const promptMock = async () => {
const error = new Error("Request aborted while waiting for input")
error.name = "MessageAbortedError"
throw error
}
const client = {
session: {
prompt: promptMock,
promptAsync: promptMock,
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-idle-queue",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task idle queue",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
expect(queuedNotifications).toHaveLength(1)
expect(queuedNotifications[0]).toContain("<system-reminder>")
expect(queuedNotifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
manager.shutdown()
})
}) })
describe("BackgroundManager.notifyParentSession - notifications toggle", () => { describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
@@ -1105,6 +1152,29 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
}) })
}) })
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
test("should prepend queued notifications to first text part and clear queue", () => {
// given
const manager = createBackgroundManager()
manager.queuePendingNotification("session-parent", "<system-reminder>queued-one</system-reminder>")
manager.queuePendingNotification("session-parent", "<system-reminder>queued-two</system-reminder>")
const output = {
parts: [{ type: "text", text: "User prompt" }],
}
// when
manager.injectPendingNotificationsIntoChatMessage(output, "session-parent")
// then
expect(output.parts[0].text).toContain("<system-reminder>queued-one</system-reminder>")
expect(output.parts[0].text).toContain("<system-reminder>queued-two</system-reminder>")
expect(output.parts[0].text).toContain("User prompt")
expect(getPendingNotifications(manager).get("session-parent")).toBeUndefined()
manager.shutdown()
})
})
function buildNotificationPromptBody( function buildNotificationPromptBody(
task: BackgroundTask, task: BackgroundTask,
currentMessage: CurrentMessage | null currentMessage: CurrentMessage | null

View File

@@ -93,6 +93,7 @@ export class BackgroundManager {
private tasks: Map<string, BackgroundTask> private tasks: Map<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]> private notifications: Map<string, BackgroundTask[]>
private pendingNotifications: Map<string, string[]>
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
private client: OpencodeClient private client: OpencodeClient
private directory: string private directory: string
@@ -125,6 +126,7 @@ export class BackgroundManager {
) { ) {
this.tasks = new Map() this.tasks = new Map()
this.notifications = new Map() this.notifications = new Map()
this.pendingNotifications = new Map()
this.pendingByParent = new Map() this.pendingByParent = new Map()
this.client = ctx.client this.client = ctx.client
this.directory = ctx.directory this.directory = ctx.directory
@@ -917,6 +919,32 @@ export class BackgroundManager {
this.notifications.delete(sessionID) this.notifications.delete(sessionID)
} }
queuePendingNotification(sessionID: string | undefined, notification: string): void {
if (!sessionID) return
const existingNotifications = this.pendingNotifications.get(sessionID) ?? []
existingNotifications.push(notification)
this.pendingNotifications.set(sessionID, existingNotifications)
}
injectPendingNotificationsIntoChatMessage(output: { parts: Array<{ type: string; text?: string; [key: string]: unknown }> }, sessionID: string): void {
const pendingNotifications = this.pendingNotifications.get(sessionID)
if (!pendingNotifications || pendingNotifications.length === 0) {
return
}
this.pendingNotifications.delete(sessionID)
const notificationContent = pendingNotifications.join("\n\n")
const firstTextPartIndex = output.parts.findIndex((part) => part.type === "text")
if (firstTextPartIndex === -1) {
output.parts.unshift(createInternalAgentTextPart(notificationContent))
return
}
const originalText = output.parts[firstTextPartIndex].text ?? ""
output.parts[firstTextPartIndex].text = `${notificationContent}\n\n---\n\n${originalText}`
}
/** /**
* Validates that a session has actual assistant/tool output before marking complete. * Validates that a session has actual assistant/tool output before marking complete.
* Prevents premature completion when session.idle fires before agent responds. * Prevents premature completion when session.idle fires before agent responds.
@@ -1340,6 +1368,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
taskId: task.id, taskId: task.id,
parentSessionID: task.parentSessionID, parentSessionID: task.parentSessionID,
}) })
this.queuePendingNotification(task.parentSessionID, notification)
} else { } else {
log("[background-agent] Failed to send notification:", error) log("[background-agent] Failed to send notification:", error)
} }
@@ -1568,6 +1597,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.concurrencyManager.clear() this.concurrencyManager.clear()
this.tasks.clear() this.tasks.clear()
this.notifications.clear() this.notifications.clear()
this.pendingNotifications.clear()
this.pendingByParent.clear() this.pendingByParent.clear()
this.notificationQueueByParent.clear() this.notificationQueueByParent.clear()
this.queuesByKey.clear() this.queuesByKey.clear()

View File

@@ -269,6 +269,71 @@ describe("boulder-state", () => {
expect(progress.isComplete).toBe(false) expect(progress.isComplete).toBe(false)
}) })
test("should count space-indented unchecked checkbox", () => {
// given - plan file with a two-space indented checkbox
const planPath = join(TEST_DIR, "space-indented-plan.md")
writeFileSync(planPath, `# Plan
- [ ] indented task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(1)
expect(progress.completed).toBe(0)
expect(progress.isComplete).toBe(false)
})
test("should count tab-indented unchecked checkbox", () => {
// given - plan file with a tab-indented checkbox
const planPath = join(TEST_DIR, "tab-indented-plan.md")
writeFileSync(planPath, `# Plan
- [ ] tab-indented task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(1)
expect(progress.completed).toBe(0)
expect(progress.isComplete).toBe(false)
})
test("should count mixed top-level checked and indented unchecked checkboxes", () => {
// given - plan file with checked top-level and unchecked indented task
const planPath = join(TEST_DIR, "mixed-indented-plan.md")
writeFileSync(planPath, `# Plan
- [x] top-level completed task
- [ ] nested unchecked task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(2)
expect(progress.completed).toBe(1)
expect(progress.isComplete).toBe(false)
})
test("should count space-indented completed checkbox", () => {
// given - plan file with a two-space indented completed checkbox
const planPath = join(TEST_DIR, "indented-completed-plan.md")
writeFileSync(planPath, `# Plan
- [x] indented completed task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(1)
expect(progress.completed).toBe(1)
expect(progress.isComplete).toBe(true)
})
test("should return isComplete true when all checked", () => { test("should return isComplete true when all checked", () => {
// given - all tasks completed // given - all tasks completed
const planPath = join(TEST_DIR, "complete-plan.md") const planPath = join(TEST_DIR, "complete-plan.md")

View File

@@ -121,8 +121,8 @@ export function getPlanProgress(planPath: string): PlanProgress {
const content = readFileSync(planPath, "utf-8") const content = readFileSync(planPath, "utf-8")
// Match markdown checkboxes: - [ ] or - [x] or - [X] // Match markdown checkboxes: - [ ] or - [x] or - [X]
const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || [] const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || []
const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || [] const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || []
const total = uncheckedMatches.length + checkedMatches.length const total = uncheckedMatches.length + checkedMatches.length
const completed = checkedMatches.length const completed = checkedMatches.length
@@ -150,7 +150,8 @@ export function getPlanName(planPath: string): string {
export function createBoulderState( export function createBoulderState(
planPath: string, planPath: string,
sessionId: string, sessionId: string,
agent?: string agent?: string,
worktreePath?: string,
): BoulderState { ): BoulderState {
return { return {
active_plan: planPath, active_plan: planPath,
@@ -158,5 +159,6 @@ export function createBoulderState(
session_ids: [sessionId], session_ids: [sessionId],
plan_name: getPlanName(planPath), plan_name: getPlanName(planPath),
...(agent !== undefined ? { agent } : {}), ...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
} }
} }

View File

@@ -16,6 +16,8 @@ export interface BoulderState {
plan_name: string plan_name: string
/** Agent type to use when resuming (e.g., 'atlas') */ /** Agent type to use when resuming (e.g., 'atlas') */
agent?: string agent?: string
/** Absolute path to the git worktree root where work happens */
worktree_path?: string
} }
export interface PlanProgress { export interface PlanProgress {

View File

@@ -1,5 +1,14 @@
export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session. export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
## ARGUMENTS
- \`/start-work [plan-name] [--worktree <path>]\`
- \`plan-name\` (optional): name or partial match of the plan to start
- \`--worktree <path>\` (optional): absolute path to an existing git worktree to work in
- If specified and valid: hook pre-sets worktree_path in boulder.json
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
- If omitted: you MUST choose or create a worktree (see Worktree Setup below)
## WHAT TO DO ## WHAT TO DO
1. **Find available plans**: Search for Prometheus-generated plan files at \`.sisyphus/plans/\` 1. **Find available plans**: Search for Prometheus-generated plan files at \`.sisyphus/plans/\`
@@ -15,17 +24,24 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
- If ONE plan: auto-select it - If ONE plan: auto-select it
- If MULTIPLE plans: show list with timestamps, ask user to select - If MULTIPLE plans: show list with timestamps, ask user to select
4. **Create/Update boulder.json**: 4. **Worktree Setup** (when \`worktree_path\` not already set in boulder.json):
1. \`git worktree list --porcelain\` — see available worktrees
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
4. All work happens inside that worktree directory
5. **Create/Update boulder.json**:
\`\`\`json \`\`\`json
{ {
"active_plan": "/absolute/path/to/plan.md", "active_plan": "/absolute/path/to/plan.md",
"started_at": "ISO_TIMESTAMP", "started_at": "ISO_TIMESTAMP",
"session_ids": ["session_id_1", "session_id_2"], "session_ids": ["session_id_1", "session_id_2"],
"plan_name": "plan-name" "plan_name": "plan-name",
"worktree_path": "/absolute/path/to/git/worktree"
} }
\`\`\` \`\`\`
5. **Read the plan file** and start executing tasks according to atlas workflow 6. **Read the plan file** and start executing tasks according to atlas workflow
## OUTPUT FORMAT ## OUTPUT FORMAT
@@ -49,6 +65,7 @@ Resuming Work Session
Active Plan: {plan-name} Active Plan: {plan-name}
Progress: {completed}/{total} tasks Progress: {completed}/{total} tasks
Sessions: {count} (appending current session) Sessions: {count} (appending current session)
Worktree: {worktree_path}
Reading plan and continuing from last incomplete task... Reading plan and continuing from last incomplete task...
\`\`\` \`\`\`
@@ -60,6 +77,7 @@ Starting Work Session
Plan: {plan-name} Plan: {plan-name}
Session ID: {session_id} Session ID: {session_id}
Started: {timestamp} Started: {timestamp}
Worktree: {worktree_path}
Reading plan and beginning execution... Reading plan and beginning execution...
\`\`\` \`\`\`
@@ -68,5 +86,6 @@ Reading plan and beginning execution...
- The session_id is injected by the hook - use it directly - The session_id is injected by the hook - use it directly
- Always update boulder.json BEFORE starting work - Always update boulder.json BEFORE starting work
- Always set worktree_path in boulder.json before executing any tasks
- Read the FULL plan file before delegating any tasks - Read the FULL plan file before delegating any tasks
- Follow atlas delegation protocols (7-section format)` - Follow atlas delegation protocols (7-section format)`

View File

@@ -1,6 +1,6 @@
# src/features/claude-tasks/ — Task Schema + Storage # src/features/claude-tasks/ — Task Schema + Storage
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,6 +1,6 @@
# src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers # src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,6 +1,6 @@
# src/features/opencode-skill-loader/ — 4-Scope Skill Discovery # src/features/opencode-skill-loader/ — 4-Scope Skill Discovery
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,6 +1,6 @@
# src/features/tmux-subagent/ — Tmux Pane Management # src/features/tmux-subagent/ — Tmux Pane Management
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,14 +1,14 @@
# src/hooks/ — 44 Lifecycle Hooks # src/hooks/ — 46 Lifecycle Hooks
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW
44 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(35) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. 46 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS ## HOOK TIERS
### Tier 1: Session Hooks (22) — `create-session-hooks.ts` ### Tier 1: Session Hooks (23) — `create-session-hooks.ts`
## STRUCTURE ## STRUCTURE
``` ```
hooks/ hooks/
@@ -70,11 +70,12 @@ hooks/
| questionLabelTruncator | tool.execute.before | Truncate long question labels | | questionLabelTruncator | tool.execute.before | Truncate long question labels |
| taskResumeInfo | chat.message | Inject task context on resume | | taskResumeInfo | chat.message | Inject task context on resume |
| anthropicEffort | chat.params | Adjust reasoning effort level | | anthropicEffort | chat.params | Adjust reasoning effort level |
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder | | modelFallback | chat.params | Provider-level model fallback on errors |
| sisyphusGptHephaestusReminder | chat.message | Toast warning when Sisyphus uses GPT model | | noSisyphusGpt | chat.message | Block Sisyphus from using GPT models (toast warning) |
| taskReminder | tool.execute.after | Remind about task tools after 10 turns without usage | | noHephaestusNonGpt | chat.message | Block Hephaestus from using non-GPT models |
| runtimeFallback | event | Auto-switch models on API provider errors |
### Tier 2: Tool Guard Hooks (9) — `create-tool-guard-hooks.ts` ### Tier 2: Tool Guard Hooks (10) — `create-tool-guard-hooks.ts`
| Hook | Event | Purpose | | Hook | Event | Purpose |
|------|-------|---------| |------|-------|---------|
@@ -87,6 +88,7 @@ hooks/
| tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active | | tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active |
| writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files | | writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files |
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes | | hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts` ### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts`

View File

@@ -1,6 +1,6 @@
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery # src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -6,7 +6,7 @@ export function getOrCreateRetryState(
): RetryState { ): RetryState {
let state = autoCompactState.retryStateBySession.get(sessionID) let state = autoCompactState.retryStateBySession.get(sessionID)
if (!state) { if (!state) {
state = { attempt: 0, lastAttemptTime: 0 } state = { attempt: 0, lastAttemptTime: 0, firstAttemptTime: 0 }
autoCompactState.retryStateBySession.set(sessionID, state) autoCompactState.retryStateBySession.set(sessionID, state)
} }
return state return state

View File

@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
type TimeoutCall = {
delay: number
}
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map<string, RetryState>(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
}
describe("runSummarizeRetryStrategy", () => {
const sessionID = "ses_retry_timeout"
const directory = "/tmp"
let autoCompactState: AutoCompactState
const summarizeMock = mock(() => Promise.resolve())
const showToastMock = mock(() => Promise.resolve())
const client = {
session: {
summarize: summarizeMock,
messages: mock(() => Promise.resolve({ data: [] })),
promptAsync: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
},
tui: {
showToast: showToastMock,
},
}
beforeEach(() => {
autoCompactState = createAutoCompactState()
summarizeMock.mockReset()
showToastMock.mockReset()
summarizeMock.mockResolvedValue(undefined)
showToastMock.mockResolvedValue(undefined)
})
afterEach(() => {
globalThis.setTimeout = originalSetTimeout
})
const originalSetTimeout = globalThis.setTimeout
test("stops retries when total summarize timeout is exceeded", async () => {
//#given
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, {
currentTokens: 250000,
maxTokens: 200000,
errorType: "token_limit_exceeded",
})
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 1,
lastAttemptTime: Date.now(),
firstAttemptTime: Date.now() - 130000,
})
//#when
await runSummarizeRetryStrategy({
sessionID,
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
autoCompactState,
client: client as never,
directory,
pluginConfig: {} as OhMyOpenCodeConfig,
})
//#then
expect(summarizeMock).not.toHaveBeenCalled()
expect(autoCompactState.pendingCompact.has(sessionID)).toBe(false)
expect(autoCompactState.errorDataBySession.has(sessionID)).toBe(false)
expect(autoCompactState.retryStateBySession.has(sessionID)).toBe(false)
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
title: "Auto Compact Timed Out",
}),
}),
)
})
test("caps retry delay by remaining total timeout window", async () => {
//#given
const timeoutCalls: TimeoutCall[] = []
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
timeoutCalls.push({ delay: delay ?? 0 })
return 1 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 1,
lastAttemptTime: Date.now(),
firstAttemptTime: Date.now() - 119700,
})
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
//#when
await runSummarizeRetryStrategy({
sessionID,
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
autoCompactState,
client: client as never,
directory,
pluginConfig: {} as OhMyOpenCodeConfig,
})
//#then
expect(timeoutCalls.length).toBe(1)
expect(timeoutCalls[0]!.delay).toBeGreaterThan(0)
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(500)
})
})

View File

@@ -7,6 +7,8 @@ import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
import { fixEmptyMessages } from "./empty-content-recovery" import { fixEmptyMessages } from "./empty-content-recovery"
import { resolveCompactionModel } from "../shared/compaction-model-resolver" import { resolveCompactionModel } from "../shared/compaction-model-resolver"
const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000
export async function runSummarizeRetryStrategy(params: { export async function runSummarizeRetryStrategy(params: {
sessionID: string sessionID: string
msg: Record<string, unknown> msg: Record<string, unknown>
@@ -18,6 +20,27 @@ export async function runSummarizeRetryStrategy(params: {
messageIndex?: number messageIndex?: number
}): Promise<void> { }): Promise<void> {
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID) const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
const now = Date.now()
if (retryState.firstAttemptTime === 0) {
retryState.firstAttemptTime = now
}
const elapsedTimeMs = now - retryState.firstAttemptTime
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
if (params.errorType?.includes("non-empty content")) { if (params.errorType?.includes("non-empty content")) {
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID) const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
@@ -52,6 +75,7 @@ export async function runSummarizeRetryStrategy(params: {
if (Date.now() - retryState.lastAttemptTime > 300000) { if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0 retryState.attempt = 0
retryState.firstAttemptTime = Date.now()
params.autoCompactState.truncateStateBySession.delete(params.sessionID) params.autoCompactState.truncateStateBySession.delete(params.sessionID)
} }
@@ -92,10 +116,26 @@ export async function runSummarizeRetryStrategy(params: {
}) })
return return
} catch { } catch {
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
if (remainingTimeMs <= 0) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
const delay = const delay =
RETRY_CONFIG.initialDelayMs * RETRY_CONFIG.initialDelayMs *
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1) Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs) const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs)
setTimeout(() => { setTimeout(() => {
void runSummarizeRetryStrategy(params) void runSummarizeRetryStrategy(params)

View File

@@ -11,6 +11,7 @@ export interface ParsedTokenLimitError {
export interface RetryState { export interface RetryState {
attempt: number attempt: number
lastAttemptTime: number lastAttemptTime: number
firstAttemptTime: number
} }
export interface TruncateState { export interface TruncateState {

View File

@@ -1,6 +1,6 @@
# src/hooks/atlas/ — Master Boulder Orchestrator # src/hooks/atlas/ — Master Boulder Orchestrator
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -14,6 +14,7 @@ export async function injectBoulderContinuation(input: {
remaining: number remaining: number
total: number total: number
agent?: string agent?: string
worktreePath?: string
backgroundManager?: BackgroundManager backgroundManager?: BackgroundManager
sessionState: SessionState sessionState: SessionState
}): Promise<void> { }): Promise<void> {
@@ -24,6 +25,7 @@ export async function injectBoulderContinuation(input: {
remaining, remaining,
total, total,
agent, agent,
worktreePath,
backgroundManager, backgroundManager,
sessionState, sessionState,
} = input } = input
@@ -37,9 +39,11 @@ export async function injectBoulderContinuation(input: {
return return
} }
const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
const prompt = const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) + BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
worktreeContext
try { try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
@@ -62,6 +66,7 @@ export async function injectBoulderContinuation(input: {
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} catch (err) { } catch (err) {
sessionState.promptFailureCount += 1 sessionState.promptFailureCount += 1
sessionState.lastFailureAt = Date.now()
log(`[${HOOK_NAME}] Boulder continuation failed`, { log(`[${HOOK_NAME}] Boulder continuation failed`, {
sessionID, sessionID,
error: String(err), error: String(err),

View File

@@ -10,6 +10,7 @@ import { getLastAgentFromSession } from "./session-last-agent"
import type { AtlasHookOptions, SessionState } from "./types" import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000 const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
export function createAtlasEventHandler(input: { export function createAtlasEventHandler(input: {
ctx: PluginInput ctx: PluginInput
@@ -53,6 +54,7 @@ export function createAtlasEventHandler(input: {
} }
const state = getState(sessionID) const state = getState(sessionID)
const now = Date.now()
if (state.lastEventWasAbortError) { if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false state.lastEventWasAbortError = false
@@ -61,11 +63,18 @@ export function createAtlasEventHandler(input: {
} }
if (state.promptFailureCount >= 2) { if (state.promptFailureCount >= 2) {
log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, { const timeSinceLastFailure = state.lastFailureAt !== undefined ? now - state.lastFailureAt : Number.POSITIVE_INFINITY
sessionID, if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
promptFailureCount: state.promptFailureCount, log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
}) sessionID,
return promptFailureCount: state.promptFailureCount,
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
})
return
}
state.promptFailureCount = 0
state.lastFailureAt = undefined
} }
const backgroundManager = options?.backgroundManager const backgroundManager = options?.backgroundManager
@@ -92,17 +101,15 @@ export function createAtlasEventHandler(input: {
const lastAgentKey = getAgentConfigKey(lastAgent ?? "") const lastAgentKey = getAgentConfigKey(lastAgent ?? "")
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas") const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
const lastAgentMatchesRequired = lastAgentKey === requiredAgent const lastAgentMatchesRequired = lastAgentKey === requiredAgent
const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas" const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
const lastAgentIsSisyphus = lastAgentKey === "sisyphus" const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
const allowSisyphusWhenDefaultAtlas = boulderAgentWasNotExplicitlySet && boulderAgentDefaultsToAtlas && lastAgentIsSisyphus const allowSisyphusForAtlasBoulder = boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
const agentMatches = lastAgentMatchesRequired || allowSisyphusWhenDefaultAtlas const agentMatches = lastAgentMatchesRequired || allowSisyphusForAtlasBoulder
if (!agentMatches) { if (!agentMatches) {
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
sessionID, sessionID,
lastAgent: lastAgent ?? "unknown", lastAgent: lastAgent ?? "unknown",
requiredAgent, requiredAgent,
boulderAgentExplicitlySet: boulderState.agent !== undefined,
}) })
return return
} }
@@ -113,7 +120,6 @@ export function createAtlasEventHandler(input: {
return return
} }
const now = Date.now()
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID, sessionID,
@@ -132,6 +138,7 @@ export function createAtlasEventHandler(input: {
remaining, remaining,
total: progress.total, total: progress.total,
agent: boulderState.agent, agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
backgroundManager, backgroundManager,
sessionState: state, sessionState: state,
}) })

View File

@@ -933,8 +933,8 @@ describe("atlas hook", () => {
expect(callArgs.body.parts[0].text).toContain("2 remaining") expect(callArgs.body.parts[0].text).toContain("2 remaining")
}) })
test("should not inject when last agent does not match boulder agent", async () => { test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
// given - boulder state with incomplete plan, but last agent does NOT match // given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work)
const planPath = join(TEST_DIR, "test-plan.md") const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -947,7 +947,7 @@ describe("atlas hook", () => {
} }
writeBoulderState(TEST_DIR, state) writeBoulderState(TEST_DIR, state)
// given - last agent is NOT the boulder agent // given - last agent is sisyphus (typical state right after /start-work)
cleanupMessageStorage(MAIN_SESSION_ID) cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "sisyphus") setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
@@ -962,7 +962,39 @@ describe("atlas hook", () => {
}, },
}) })
// then - should NOT call prompt because agent does not match // then - should call prompt because sisyphus is always allowed for atlas boulders
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should not inject when last agent is non-sisyphus and does not match boulder agent", async () => {
// given - boulder explicitly set to atlas, last agent is hephaestus (unrelated agent)
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",
agent: "atlas",
}
writeBoulderState(TEST_DIR, state)
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should NOT call prompt because hephaestus does not match atlas or sisyphus
expect(mockInput._promptMock).not.toHaveBeenCalled() expect(mockInput._promptMock).not.toHaveBeenCalled()
}) })
@@ -1122,6 +1154,144 @@ describe("atlas hook", () => {
} }
}) })
test("should keep skipping continuation during 5-minute backoff after 2 consecutive failures", async () => {
//#given - boulder state with incomplete plan and prompt always fails
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 promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - third idle occurs inside 5-minute backoff window
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 60000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - third attempt should still be skipped
expect(promptMock).toHaveBeenCalledTimes(2)
} finally {
Date.now = originalDateNow
}
})
test("should retry continuation after 5-minute backoff expires following 2 consecutive failures", async () => {
//#given - boulder state with incomplete plan and prompt always fails
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 promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - third idle occurs after 5+ minutes
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 300000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - third attempt should run after backoff expiration
expect(promptMock).toHaveBeenCalledTimes(3)
} finally {
Date.now = originalDateNow
}
})
test("should reset prompt failure counter after successful retry beyond backoff window", async () => {
//#given - boulder state with incomplete plan and success on first retry after backoff
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 promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - fail twice, recover after backoff with success, then fail twice again
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 300000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - success retry resets counter, so two additional failures are allowed before skip
expect(promptMock).toHaveBeenCalledTimes(5)
} finally {
Date.now = originalDateNow
}
})
test("should reset continuation failure state on session.compacted event", async () => { test("should reset continuation failure state on session.compacted event", async () => {
//#given - boulder state with incomplete plan and prompt always fails //#given - boulder state with incomplete plan and prompt always fails
const planPath = join(TEST_DIR, "test-plan.md") const planPath = join(TEST_DIR, "test-plan.md")

View File

@@ -26,4 +26,5 @@ export interface SessionState {
lastEventWasAbortError?: boolean lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number lastContinuationInjectedAt?: number
promptFailureCount: number promptFailureCount: number
lastFailureAt?: number
} }

View File

@@ -9,6 +9,14 @@ interface EventInput {
event: Event event: Event
} }
interface ChatMessageInput {
sessionID: string
}
interface ChatMessageOutput {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
/** /**
* Background notification hook - handles event routing to BackgroundManager. * Background notification hook - handles event routing to BackgroundManager.
* *
@@ -20,7 +28,15 @@ export function createBackgroundNotificationHook(manager: BackgroundManager) {
manager.handleEvent(event) manager.handleEvent(event)
} }
const chatMessageHandler = async (
input: ChatMessageInput,
output: ChatMessageOutput,
): Promise<void> => {
manager.injectPendingNotificationsIntoChatMessage(output, input.sessionID)
}
return { return {
"chat.message": chatMessageHandler,
event: eventHandler, event: eventHandler,
} }
} }

View File

@@ -1,6 +1,6 @@
# src/hooks/claude-code-hooks/ — Claude Code Compatibility # src/hooks/claude-code-hooks/ — Claude Code Compatibility
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,6 +1,6 @@
# src/hooks/keyword-detector/ — Mode Keyword Injection # src/hooks/keyword-detector/ — Mode Keyword Injection
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -12,12 +12,16 @@ const TOAST_MESSAGE = [
].join("\n") ].join("\n")
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
function showToast(ctx: PluginInput, sessionID: string): void { type NoHephaestusNonGptHookOptions = {
allowNonGptModel?: boolean
}
function showToast(ctx: PluginInput, sessionID: string, variant: "error" | "warning"): void {
ctx.client.tui.showToast({ ctx.client.tui.showToast({
body: { body: {
title: TOAST_TITLE, title: TOAST_TITLE,
message: TOAST_MESSAGE, message: TOAST_MESSAGE,
variant: "error", variant,
duration: 10000, duration: 10000,
}, },
}).catch((error) => { }).catch((error) => {
@@ -28,7 +32,10 @@ function showToast(ctx: PluginInput, sessionID: string): void {
}) })
} }
export function createNoHephaestusNonGptHook(ctx: PluginInput) { export function createNoHephaestusNonGptHook(
ctx: PluginInput,
options?: NoHephaestusNonGptHookOptions,
) {
return { return {
"chat.message": async (input: { "chat.message": async (input: {
sessionID: string sessionID: string
@@ -40,9 +47,13 @@ export function createNoHephaestusNonGptHook(ctx: PluginInput) {
const rawAgent = input.agent ?? getSessionAgent(input.sessionID) ?? "" const rawAgent = input.agent ?? getSessionAgent(input.sessionID) ?? ""
const agentKey = getAgentConfigKey(rawAgent) const agentKey = getAgentConfigKey(rawAgent)
const modelID = input.model?.modelID const modelID = input.model?.modelID
const allowNonGptModel = options?.allowNonGptModel === true
if (agentKey === "hephaestus" && modelID && !isGptModel(modelID)) { if (agentKey === "hephaestus" && modelID && !isGptModel(modelID)) {
showToast(ctx, input.sessionID) showToast(ctx, input.sessionID, allowNonGptModel ? "warning" : "error")
if (allowNonGptModel) {
return
}
input.agent = SISYPHUS_DISPLAY input.agent = SISYPHUS_DISPLAY
if (output?.message) { if (output?.message) {
output.message.agent = SISYPHUS_DISPLAY output.message.agent = SISYPHUS_DISPLAY

View File

@@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, expect, spyOn, test } from "bun:test" import { describe, expect, spyOn, test } from "bun:test"
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state" import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
import { getAgentDisplayName } from "../../shared/agent-display-names" import { getAgentDisplayName } from "../../shared/agent-display-names"
@@ -8,7 +10,7 @@ const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
function createOutput() { function createOutput() {
return { return {
message: {}, message: {} as { agent?: string; [key: string]: unknown },
parts: [], parts: [],
} }
} }
@@ -16,7 +18,7 @@ function createOutput() {
describe("no-hephaestus-non-gpt hook", () => { describe("no-hephaestus-non-gpt hook", () => {
test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => { test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => {
// given - hephaestus with claude model // given - hephaestus with claude model
const showToast = spyOn({ fn: async () => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) } as any)
@@ -49,9 +51,38 @@ describe("no-hephaestus-non-gpt hook", () => {
}) })
}) })
test("shows warning and does not switch agent when allow_non_gpt_model is enabled", async () => {
// given - hephaestus with claude model and opt-out enabled
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({
client: { tui: { showToast } },
} as any, {
allowNonGptModel: true,
})
const output = createOutput()
// when - chat.message runs
await hook["chat.message"]?.({
sessionID: "ses_opt_out",
agent: HEPHAESTUS_DISPLAY,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}, output)
// then - warning toast is shown but agent is not switched
expect(showToast).toHaveBeenCalledTimes(1)
expect(output.message.agent).toBeUndefined()
expect(showToast.mock.calls[0]?.[0]).toMatchObject({
body: {
title: "NEVER Use Hephaestus with Non-GPT",
variant: "warning",
},
})
})
test("does not show toast when hephaestus uses gpt model", async () => { test("does not show toast when hephaestus uses gpt model", async () => {
// given - hephaestus with gpt model // given - hephaestus with gpt model
const showToast = spyOn({ fn: async () => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) } as any)
@@ -72,7 +103,7 @@ describe("no-hephaestus-non-gpt hook", () => {
test("does not show toast for non-hephaestus agent", async () => { test("does not show toast for non-hephaestus agent", async () => {
// given - sisyphus with claude model (non-gpt) // given - sisyphus with claude model (non-gpt)
const showToast = spyOn({ fn: async () => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) } as any)
@@ -95,7 +126,7 @@ describe("no-hephaestus-non-gpt hook", () => {
// given - session agent saved as hephaestus // given - session agent saved as hephaestus
_resetForTesting() _resetForTesting()
updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY) updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY)
const showToast = spyOn({ fn: async () => ({}) }, "fn") const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
const hook = createNoHephaestusNonGptHook({ const hook = createNoHephaestusNonGptHook({
client: { tui: { showToast } }, client: { tui: { showToast } },
} as any) } as any)

View File

@@ -45,6 +45,23 @@ function createMockCtx() {
} }
} }
function setupImmediateTimeouts(): () => void {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
callback(...args)
return 1 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = (() => {}) as typeof clearTimeout
return () => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
}
}
describe("preemptive-compaction", () => { describe("preemptive-compaction", () => {
let ctx: ReturnType<typeof createMockCtx> let ctx: ReturnType<typeof createMockCtx>
@@ -63,7 +80,7 @@ describe("preemptive-compaction", () => {
// #when tool.execute.after is called // #when tool.execute.after is called
// #then session.messages() should NOT be called // #then session.messages() should NOT be called
it("should use cached token info instead of fetching session.messages()", async () => { it("should use cached token info instead of fetching session.messages()", async () => {
const hook = createPreemptiveCompactionHook(ctx as never) const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_test1" const sessionID = "ses_test1"
// Simulate message.updated with token info below threshold // Simulate message.updated with token info below threshold
@@ -101,7 +118,7 @@ describe("preemptive-compaction", () => {
// #when tool.execute.after is called // #when tool.execute.after is called
// #then should skip without fetching // #then should skip without fetching
it("should skip gracefully when no cached token info exists", async () => { it("should skip gracefully when no cached token info exists", async () => {
const hook = createPreemptiveCompactionHook(ctx as never) const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const output = { title: "", output: "test", metadata: null } const output = { title: "", output: "test", metadata: null }
await hook["tool.execute.after"]( await hook["tool.execute.after"](
@@ -116,7 +133,7 @@ describe("preemptive-compaction", () => {
// #when tool.execute.after runs // #when tool.execute.after runs
// #then should trigger summarize // #then should trigger summarize
it("should trigger compaction when usage exceeds threshold", async () => { it("should trigger compaction when usage exceeds threshold", async () => {
const hook = createPreemptiveCompactionHook(ctx as never) const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_high" const sessionID = "ses_high"
// 170K input + 10K cache = 180K → 90% of 200K // 170K input + 10K cache = 180K → 90% of 200K
@@ -153,7 +170,7 @@ describe("preemptive-compaction", () => {
it("should trigger compaction for google-vertex-anthropic provider", async () => { it("should trigger compaction for google-vertex-anthropic provider", async () => {
//#given google-vertex-anthropic usage above threshold //#given google-vertex-anthropic usage above threshold
const hook = createPreemptiveCompactionHook(ctx as never) const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_vertex_anthropic_high" const sessionID = "ses_vertex_anthropic_high"
await hook.event({ await hook.event({
@@ -191,7 +208,7 @@ describe("preemptive-compaction", () => {
// #given session deleted // #given session deleted
// #then cache should be cleaned up // #then cache should be cleaned up
it("should clean up cache on session.deleted", async () => { it("should clean up cache on session.deleted", async () => {
const hook = createPreemptiveCompactionHook(ctx as never) const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_del" const sessionID = "ses_del"
await hook.event({ await hook.event({
@@ -228,7 +245,7 @@ describe("preemptive-compaction", () => {
it("should log summarize errors instead of swallowing them", async () => { it("should log summarize errors instead of swallowing them", async () => {
//#given //#given
const hook = createPreemptiveCompactionHook(ctx as never) const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_log_error" const sessionID = "ses_log_error"
const summarizeError = new Error("summarize failed") const summarizeError = new Error("summarize failed")
ctx.client.session.summarize.mockRejectedValueOnce(summarizeError) ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
@@ -343,4 +360,58 @@ describe("preemptive-compaction", () => {
//#then //#then
expect(ctx.client.session.summarize).not.toHaveBeenCalled() expect(ctx.client.session.summarize).not.toHaveBeenCalled()
}) })
it("should clear in-progress lock when summarize times out", async () => {
//#given
const restoreTimeouts = setupImmediateTimeouts()
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_timeout"
ctx.client.session.summarize
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValueOnce({})
try {
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
//#when
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_timeout_1" },
{ title: "", output: "test", metadata: null },
)
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_timeout_2" },
{ title: "", output: "test", metadata: null },
)
//#then
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(2)
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
sessionID,
error: expect.stringContaining("Compaction summarize timed out"),
})
} finally {
restoreTimeouts()
}
})
}) })

View File

@@ -3,6 +3,7 @@ import type { OhMyOpenCodeConfig } from "../config"
import { resolveCompactionModel } from "./shared/compaction-model-resolver" import { resolveCompactionModel } from "./shared/compaction-model-resolver"
const DEFAULT_ACTUAL_LIMIT = 200_000 const DEFAULT_ACTUAL_LIMIT = 200_000
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
type ModelCacheStateLike = { type ModelCacheStateLike = {
anthropicContext1MEnabled: boolean anthropicContext1MEnabled: boolean
@@ -31,6 +32,26 @@ interface CachedCompactionState {
tokens: TokenInfo tokens: TokenInfo
} }
function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
}
})
}
function isAnthropicProvider(providerID: string): boolean { function isAnthropicProvider(providerID: string): boolean {
return providerID === "anthropic" || providerID === "google-vertex-anthropic" return providerID === "anthropic" || providerID === "google-vertex-anthropic"
} }
@@ -94,11 +115,15 @@ export function createPreemptiveCompactionHook(
modelID modelID
) )
await ctx.client.session.summarize({ await withTimeout(
path: { id: sessionID }, ctx.client.session.summarize({
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never, path: { id: sessionID },
query: { directory: ctx.directory }, body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
}) query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID) compactedSessions.add(sessionID)
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,6 @@
# src/hooks/ralph-loop/ — Self-Referential Dev Loop # src/hooks/ralph-loop/ — Self-Referential Dev Loop
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -33,15 +33,6 @@ export async function continueIteration(
return return
} }
const boundState = options.loopState.setSessionID(newSessionID)
if (!boundState) {
log(`[${HOOK_NAME}] Failed to bind loop state to new session`, {
previousSessionID: options.previousSessionID,
newSessionID,
})
return
}
await injectContinuationPrompt(ctx, { await injectContinuationPrompt(ctx, {
sessionID: newSessionID, sessionID: newSessionID,
inheritFromSessionID: options.previousSessionID, inheritFromSessionID: options.previousSessionID,
@@ -51,6 +42,16 @@ export async function continueIteration(
}) })
await selectSessionInTui(ctx.client, newSessionID) await selectSessionInTui(ctx.client, newSessionID)
const boundState = options.loopState.setSessionID(newSessionID)
if (!boundState) {
log(`[${HOOK_NAME}] Failed to bind loop state to new session`, {
previousSessionID: options.previousSessionID,
newSessionID,
})
return
}
return return
} }

View File

@@ -0,0 +1,113 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { createRalphLoopHook } from "./index"
function createDeferred(): {
promise: Promise<void>
resolve: () => void
} {
let resolvePromise: (() => void) | null = null
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve
})
return {
promise,
resolve: () => {
if (resolvePromise) {
resolvePromise()
}
},
}
}
async function waitUntil(condition: () => boolean): Promise<void> {
for (let index = 0; index < 100; index++) {
if (condition()) {
return
}
await new Promise<void>((resolve) => {
setTimeout(resolve, 0)
})
}
throw new Error("Condition was not met in time")
}
describe("ralph-loop reset strategy race condition", () => {
test("should continue iteration when old session idle arrives before TUI switch completes", async () => {
// given - reset strategy loop with blocked TUI session switch
const promptCalls: Array<{ sessionID: string; text: string }> = []
const createSessionCalls: Array<{ parentID?: string }> = []
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 {}
},
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 {}
},
},
},
} as Parameters<typeof createRalphLoopHook>[0])
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
// when - first idle is in-flight and old session fires idle again before TUI switch resolves
const firstIdleEvent = hook.event({
event: { type: "session.idle", properties: { sessionID: "session-old" } },
})
await waitUntil(() => selectSessionCalls > 0)
const secondIdleEvent = hook.event({
event: { type: "session.idle", properties: { sessionID: "session-old" } },
})
await waitUntil(() => selectSessionCalls > 1)
selectSessionDeferred.resolve()
await Promise.all([firstIdleEvent, secondIdleEvent])
// then - second idle should not be skipped during reset transition
expect(createSessionCalls.length).toBe(2)
expect(promptCalls.length).toBe(2)
expect(hook.getState()?.iteration).toBe(3)
})
})

View File

@@ -1,6 +1,6 @@
# src/hooks/rules-injector/ — Conditional Rules Injection # src/hooks/rules-injector/ — Conditional Rules Injection
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -3,6 +3,7 @@ const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:te
const { createSessionNotification } = require("./session-notification") const { createSessionNotification } = require("./session-notification")
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state") const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
const utils = require("./session-notification-utils") const utils = require("./session-notification-utils")
const sender = require("./session-notification-sender")
describe("session-notification input-needed events", () => { describe("session-notification input-needed events", () => {
let notificationCalls: string[] let notificationCalls: string[]
@@ -37,6 +38,10 @@ describe("session-notification input-needed events", () => {
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send") spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell") spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {}) spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx: unknown, _platform: unknown, _title: unknown, message: string) => {
notificationCalls.push(message)
})
}) })
afterEach(() => { afterEach(() => {
@@ -47,7 +52,7 @@ describe("session-notification input-needed events", () => {
test("sends question notification when question tool asks for input", async () => { test("sends question notification when question tool asks for input", async () => {
const sessionID = "main-question" const sessionID = "main-question"
setMainSession(sessionID) setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput()) const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
await hook({ await hook({
event: { event: {
@@ -74,7 +79,7 @@ describe("session-notification input-needed events", () => {
test("sends permission notification for permission events", async () => { test("sends permission notification for permission events", async () => {
const sessionID = "main-permission" const sessionID = "main-permission"
setMainSession(sessionID) setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput()) const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
await hook({ await hook({
event: { event: {

View File

@@ -1,8 +1,9 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
import { createSessionNotification } from "./session-notification" import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import * as utils from "./session-notification-utils" import * as utils from "./session-notification-utils"
import * as sender from "./session-notification-sender"
describe("session-notification", () => { describe("session-notification", () => {
let notificationCalls: string[] let notificationCalls: string[]
@@ -40,6 +41,10 @@ describe("session-notification", () => {
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay") spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay") spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {}) spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx, _platform, _title, message) => {
notificationCalls.push(message)
})
}) })
afterEach(() => { afterEach(() => {
@@ -105,6 +110,7 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), { const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 10, idleConfirmationDelay: 10,
skipIfIncompleteTodos: false, skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
}) })
// when - main session goes idle // when - main session goes idle
@@ -332,6 +338,7 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), { const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 10, idleConfirmationDelay: 10,
skipIfIncompleteTodos: false, skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
}) })
// when - session goes idle twice // when - session goes idle twice

View File

@@ -4,11 +4,9 @@ import {
startBackgroundCheck, startBackgroundCheck,
} from "./session-notification-utils" } from "./session-notification-utils"
import { import {
detectPlatform, type Platform,
getDefaultSoundPath,
playSessionNotificationSound,
sendSessionNotification,
} from "./session-notification-sender" } from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import { hasIncompleteTodos } from "./session-todo-status" import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler" import { createIdleNotificationScheduler } from "./session-notification-scheduler"
@@ -25,13 +23,14 @@ interface SessionNotificationConfig {
skipIfIncompleteTodos?: boolean skipIfIncompleteTodos?: boolean
/** Maximum number of sessions to track before cleanup (default: 100) */ /** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number maxTrackedSessions?: number
enforceMainSessionFilter?: boolean
} }
export function createSessionNotification( export function createSessionNotification(
ctx: PluginInput, ctx: PluginInput,
config: SessionNotificationConfig = {} config: SessionNotificationConfig = {}
) { ) {
const currentPlatform = detectPlatform() const currentPlatform: Platform = sessionNotificationSender.detectPlatform()
const defaultSoundPath = getDefaultSoundPath(currentPlatform) const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform)
startBackgroundCheck(currentPlatform) startBackgroundCheck(currentPlatform)
@@ -45,6 +44,7 @@ export function createSessionNotification(
idleConfirmationDelay: 1500, idleConfirmationDelay: 1500,
skipIfIncompleteTodos: true, skipIfIncompleteTodos: true,
maxTrackedSessions: 100, maxTrackedSessions: 100,
enforceMainSessionFilter: true,
...config, ...config,
} }
@@ -53,8 +53,8 @@ export function createSessionNotification(
platform: currentPlatform, platform: currentPlatform,
config: mergedConfig, config: mergedConfig,
hasIncompleteTodos, hasIncompleteTodos,
send: sendSessionNotification, send: sessionNotificationSender.sendSessionNotification,
playSound: playSessionNotificationSound, playSound: sessionNotificationSender.playSessionNotificationSound,
}) })
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"]) const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
@@ -81,8 +81,10 @@ export function createSessionNotification(
const shouldNotifyForSession = (sessionID: string): boolean => { const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false if (subagentSessions.has(sessionID)) return false
const mainSessionID = getMainSessionID() if (mergedConfig.enforceMainSessionFilter) {
if (mainSessionID && sessionID !== mainSessionID) return false const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return false
}
return true return true
} }
@@ -146,9 +148,14 @@ export function createSessionNotification(
if (!shouldNotifyForSession(sessionID)) return if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID) scheduler.markSessionActivity(sessionID)
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.permissionMessage) await sessionNotificationSender.sendSessionNotification(
ctx,
currentPlatform,
mergedConfig.title,
mergedConfig.permissionMessage,
)
if (mergedConfig.playSound && mergedConfig.soundPath) { if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath) await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
} }
return return
} }
@@ -168,9 +175,9 @@ export function createSessionNotification(
? mergedConfig.permissionMessage ? mergedConfig.permissionMessage
: mergedConfig.questionMessage : mergedConfig.questionMessage
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message) await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
if (mergedConfig.playSound && mergedConfig.soundPath) { if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath) await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
} }
} }
} }

View File

@@ -1,6 +1,6 @@
# src/hooks/session-recovery/ — Auto Session Error Recovery # src/hooks/session-recovery/ — Auto Session Error Recovery
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -7,9 +7,12 @@ import { createStartWorkHook } from "./index"
import { import {
writeBoulderState, writeBoulderState,
clearBoulderState, clearBoulderState,
readBoulderState,
} from "../../features/boulder-state" } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state"
import * as sessionState from "../../features/claude-code-session-state" import * as sessionState from "../../features/claude-code-session-state"
import * as worktreeDetector from "./worktree-detector"
import * as worktreeDetector from "./worktree-detector"
describe("start-work hook", () => { describe("start-work hook", () => {
let testDir: string let testDir: string
@@ -402,4 +405,152 @@ describe("start-work hook", () => {
updateSpy.mockRestore() updateSpy.mockRestore()
}) })
}) })
describe("worktree support", () => {
let detectSpy: ReturnType<typeof spyOn>
beforeEach(() => {
detectSpy = spyOn(worktreeDetector, "detectWorktreePath").mockReturnValue(null)
})
afterEach(() => {
detectSpy.mockRestore()
})
test("should inject model-decides instructions when no --worktree flag", async () => {
// given - single plan, no worktree flag
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - model-decides instructions should appear
expect(output.parts[0].text).toContain("Worktree Setup Required")
expect(output.parts[0].text).toContain("git worktree list --porcelain")
expect(output.parts[0].text).toContain("git worktree add")
})
test("should inject worktree path when --worktree flag is valid", async () => {
// given - single plan + valid worktree path
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
detectSpy.mockReturnValue("/validated/worktree")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /validated/worktree</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - validated path shown, no model-decides instructions
expect(output.parts[0].text).toContain("**Worktree**: /validated/worktree")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
test("should store worktree_path in boulder when --worktree is valid", async () => {
// given - plan + valid worktree
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
detectSpy.mockReturnValue("/valid/wt")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /valid/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - boulder.json has worktree_path
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBe("/valid/wt")
})
test("should NOT store worktree_path when --worktree path is invalid", async () => {
// given - plan + invalid worktree path (detectWorktreePath returns null)
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
// detectSpy already returns null by default
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /nonexistent/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - worktree_path absent, setup instructions present
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBeUndefined()
expect(output.parts[0].text).toContain("needs setup")
expect(output.parts[0].text).toContain("git worktree add /nonexistent/wt")
})
test("should update boulder worktree_path on resume when new --worktree given", async () => {
// given - existing boulder with old worktree, user provides new worktree
const planPath = join(testDir, "plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const existingState: BoulderState = {
active_plan: planPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "plan",
worktree_path: "/old/wt",
}
writeBoulderState(testDir, existingState)
detectSpy.mockReturnValue("/new/wt")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /new/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-456" }, output)
// then - boulder reflects updated worktree and new session appended
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBe("/new/wt")
expect(state?.session_ids).toContain("session-456")
})
test("should show existing worktree on resume when no --worktree flag", async () => {
// given - existing boulder already has worktree_path, no flag given
const planPath = join(testDir, "plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const existingState: BoulderState = {
active_plan: planPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "plan",
worktree_path: "/existing/wt",
}
writeBoulderState(testDir, existingState)
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-789" }, output)
// then - shows existing worktree, no model-decides instructions
expect(output.parts[0].text).toContain("/existing/wt")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
})
}) })

View File

@@ -1 +1,4 @@
export { HOOK_NAME, createStartWorkHook } from "./start-work-hook" export { HOOK_NAME, createStartWorkHook } from "./start-work-hook"
export { detectWorktreePath } from "./worktree-detector"
export type { ParsedUserRequest } from "./parse-user-request"
export { parseUserRequest } from "./parse-user-request"

View File

@@ -0,0 +1,78 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { parseUserRequest } from "./parse-user-request"
describe("parseUserRequest", () => {
describe("when no user-request tag", () => {
test("#given prompt without tag #when parsing #then returns nulls", () => {
const result = parseUserRequest("Just a regular message without any tags")
expect(result.planName).toBeNull()
expect(result.explicitWorktreePath).toBeNull()
})
})
describe("when user-request tag is empty", () => {
test("#given empty user-request tag #when parsing #then returns nulls", () => {
const result = parseUserRequest("<user-request> </user-request>")
expect(result.planName).toBeNull()
expect(result.explicitWorktreePath).toBeNull()
})
})
describe("when only plan name given", () => {
test("#given plan name without worktree flag #when parsing #then returns plan name with null worktree", () => {
const result = parseUserRequest("<session-context>\n<user-request>my-plan</user-request>\n</session-context>")
expect(result.planName).toBe("my-plan")
expect(result.explicitWorktreePath).toBeNull()
})
})
describe("when only --worktree flag given", () => {
test("#given --worktree with path only #when parsing #then returns worktree path with null plan", () => {
const result = parseUserRequest("<user-request>--worktree /home/user/repo-feat</user-request>")
expect(result.planName).toBeNull()
expect(result.explicitWorktreePath).toBe("/home/user/repo-feat")
})
})
describe("when plan name and --worktree are both given", () => {
test("#given plan name before --worktree #when parsing #then returns both", () => {
const result = parseUserRequest("<user-request>my-plan --worktree /path/to/worktree</user-request>")
expect(result.planName).toBe("my-plan")
expect(result.explicitWorktreePath).toBe("/path/to/worktree")
})
test("#given --worktree before plan name #when parsing #then returns both", () => {
const result = parseUserRequest("<user-request>--worktree /path/to/worktree my-plan</user-request>")
expect(result.planName).toBe("my-plan")
expect(result.explicitWorktreePath).toBe("/path/to/worktree")
})
})
describe("when --worktree flag has no path", () => {
test("#given --worktree without path #when parsing #then worktree path is null", () => {
const result = parseUserRequest("<user-request>--worktree</user-request>")
expect(result.explicitWorktreePath).toBeNull()
})
})
describe("when ultrawork keywords are present", () => {
test("#given plan name with ultrawork keyword #when parsing #then strips keyword from plan name", () => {
const result = parseUserRequest("<user-request>my-plan ultrawork</user-request>")
expect(result.planName).toBe("my-plan")
})
test("#given plan name with ulw keyword and worktree #when parsing #then strips ulw, preserves worktree", () => {
const result = parseUserRequest("<user-request>my-plan ulw --worktree /path/to/wt</user-request>")
expect(result.planName).toBe("my-plan")
expect(result.explicitWorktreePath).toBe("/path/to/wt")
})
test("#given only ultrawork keyword with worktree #when parsing #then plan name is null, worktree preserved", () => {
const result = parseUserRequest("<user-request>ultrawork --worktree /wt</user-request>")
expect(result.planName).toBeNull()
expect(result.explicitWorktreePath).toBe("/wt")
})
})
})

View File

@@ -0,0 +1,29 @@
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
const WORKTREE_FLAG_PATTERN = /--worktree(?:\s+(\S+))?/
export interface ParsedUserRequest {
planName: string | null
explicitWorktreePath: string | null
}
export function parseUserRequest(promptText: string): ParsedUserRequest {
const match = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
if (!match) return { planName: null, explicitWorktreePath: null }
let rawArg = match[1].trim()
if (!rawArg) return { planName: null, explicitWorktreePath: null }
const worktreeMatch = rawArg.match(WORKTREE_FLAG_PATTERN)
const explicitWorktreePath = worktreeMatch ? (worktreeMatch[1] ?? null) : null
if (worktreeMatch) {
rawArg = rawArg.replace(worktreeMatch[0], "").trim()
}
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
return {
planName: cleanedArg || null,
explicitWorktreePath,
}
}

View File

@@ -1,3 +1,4 @@
import { statSync } from "node:fs"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { import {
readBoulderState, readBoulderState,
@@ -11,11 +12,11 @@ import {
} from "../../features/boulder-state" } from "../../features/boulder-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { updateSessionAgent } from "../../features/claude-code-session-state" import { updateSessionAgent } from "../../features/claude-code-session-state"
import { detectWorktreePath } from "./worktree-detector"
import { parseUserRequest } from "./parse-user-request"
export const HOOK_NAME = "start-work" as const export const HOOK_NAME = "start-work" as const
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
interface StartWorkHookInput { interface StartWorkHookInput {
sessionID: string sessionID: string
messageID?: string messageID?: string
@@ -25,73 +26,76 @@ interface StartWorkHookOutput {
parts: Array<{ type: string; text?: string }> parts: Array<{ type: string; text?: string }>
} }
function extractUserRequestPlanName(promptText: string): string | null {
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
if (!userRequestMatch) return null
const rawArg = userRequestMatch[1].trim()
if (!rawArg) return null
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
return cleanedArg || null
}
function findPlanByName(plans: string[], requestedName: string): string | null { function findPlanByName(plans: string[], requestedName: string): string | null {
const lowerName = requestedName.toLowerCase() const lowerName = requestedName.toLowerCase()
const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName)
const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName)
if (exactMatch) return exactMatch if (exactMatch) return exactMatch
const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName))
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
return partialMatch || null return partialMatch || null
} }
const MODEL_DECIDES_WORKTREE_BLOCK = `
## Worktree Setup Required
No worktree specified. Before starting work, you MUST choose or create one:
1. \`git worktree list --porcelain\` — list existing worktrees
2. Create if needed: \`git worktree add <absolute-path> <branch-or-HEAD>\`
3. Update \`.sisyphus/boulder.json\` — add \`"worktree_path": "<absolute-path>"\`
4. Work exclusively inside that worktree directory`
function resolveWorktreeContext(
explicitWorktreePath: string | null,
): { worktreePath: string | undefined; block: string } {
if (explicitWorktreePath === null) {
return { worktreePath: undefined, block: MODEL_DECIDES_WORKTREE_BLOCK }
}
const validatedPath = detectWorktreePath(explicitWorktreePath)
if (validatedPath) {
return { worktreePath: validatedPath, block: `\n**Worktree**: ${validatedPath}` }
}
return {
worktreePath: undefined,
block: `\n**Worktree** (needs setup): \`git worktree add ${explicitWorktreePath} <branch>\`, then add \`"worktree_path"\` to boulder.json`,
}
}
export function createStartWorkHook(ctx: PluginInput) { export function createStartWorkHook(ctx: PluginInput) {
return { return {
"chat.message": async ( "chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => {
input: StartWorkHookInput,
output: StartWorkHookOutput
): Promise<void> => {
const parts = output.parts const parts = output.parts
const promptText = parts const promptText =
?.filter((p) => p.type === "text" && p.text) parts
.map((p) => p.text) ?.filter((p) => p.type === "text" && p.text)
.join("\n") .map((p) => p.text)
.trim() || "" .join("\n")
.trim() || ""
// Only trigger on actual command execution (contains <session-context> tag) if (!promptText.includes("<session-context>")) return
// NOT on description text like "Start Sisyphus work session from Prometheus plan"
const isStartWorkCommand = promptText.includes("<session-context>")
if (!isStartWorkCommand) { log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
return updateSessionAgent(input.sessionID, "atlas")
}
log(`[${HOOK_NAME}] Processing start-work command`, {
sessionID: input.sessionID,
})
updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298
const existingState = readBoulderState(ctx.directory) const existingState = readBoulderState(ctx.directory)
const sessionId = input.sessionID const sessionId = input.sessionID
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
let contextInfo = "" let contextInfo = ""
const explicitPlanName = extractUserRequestPlanName(promptText)
if (explicitPlanName) { if (explicitPlanName) {
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
sessionID: input.sessionID,
})
const allPlans = findPrometheusPlans(ctx.directory) const allPlans = findPrometheusPlans(ctx.directory)
const matchedPlan = findPlanByName(allPlans, explicitPlanName) const matchedPlan = findPlanByName(allPlans, explicitPlanName)
if (matchedPlan) { if (matchedPlan) {
const progress = getPlanProgress(matchedPlan) const progress = getPlanProgress(matchedPlan)
if (progress.isComplete) { if (progress.isComplete) {
contextInfo = ` contextInfo = `
## Plan Already Complete ## Plan Already Complete
@@ -99,12 +103,10 @@ export function createStartWorkHook(ctx: PluginInput) {
The requested plan "${getPlanName(matchedPlan)}" has been completed. The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
} else { } else {
if (existingState) { if (existingState) clearBoulderState(ctx.directory)
clearBoulderState(ctx.directory) const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath)
}
const newState = createBoulderState(matchedPlan, sessionId, "atlas")
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo = ` contextInfo = `
## Auto-Selected Plan ## Auto-Selected Plan
@@ -113,17 +115,20 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
**Progress**: ${progress.completed}/${progress.total} tasks **Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId} **Session ID**: ${sessionId}
**Started**: ${timestamp} **Started**: ${timestamp}
${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.` boulder.json has been created. Read the plan and begin execution.`
} }
} else { } else {
const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
if (incompletePlans.length > 0) { if (incompletePlans.length > 0) {
const planList = incompletePlans.map((p, i) => { const planList = incompletePlans
const prog = getPlanProgress(p) .map((p, i) => {
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` const prog = getPlanProgress(p)
}).join("\n") return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
})
.join("\n")
contextInfo = ` contextInfo = `
## Plan Not Found ## Plan Not Found
@@ -143,9 +148,25 @@ No incomplete plans available. Create a new plan with: /plan "your task"`
} }
} else if (existingState) { } else if (existingState) {
const progress = getPlanProgress(existingState.active_plan) const progress = getPlanProgress(existingState.active_plan)
if (!progress.isComplete) { if (!progress.isComplete) {
appendSessionId(ctx.directory, sessionId) const effectiveWorktree = worktreePath ?? existingState.worktree_path
if (worktreePath !== undefined) {
const updatedSessions = existingState.session_ids.includes(sessionId)
? existingState.session_ids
: [...existingState.session_ids, sessionId]
writeBoulderState(ctx.directory, {
...existingState,
worktree_path: worktreePath,
session_ids: updatedSessions,
})
} else {
appendSessionId(ctx.directory, sessionId)
}
const worktreeDisplay = effectiveWorktree ? `\n**Worktree**: ${effectiveWorktree}` : worktreeBlock
contextInfo = ` contextInfo = `
## Active Work Session Found ## Active Work Session Found
@@ -155,6 +176,7 @@ No incomplete plans available. Create a new plan with: /plan "your task"`
**Progress**: ${progress.completed}/${progress.total} tasks completed **Progress**: ${progress.completed}/${progress.total} tasks completed
**Sessions**: ${existingState.session_ids.length + 1} (current session appended) **Sessions**: ${existingState.session_ids.length + 1} (current session appended)
**Started**: ${existingState.started_at} **Started**: ${existingState.started_at}
${worktreeDisplay}
The current session (${sessionId}) has been added to session_ids. The current session (${sessionId}) has been added to session_ids.
Read the plan file and continue from the first unchecked task.` Read the plan file and continue from the first unchecked task.`
@@ -167,13 +189,15 @@ Looking for new plans...`
} }
} }
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) { if (
(!existingState && !explicitPlanName) ||
(existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
) {
const plans = findPrometheusPlans(ctx.directory) const plans = findPrometheusPlans(ctx.directory)
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete) const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
if (plans.length === 0) { if (plans.length === 0) {
contextInfo += ` contextInfo += `
## No Plans Found ## No Plans Found
No Prometheus plan files found at .sisyphus/plans/ No Prometheus plan files found at .sisyphus/plans/
@@ -187,7 +211,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
} else if (incompletePlans.length === 1) { } else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0] const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath) const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, "atlas") const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath)
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo += ` contextInfo += `
@@ -199,15 +223,17 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
**Progress**: ${progress.completed}/${progress.total} tasks **Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId} **Session ID**: ${sessionId}
**Started**: ${timestamp} **Started**: ${timestamp}
${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.` boulder.json has been created. Read the plan and begin execution.`
} else { } else {
const planList = incompletePlans.map((p, i) => { const planList = incompletePlans
const progress = getPlanProgress(p) .map((p, i) => {
const stat = require("node:fs").statSync(p) const progress = getPlanProgress(p)
const modified = new Date(stat.mtimeMs).toISOString() const modified = new Date(statSync(p).mtimeMs).toISOString()
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
}).join("\n") })
.join("\n")
contextInfo += ` contextInfo += `
@@ -220,6 +246,7 @@ Session ID: ${sessionId}
${planList} ${planList}
Ask the user which plan to work on. Present the options above and wait for their response. Ask the user which plan to work on. Present the options above and wait for their response.
${worktreeBlock}
</system-reminder>` </system-reminder>`
} }
} }
@@ -229,13 +256,14 @@ Ask the user which plan to work on. Present the options above and wait for their
output.parts[idx].text = output.parts[idx].text output.parts[idx].text = output.parts[idx].text
.replace(/\$SESSION_ID/g, sessionId) .replace(/\$SESSION_ID/g, sessionId)
.replace(/\$TIMESTAMP/g, timestamp) .replace(/\$TIMESTAMP/g, timestamp)
output.parts[idx].text += `\n\n---\n${contextInfo}` output.parts[idx].text += `\n\n---\n${contextInfo}`
} }
log(`[${HOOK_NAME}] Context injected`, { log(`[${HOOK_NAME}] Context injected`, {
sessionID: input.sessionID, sessionID: input.sessionID,
hasExistingState: !!existingState, hasExistingState: !!existingState,
worktreePath,
}) })
}, },
} }

View File

@@ -0,0 +1,79 @@
/// <reference types="bun-types" />
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import * as childProcess from "node:child_process"
import { detectWorktreePath } from "./worktree-detector"
describe("detectWorktreePath", () => {
let execFileSyncSpy: ReturnType<typeof spyOn>
beforeEach(() => {
execFileSyncSpy = spyOn(childProcess, "execFileSync").mockImplementation(
((_file: string, _args: string[]) => "") as typeof childProcess.execFileSync,
)
})
afterEach(() => {
execFileSyncSpy.mockRestore()
})
describe("when directory is a valid git worktree", () => {
test("#given valid git dir #when detecting #then returns worktree root path", () => {
execFileSyncSpy.mockImplementation(
((_file: string, _args: string[]) => "/home/user/my-repo\n") as typeof childProcess.execFileSync,
)
// when
const result = detectWorktreePath("/home/user/my-repo/src")
// then
expect(result).toBe("/home/user/my-repo")
})
test("#given git output with trailing newline #when detecting #then trims output", () => {
execFileSyncSpy.mockImplementation(
((_file: string, _args: string[]) => "/projects/worktree-a\n\n") as typeof childProcess.execFileSync,
)
const result = detectWorktreePath("/projects/worktree-a")
expect(result).toBe("/projects/worktree-a")
})
test("#given valid dir #when detecting #then calls git rev-parse with cwd", () => {
execFileSyncSpy.mockImplementation(
((_file: string, _args: string[]) => "/repo\n") as typeof childProcess.execFileSync,
)
detectWorktreePath("/repo/some/subdir")
expect(execFileSyncSpy).toHaveBeenCalledWith(
"git",
["rev-parse", "--show-toplevel"],
expect.objectContaining({ cwd: "/repo/some/subdir" }),
)
})
})
describe("when directory is not a git worktree", () => {
test("#given non-git directory #when detecting #then returns null", () => {
execFileSyncSpy.mockImplementation((_file: string, _args: string[]) => {
throw new Error("not a git repository")
})
const result = detectWorktreePath("/tmp/not-a-repo")
expect(result).toBeNull()
})
test("#given non-existent directory #when detecting #then returns null", () => {
execFileSyncSpy.mockImplementation((_file: string, _args: string[]) => {
throw new Error("ENOENT: no such file or directory")
})
const result = detectWorktreePath("/nonexistent/path")
expect(result).toBeNull()
})
})
})

View File

@@ -0,0 +1,14 @@
import { execFileSync } from "node:child_process"
export function detectWorktreePath(directory: string): string | null {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
} catch {
return null
}
}

View File

@@ -1,6 +1,6 @@
import { detectThinkKeyword, extractPromptText } from "./detector" import { detectThinkKeyword, extractPromptText } from "./detector"
import { getHighVariant, getThinkingConfig, isAlreadyHighVariant } from "./switcher" import { getHighVariant, isAlreadyHighVariant } from "./switcher"
import type { ThinkModeInput, ThinkModeState } from "./types" import type { ThinkModeState } from "./types"
import { log } from "../../shared" import { log } from "../../shared"
const thinkModeState = new Map<string, ThinkModeState>() const thinkModeState = new Map<string, ThinkModeState>()
@@ -10,53 +10,24 @@ export function clearThinkModeState(sessionID: string): void {
} }
export function createThinkModeHook() { export function createThinkModeHook() {
function isDisabledThinkingConfig(config: Record<string, unknown>): boolean {
const thinkingConfig = config.thinking
if (
typeof thinkingConfig === "object" &&
thinkingConfig !== null &&
"type" in thinkingConfig &&
(thinkingConfig as { type?: string }).type === "disabled"
) {
return true
}
const providerOptions = config.providerOptions
if (typeof providerOptions !== "object" || providerOptions === null) {
return false
}
return Object.values(providerOptions as Record<string, unknown>).some(
(providerConfig) => {
if (typeof providerConfig !== "object" || providerConfig === null) {
return false
}
const providerConfigMap = providerConfig as Record<string, unknown>
const extraBody = providerConfigMap.extra_body
if (typeof extraBody !== "object" || extraBody === null) {
return false
}
const extraBodyMap = extraBody as Record<string, unknown>
const extraThinking = extraBodyMap.thinking
return (
typeof extraThinking === "object" &&
extraThinking !== null &&
(extraThinking as { type?: string }).type === "disabled"
)
}
)
}
return { return {
"chat.params": async (output: ThinkModeInput, sessionID: string): Promise<void> => { "chat.message": async (
input: {
sessionID: string
model?: { providerID: string; modelID: string }
},
output: {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
const promptText = extractPromptText(output.parts) const promptText = extractPromptText(output.parts)
const sessionID = input.sessionID
const state: ThinkModeState = { const state: ThinkModeState = {
requested: false, requested: false,
modelSwitched: false, modelSwitched: false,
thinkingConfigInjected: false, variantSet: false,
} }
if (!detectThinkKeyword(promptText)) { if (!detectThinkKeyword(promptText)) {
@@ -66,7 +37,12 @@ export function createThinkModeHook() {
state.requested = true state.requested = true
const currentModel = output.message.model if (typeof output.message.variant === "string") {
thinkModeState.set(sessionID, state)
return
}
const currentModel = input.model
if (!currentModel) { if (!currentModel) {
thinkModeState.set(sessionID, state) thinkModeState.set(sessionID, state)
return return
@@ -81,14 +57,15 @@ export function createThinkModeHook() {
} }
const highVariant = getHighVariant(currentModel.modelID) const highVariant = getHighVariant(currentModel.modelID)
const thinkingConfig = getThinkingConfig(currentModel.providerID, currentModel.modelID)
if (highVariant) { if (highVariant) {
output.message.model = { output.message.model = {
providerID: currentModel.providerID, providerID: currentModel.providerID,
modelID: highVariant, modelID: highVariant,
} }
output.message.variant = "high"
state.modelSwitched = true state.modelSwitched = true
state.variantSet = true
log("Think mode: model switched to high variant", { log("Think mode: model switched to high variant", {
sessionID, sessionID,
from: currentModel.modelID, from: currentModel.modelID,
@@ -96,42 +73,6 @@ export function createThinkModeHook() {
}) })
} }
if (thinkingConfig) {
const messageData = output.message as Record<string, unknown>
const agentThinking = messageData.thinking as { type?: string } | undefined
const agentProviderOptions = messageData.providerOptions
const agentDisabledThinking = agentThinking?.type === "disabled"
const agentHasCustomProviderOptions = Boolean(agentProviderOptions)
if (agentDisabledThinking) {
log("Think mode: skipping - agent has thinking disabled", {
sessionID,
provider: currentModel.providerID,
})
} else if (agentHasCustomProviderOptions) {
log("Think mode: skipping - agent has custom providerOptions", {
sessionID,
provider: currentModel.providerID,
})
} else if (
!isDisabledThinkingConfig(thinkingConfig as Record<string, unknown>)
) {
Object.assign(output.message, thinkingConfig)
state.thinkingConfigInjected = true
log("Think mode: thinking config injected", {
sessionID,
provider: currentModel.providerID,
config: thinkingConfig,
})
} else {
log("Think mode: skipping disabled thinking config", {
sessionID,
provider: currentModel.providerID,
})
}
}
thinkModeState.set(sessionID, state) thinkModeState.set(sessionID, state)
}, },

View File

@@ -1,452 +1,155 @@
import { describe, expect, it, beforeEach } from "bun:test" import { beforeEach, describe, expect, it } from "bun:test"
import type { ThinkModeInput } from "./types"
const { createThinkModeHook, clearThinkModeState } = await import("./index") const { clearThinkModeState, createThinkModeHook } = await import("./index")
type ThinkModeHookInput = {
sessionID: string
model?: { providerID: string; modelID: string }
}
type ThinkModeHookOutput = {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
function createHookInput(args: {
sessionID?: string
providerID?: string
modelID?: string
}): ThinkModeHookInput {
const { sessionID = "test-session-id", providerID, modelID } = args
if (!providerID || !modelID) {
return { sessionID }
}
/**
* Helper to create a mock ThinkModeInput for testing
*/
function createMockInput(
providerID: string,
modelID: string,
promptText: string
): ThinkModeInput {
return { return {
parts: [{ type: "text", text: promptText }], sessionID,
message: { model: { providerID, modelID },
model: {
providerID,
modelID,
},
},
} }
} }
/** function createHookOutput(promptText: string, variant?: string): ThinkModeHookOutput {
* Type helper for accessing dynamically injected properties on message return {
*/ message: variant ? { variant } : {},
type MessageWithInjectedProps = Record<string, unknown> parts: [{ type: "text", text: promptText }],
}
}
describe("createThinkModeHook integration", () => { describe("createThinkModeHook", () => {
const sessionID = "test-session-id" const sessionID = "test-session-id"
beforeEach(() => { beforeEach(() => {
clearThinkModeState(sessionID) clearThinkModeState(sessionID)
}) })
describe("GitHub Copilot provider integration", () => { it("sets high variant and switches model when think keyword is present", async () => {
describe("Claude models", () => { // given
it("should activate thinking mode for github-copilot Claude with think keyword", async () => { const hook = createThinkModeHook()
// given a github-copilot Claude model and prompt with "think" keyword const input = createHookInput({
const hook = createThinkModeHook() sessionID,
const input = createMockInput( providerID: "github-copilot",
"github-copilot", modelID: "claude-opus-4-6",
"claude-opus-4-6",
"Please think deeply about this problem"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should upgrade to high variant and inject thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
expect(message.thinking).toBeDefined()
expect((message.thinking as Record<string, unknown>)?.type).toBe(
"enabled"
)
expect(
(message.thinking as Record<string, unknown>)?.budgetTokens
).toBe(64000)
})
it("should handle github-copilot Claude with dots in version", async () => {
// given a github-copilot Claude model with dot format (claude-opus-4.6)
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"claude-opus-4.6",
"ultrathink mode"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should upgrade to high variant (hyphen format)
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
expect(message.thinking).toBeDefined()
})
it("should handle github-copilot Claude Sonnet", async () => {
// given a github-copilot Claude Sonnet model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"claude-sonnet-4-6",
"think about this"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should upgrade to high variant
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-sonnet-4-6-high")
expect(message.thinking).toBeDefined()
})
}) })
const output = createHookOutput("Please think deeply about this")
describe("Gemini models", () => { // when
it("should activate thinking mode for github-copilot Gemini Pro", async () => { await hook["chat.message"](input, output)
// given a github-copilot Gemini Pro model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"gemini-3-pro",
"think about this"
)
// when the chat.params hook is called // then
await hook["chat.params"](input, sessionID) expect(output.message.variant).toBe("high")
expect(output.message.model).toEqual({
// then should upgrade to high variant and inject google thinking config providerID: "github-copilot",
const message = input.message as MessageWithInjectedProps modelID: "claude-opus-4-6-high",
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
expect(message.providerOptions).toBeDefined()
const googleOptions = (
message.providerOptions as Record<string, unknown>
)?.google as Record<string, unknown>
expect(googleOptions?.thinkingConfig).toBeDefined()
})
it("should activate thinking mode for github-copilot Gemini Flash", async () => {
// given a github-copilot Gemini Flash model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"gemini-3-flash",
"ultrathink"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should upgrade to high variant
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gemini-3-flash-high")
expect(message.providerOptions).toBeDefined()
})
})
describe("GPT models", () => {
it("should activate thinking mode for github-copilot GPT-5.2", async () => {
// given a github-copilot GPT-5.2 model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"gpt-5.2",
"please think"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should upgrade to high variant and inject openai thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5-2-high")
expect(message.reasoning_effort).toBe("high")
})
it("should activate thinking mode for github-copilot GPT-5", async () => {
// given a github-copilot GPT-5 model
const hook = createThinkModeHook()
const input = createMockInput("github-copilot", "gpt-5", "think deeply")
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should upgrade to high variant
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5-high")
expect(message.reasoning_effort).toBe("high")
})
})
describe("No think keyword", () => {
it("should NOT activate for github-copilot without think keyword", async () => {
// given a prompt without any think keyword
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"claude-opus-4-6",
"Just do this task"
)
const originalModelID = input.message.model?.modelID
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should NOT change model or inject config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe(originalModelID)
expect(message.thinking).toBeUndefined()
})
}) })
}) })
describe("Backwards compatibility with direct providers", () => { it("supports dotted model IDs by switching to normalized high variant", async () => {
it("should still work for direct anthropic provider", async () => { // given
// given direct anthropic provider const hook = createThinkModeHook()
const hook = createThinkModeHook() const input = createHookInput({
const input = createMockInput( sessionID,
"anthropic", providerID: "github-copilot",
"claude-sonnet-4-6", modelID: "gpt-5.2",
"think about this"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should work as before
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-sonnet-4-6-high")
expect(message.thinking).toBeDefined()
}) })
const output = createHookOutput("ultrathink about this")
it("should work for direct google-vertex-anthropic provider", async () => { // when
//#given direct google-vertex-anthropic provider await hook["chat.message"](input, output)
const hook = createThinkModeHook()
const input = createMockInput(
"google-vertex-anthropic",
"claude-opus-4-6",
"think deeply"
)
//#when the chat.params hook is called // then
await hook["chat.params"](input, sessionID) expect(output.message.variant).toBe("high")
expect(output.message.model).toEqual({
//#then should upgrade model and inject Claude thinking config providerID: "github-copilot",
const message = input.message as MessageWithInjectedProps modelID: "gpt-5-2-high",
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
expect(message.thinking).toBeDefined()
expect((message.thinking as Record<string, unknown>)?.budgetTokens).toBe(
64000
)
})
it("should still work for direct google provider", async () => {
// given direct google provider
const hook = createThinkModeHook()
const input = createMockInput(
"google",
"gemini-3-pro",
"think about this"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should work as before
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
expect(message.providerOptions).toBeDefined()
})
it("should still work for direct openai provider", async () => {
// given direct openai provider
const hook = createThinkModeHook()
const input = createMockInput("openai", "gpt-5", "think about this")
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should work
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5-high")
expect(message.reasoning_effort).toBe("high")
})
it("should still work for amazon-bedrock provider", async () => {
// given amazon-bedrock provider
const hook = createThinkModeHook()
const input = createMockInput(
"amazon-bedrock",
"claude-sonnet-4-6",
"think"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should inject bedrock thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-sonnet-4-6-high")
expect(message.reasoningConfig).toBeDefined()
}) })
}) })
describe("Already-high variants", () => { it("skips when message variant is already set", async () => {
it("should NOT re-upgrade already-high variants", async () => { // given
// given an already-high variant model const hook = createThinkModeHook()
const hook = createThinkModeHook() const input = createHookInput({
const input = createMockInput( sessionID,
"github-copilot", providerID: "github-copilot",
"claude-opus-4-6-high", modelID: "claude-sonnet-4-6",
"think deeply"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should NOT modify the model (already high)
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
// No additional thinking config should be injected
expect(message.thinking).toBeUndefined()
}) })
const output = createHookOutput("think through this", "max")
it("should NOT re-upgrade already-high GPT variants", async () => { // when
// given an already-high GPT variant await hook["chat.message"](input, output)
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"gpt-5.2-high",
"ultrathink"
)
// when the chat.params hook is called // then
await hook["chat.params"](input, sessionID) expect(output.message.variant).toBe("max")
expect(output.message.model).toBeUndefined()
// then should NOT modify the model
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5.2-high")
expect(message.reasoning_effort).toBeUndefined()
})
}) })
describe("Unknown models", () => { it("does nothing when think keyword is absent", async () => {
it("should not crash for unknown models via github-copilot", async () => { // given
// given an unknown model type const hook = createThinkModeHook()
const hook = createThinkModeHook() const input = createHookInput({
const input = createMockInput( sessionID,
"github-copilot", providerID: "google",
"llama-3-70b", modelID: "gemini-3-pro",
"think about this"
)
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should not crash and model should remain unchanged
expect(input.message.model?.modelID).toBe("llama-3-70b")
}) })
const output = createHookOutput("Please solve this directly")
// when
await hook["chat.message"](input, output)
// then
expect(output.message.variant).toBeUndefined()
expect(output.message.model).toBeUndefined()
}) })
describe("Edge cases", () => { it("does not modify already-high models", async () => {
it("should handle missing model gracefully", async () => { // given
// given input without a model const hook = createThinkModeHook()
const hook = createThinkModeHook() const input = createHookInput({
const input: ThinkModeInput = { sessionID,
parts: [{ type: "text", text: "think about this" }], providerID: "openai",
message: {}, modelID: "gpt-5-high",
}
// when the chat.params hook is called
// then should not crash
await expect(
hook["chat.params"](input, sessionID)
).resolves.toBeUndefined()
}) })
const output = createHookOutput("think deeply")
it("should handle empty prompt gracefully", async () => { // when
// given empty prompt await hook["chat.message"](input, output)
const hook = createThinkModeHook()
const input = createMockInput("github-copilot", "claude-opus-4-6", "")
// when the chat.params hook is called // then
await hook["chat.params"](input, sessionID) expect(output.message.variant).toBeUndefined()
expect(output.message.model).toBeUndefined()
// then should not upgrade (no think keyword)
expect(input.message.model?.modelID).toBe("claude-opus-4-6")
})
}) })
describe("Agent-level thinking configuration respect", () => { it("handles missing input model without crashing", async () => {
it("should omit Z.ai GLM disabled thinking config", async () => { // given
//#given a Z.ai GLM model with think prompt const hook = createThinkModeHook()
const hook = createThinkModeHook() const input = createHookInput({ sessionID })
const input = createMockInput( const output = createHookOutput("think about this")
"zai-coding-plan",
"glm-5",
"ultrathink mode"
)
//#when think mode resolves Z.ai thinking configuration // when
await hook["chat.params"](input, sessionID) await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
//#then thinking config should be omitted from request // then
const message = input.message as MessageWithInjectedProps expect(output.message.variant).toBeUndefined()
expect(input.message.model?.modelID).toBe("glm-5") expect(output.message.model).toBeUndefined()
expect(message.thinking).toBeUndefined()
expect(message.providerOptions).toBeUndefined()
})
it("should NOT inject thinking config when agent has thinking disabled", async () => {
// given agent with thinking explicitly disabled
const hook = createThinkModeHook()
const input: ThinkModeInput = {
parts: [{ type: "text", text: "ultrathink deeply" }],
message: {
model: { providerID: "google", modelID: "gemini-3-pro" },
thinking: { type: "disabled" },
} as ThinkModeInput["message"],
}
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should NOT override agent's thinking disabled setting
const message = input.message as MessageWithInjectedProps
expect((message.thinking as { type: string }).type).toBe("disabled")
expect(message.providerOptions).toBeUndefined()
})
it("should NOT inject thinking config when agent has custom providerOptions", async () => {
// given agent with custom providerOptions
const hook = createThinkModeHook()
const input: ThinkModeInput = {
parts: [{ type: "text", text: "ultrathink" }],
message: {
model: { providerID: "google", modelID: "gemini-3-flash" },
providerOptions: {
google: { thinkingConfig: { thinkingBudget: 0 } },
},
} as ThinkModeInput["message"],
}
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should NOT override agent's providerOptions
const message = input.message as MessageWithInjectedProps
const providerOpts = message.providerOptions as Record<string, unknown>
expect((providerOpts.google as Record<string, unknown>).thinkingConfig).toEqual({
thinkingBudget: 0,
})
})
it("should still inject thinking config when agent has no thinking override", async () => {
// given agent without thinking override
const hook = createThinkModeHook()
const input = createMockInput("google", "gemini-3-pro", "ultrathink")
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// then should inject thinking config as normal
const message = input.message as MessageWithInjectedProps
expect(message.providerOptions).toBeDefined()
})
}) })
}) })

View File

@@ -1,128 +1,10 @@
import { describe, expect, it } from "bun:test" import { describe, expect, it } from "bun:test"
import { import {
getHighVariant, getHighVariant,
getThinkingConfig,
isAlreadyHighVariant, isAlreadyHighVariant,
THINKING_CONFIGS,
} from "./switcher" } from "./switcher"
describe("think-mode switcher", () => { describe("think-mode switcher", () => {
describe("GitHub Copilot provider support", () => {
describe("Claude models via github-copilot", () => {
it("should resolve github-copilot Claude Opus to anthropic config", () => {
// given a github-copilot provider with Claude Opus model
const providerID = "github-copilot"
const modelID = "claude-opus-4-6"
// when getting thinking config
const config = getThinkingConfig(providerID, modelID)
// then should return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
expect((config?.thinking as Record<string, unknown>)?.type).toBe(
"enabled"
)
expect((config?.thinking as Record<string, unknown>)?.budgetTokens).toBe(
64000
)
})
it("should resolve github-copilot Claude Sonnet to anthropic config", () => {
// given a github-copilot provider with Claude Sonnet model
const config = getThinkingConfig("github-copilot", "claude-sonnet-4-6")
// then should return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
})
it("should handle Claude with dots in version number", () => {
// given a model ID with dots (claude-opus-4.6)
const config = getThinkingConfig("github-copilot", "claude-opus-4.6")
// then should still return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
})
})
describe("Gemini models via github-copilot", () => {
it("should resolve github-copilot Gemini Pro to google config", () => {
// given a github-copilot provider with Gemini Pro model
const config = getThinkingConfig("github-copilot", "gemini-3-pro")
// then should return google thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
const googleOptions = (
config?.providerOptions as Record<string, unknown>
)?.google as Record<string, unknown>
expect(googleOptions?.thinkingConfig).toBeDefined()
})
it("should resolve github-copilot Gemini Flash to google config", () => {
// given a github-copilot provider with Gemini Flash model
const config = getThinkingConfig(
"github-copilot",
"gemini-3-flash"
)
// then should return google thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
})
})
describe("GPT models via github-copilot", () => {
it("should resolve github-copilot GPT-5.2 to openai config", () => {
// given a github-copilot provider with GPT-5.2 model
const config = getThinkingConfig("github-copilot", "gpt-5.2")
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
it("should resolve github-copilot GPT-5 to openai config", () => {
// given a github-copilot provider with GPT-5 model
const config = getThinkingConfig("github-copilot", "gpt-5")
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
it("should resolve github-copilot o1 to openai config", () => {
// given a github-copilot provider with o1 model
const config = getThinkingConfig("github-copilot", "o1-preview")
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
it("should resolve github-copilot o3 to openai config", () => {
// given a github-copilot provider with o3 model
const config = getThinkingConfig("github-copilot", "o3-mini")
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
})
describe("Unknown models via github-copilot", () => {
it("should return null for unknown model types", () => {
// given a github-copilot provider with unknown model
const config = getThinkingConfig("github-copilot", "llama-3-70b")
// then should return null (no matching provider)
expect(config).toBeNull()
})
})
})
describe("Model ID normalization", () => { describe("Model ID normalization", () => {
describe("getHighVariant with dots vs hyphens", () => { describe("getHighVariant with dots vs hyphens", () => {
it("should handle dots in Claude version numbers", () => { it("should handle dots in Claude version numbers", () => {
@@ -217,149 +99,6 @@ describe("think-mode switcher", () => {
}) })
}) })
describe("getThinkingConfig", () => {
describe("Already high variants", () => {
it("should return null for already-high variants", () => {
// given already-high model variants
expect(
getThinkingConfig("anthropic", "claude-opus-4-6-high")
).toBeNull()
expect(getThinkingConfig("openai", "gpt-5-2-high")).toBeNull()
expect(getThinkingConfig("google", "gemini-3-pro-high")).toBeNull()
})
it("should return null for already-high variants via github-copilot", () => {
// given already-high model variants via github-copilot
expect(
getThinkingConfig("github-copilot", "claude-opus-4-6-high")
).toBeNull()
expect(getThinkingConfig("github-copilot", "gpt-5.2-high")).toBeNull()
})
})
describe("Non-thinking-capable models", () => {
it("should return null for non-thinking-capable models", () => {
// given models that don't support thinking mode
expect(getThinkingConfig("anthropic", "claude-2")).toBeNull()
expect(getThinkingConfig("openai", "gpt-4")).toBeNull()
expect(getThinkingConfig("google", "gemini-1")).toBeNull()
})
})
describe("Unknown providers", () => {
it("should return null for unknown providers", () => {
// given unknown provider IDs
expect(getThinkingConfig("unknown-provider", "some-model")).toBeNull()
expect(getThinkingConfig("azure", "gpt-5")).toBeNull()
})
})
})
describe("Direct provider configs (backwards compatibility)", () => {
it("should still work for direct anthropic provider", () => {
// given direct anthropic provider
const config = getThinkingConfig("anthropic", "claude-opus-4-6")
// then should return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
expect((config?.thinking as Record<string, unknown>)?.type).toBe("enabled")
})
it("should work for direct google-vertex-anthropic provider", () => {
//#given direct google-vertex-anthropic provider
const config = getThinkingConfig(
"google-vertex-anthropic",
"claude-opus-4-6"
)
//#when thinking config is resolved
//#then it should return anthropic-style thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
expect((config?.thinking as Record<string, unknown>)?.type).toBe("enabled")
expect((config?.thinking as Record<string, unknown>)?.budgetTokens).toBe(
64000
)
})
it("should still work for direct google provider", () => {
// given direct google provider
const config = getThinkingConfig("google", "gemini-3-pro")
// then should return google thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
})
it("should still work for amazon-bedrock provider", () => {
// given amazon-bedrock provider with claude model
const config = getThinkingConfig("amazon-bedrock", "claude-sonnet-4-6")
// then should return bedrock thinking config
expect(config).not.toBeNull()
expect(config?.reasoningConfig).toBeDefined()
})
it("should still work for google-vertex provider", () => {
// given google-vertex provider
const config = getThinkingConfig("google-vertex", "gemini-3-pro")
// then should return google-vertex thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
const vertexOptions = (config?.providerOptions as Record<string, unknown>)?.[
"google-vertex"
] as Record<string, unknown>
expect(vertexOptions?.thinkingConfig).toBeDefined()
})
it("should work for direct openai provider", () => {
// given direct openai provider
const config = getThinkingConfig("openai", "gpt-5")
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
})
describe("THINKING_CONFIGS structure", () => {
it("should have correct structure for anthropic", () => {
const config = THINKING_CONFIGS.anthropic
expect(config.thinking).toBeDefined()
expect(config.maxTokens).toBe(128000)
})
it("should have correct structure for google-vertex-anthropic", () => {
//#given google-vertex-anthropic config entry
const config = THINKING_CONFIGS["google-vertex-anthropic"]
//#when structure is validated
//#then it should match anthropic style structure
expect(config.thinking).toBeDefined()
expect(config.maxTokens).toBe(128000)
})
it("should have correct structure for google", () => {
const config = THINKING_CONFIGS.google
expect(config.providerOptions).toBeDefined()
})
it("should have correct structure for openai", () => {
const config = THINKING_CONFIGS.openai
expect(config.reasoning_effort).toBe("high")
})
it("should have correct structure for amazon-bedrock", () => {
const config = THINKING_CONFIGS["amazon-bedrock"]
expect(config.reasoningConfig).toBeDefined()
expect(config.maxTokens).toBe(64000)
})
})
describe("Custom provider prefixes support", () => { describe("Custom provider prefixes support", () => {
describe("getHighVariant with prefixes", () => { describe("getHighVariant with prefixes", () => {
it("should preserve vertex_ai/ prefix when getting high variant", () => { it("should preserve vertex_ai/ prefix when getting high variant", () => {
@@ -426,141 +165,6 @@ describe("think-mode switcher", () => {
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true) expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
}) })
}) })
describe("getThinkingConfig with prefixes", () => {
it("should return null for custom providers (not in THINKING_CONFIGS)", () => {
// given custom provider with prefixed Claude model
const config = getThinkingConfig("dia-llm", "vertex_ai/claude-sonnet-4-6")
// then should return null (custom provider not in THINKING_CONFIGS)
expect(config).toBeNull()
})
it("should work with prefixed models on known providers", () => {
// given known provider (anthropic) with prefixed model
// This tests that the base model name is correctly extracted for capability check
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-6")
// then should return thinking config (base model is capable)
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
})
it("should return null for prefixed models that are already high", () => {
// given prefixed already-high model
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-6-high")
// then should return null
expect(config).toBeNull()
})
})
describe("Real-world custom provider scenario", () => {
it("should handle LLM proxy with vertex_ai prefix correctly", () => {
// given a custom LLM proxy provider using vertex_ai/ prefix
const providerID = "dia-llm"
const modelID = "vertex_ai/claude-sonnet-4-6"
// when getting high variant
const highVariant = getHighVariant(modelID)
// then should preserve the prefix
expect(highVariant).toBe("vertex_ai/claude-sonnet-4-6-high")
// #and when checking if already high
expect(isAlreadyHighVariant(modelID)).toBe(false)
expect(isAlreadyHighVariant(highVariant!)).toBe(true)
// #and when getting thinking config for custom provider
const config = getThinkingConfig(providerID, modelID)
// then should return null (custom provider, not anthropic)
// This prevents applying incompatible thinking configs to custom providers
expect(config).toBeNull()
})
it("should not break when switching to high variant in think mode", () => {
// given think mode switching vertex_ai/claude model to high variant
const original = "vertex_ai/claude-opus-4-6"
const high = getHighVariant(original)
// then the high variant should be valid
expect(high).toBe("vertex_ai/claude-opus-4-6-high")
// #and should be recognized as already high
expect(isAlreadyHighVariant(high!)).toBe(true)
// #and switching again should return null (already high)
expect(getHighVariant(high!)).toBeNull()
})
})
})
describe("Z.AI GLM-4.7 provider support", () => {
describe("getThinkingConfig for zai-coding-plan", () => {
it("should return thinking config for glm-5", () => {
//#given a Z.ai GLM model
const config = getThinkingConfig("zai-coding-plan", "glm-5")
//#when thinking config is resolved
//#then thinking type is "disabled"
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
const zaiOptions = (config?.providerOptions as Record<string, unknown>)?.[
"zai-coding-plan"
] as Record<string, unknown>
expect(zaiOptions?.extra_body).toBeDefined()
const extraBody = zaiOptions?.extra_body as Record<string, unknown>
expect(extraBody?.thinking).toBeDefined()
expect((extraBody?.thinking as Record<string, unknown>)?.type).toBe("disabled")
})
it("should return thinking config for glm-4.6v (multimodal)", () => {
// given zai-coding-plan provider with glm-4.6v model
const config = getThinkingConfig("zai-coding-plan", "glm-4.6v")
// then should return zai-coding-plan thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
})
it("should return null for non-GLM models on zai-coding-plan", () => {
// given zai-coding-plan provider with unknown model
const config = getThinkingConfig("zai-coding-plan", "some-other-model")
// then should return null
expect(config).toBeNull()
})
})
describe("HIGH_VARIANT_MAP for GLM", () => {
it("should NOT have high variant for glm-5", () => {
// given glm-5 model
const variant = getHighVariant("glm-5")
// then should return null (no high variant needed)
expect(variant).toBeNull()
})
it("should NOT have high variant for glm-4.6v", () => {
// given glm-4.6v model
const variant = getHighVariant("glm-4.6v")
// then should return null
expect(variant).toBeNull()
})
})
})
describe("THINKING_CONFIGS structure for zai-coding-plan", () => {
it("should have correct structure for zai-coding-plan", () => {
const config = THINKING_CONFIGS["zai-coding-plan"]
expect(config.providerOptions).toBeDefined()
const zaiOptions = (config.providerOptions as Record<string, unknown>)?.[
"zai-coding-plan"
] as Record<string, unknown>
expect(zaiOptions?.extra_body).toBeDefined()
})
})
}) })
})

View File

@@ -53,35 +53,7 @@ function normalizeModelID(modelID: string): string {
return modelID.replace(/\.(\d+)/g, "-$1") return modelID.replace(/\.(\d+)/g, "-$1")
} }
/**
* Resolves proxy providers (like github-copilot) to their underlying provider.
* This allows GitHub Copilot to inherit thinking configurations from the actual
* model provider (Anthropic, Google, OpenAI).
*
* @example
* resolveProvider("github-copilot", "claude-opus-4-6") // "anthropic"
* resolveProvider("github-copilot", "gemini-3-pro") // "google"
* resolveProvider("github-copilot", "gpt-5.2") // "openai"
* resolveProvider("anthropic", "claude-opus-4-6") // "anthropic" (unchanged)
*/
function resolveProvider(providerID: string, modelID: string): string {
// GitHub Copilot is a proxy - infer actual provider from model name
if (providerID === "github-copilot") {
const modelLower = modelID.toLowerCase()
if (modelLower.includes("claude")) return "anthropic"
if (modelLower.includes("gemini")) return "google"
if (
modelLower.includes("gpt") ||
modelLower.includes("o1") ||
modelLower.includes("o3")
) {
return "openai"
}
}
// Direct providers or unknown - return as-is
return providerID
}
// Maps model IDs to their "high reasoning" variant (internal convention) // Maps model IDs to their "high reasoning" variant (internal convention)
// For OpenAI models, this signals that reasoning_effort should be set to "high" // For OpenAI models, this signals that reasoning_effort should be set to "high"
@@ -116,71 +88,6 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP)) const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))
export const THINKING_CONFIGS = {
anthropic: {
thinking: {
type: "enabled",
budgetTokens: 64000,
},
maxTokens: 128000,
},
"google-vertex-anthropic": {
thinking: {
type: "enabled",
budgetTokens: 64000,
},
maxTokens: 128000,
},
"amazon-bedrock": {
reasoningConfig: {
type: "enabled",
budgetTokens: 32000,
},
maxTokens: 64000,
},
google: {
providerOptions: {
google: {
thinkingConfig: {
thinkingLevel: "HIGH",
},
},
},
},
"google-vertex": {
providerOptions: {
"google-vertex": {
thinkingConfig: {
thinkingLevel: "HIGH",
},
},
},
},
openai: {
reasoning_effort: "high",
},
"zai-coding-plan": {
providerOptions: {
"zai-coding-plan": {
extra_body: {
thinking: {
type: "disabled",
},
},
},
},
},
} as const satisfies Record<string, Record<string, unknown>>
const THINKING_CAPABLE_MODELS = {
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
"google-vertex-anthropic": ["claude-sonnet-4", "claude-opus-4", "claude-3"],
"amazon-bedrock": ["claude", "anthropic"],
google: ["gemini-2", "gemini-3"],
"google-vertex": ["gemini-2", "gemini-3"],
openai: ["gpt-5", "o1", "o3"],
"zai-coding-plan": ["glm"],
} as const satisfies Record<string, readonly string[]>
export function getHighVariant(modelID: string): string | null { export function getHighVariant(modelID: string): string | null {
const normalized = normalizeModelID(modelID) const normalized = normalizeModelID(modelID)
@@ -207,37 +114,4 @@ export function isAlreadyHighVariant(modelID: string): boolean {
return ALREADY_HIGH.has(base) || base.endsWith("-high") return ALREADY_HIGH.has(base) || base.endsWith("-high")
} }
type ThinkingProvider = keyof typeof THINKING_CONFIGS
function isThinkingProvider(provider: string): provider is ThinkingProvider {
return provider in THINKING_CONFIGS
}
export function getThinkingConfig(
providerID: string,
modelID: string
): Record<string, unknown> | null {
const normalized = normalizeModelID(modelID)
const { base } = extractModelPrefix(normalized)
if (isAlreadyHighVariant(normalized)) {
return null
}
const resolvedProvider = resolveProvider(providerID, modelID)
if (!isThinkingProvider(resolvedProvider)) {
return null
}
const config = THINKING_CONFIGS[resolvedProvider]
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
// Check capability using base model name (without prefix)
const baseLower = base.toLowerCase()
const isCapable = capablePatterns.some((pattern) =>
baseLower.includes(pattern.toLowerCase())
)
return isCapable ? config : null
}

View File

@@ -1,21 +1,16 @@
export interface ThinkModeState { export interface ThinkModeState {
requested: boolean requested: boolean
modelSwitched: boolean modelSwitched: boolean
thinkingConfigInjected: boolean variantSet: boolean
providerID?: string providerID?: string
modelID?: string modelID?: string
} }
export interface ModelRef { interface ModelRef {
providerID: string providerID: string
modelID: string modelID: string
} }
export interface MessageWithModel { interface MessageWithModel {
model?: ModelRef model?: ModelRef
} }
export interface ThinkModeInput {
parts: Array<{ type: string; text?: string }>
message: MessageWithModel
}

View File

@@ -1,6 +1,6 @@
# src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism # src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -17,6 +17,6 @@ export const TOAST_DURATION_MS = 900
export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const COUNTDOWN_GRACE_PERIOD_MS = 500
export const ABORT_WINDOW_MS = 3000 export const ABORT_WINDOW_MS = 3000
export const CONTINUATION_COOLDOWN_MS = 30_000 export const CONTINUATION_COOLDOWN_MS = 5_000
export const MAX_CONSECUTIVE_FAILURES = 5 export const MAX_CONSECUTIVE_FAILURES = 5
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000 export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000

View File

@@ -1,6 +1,6 @@
# src/mcp/ — 3 Built-in Remote MCPs # src/mcp/ — 3 Built-in Remote MCPs
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -1,6 +1,6 @@
# src/plugin-handlers/ — 6-Phase Config Loading Pipeline # src/plugin-handlers/ — 6-Phase Config Loading Pipeline
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from "bun:test"
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper" import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"
describe("remapAgentKeysToDisplayNames", () => { describe("remapAgentKeysToDisplayNames", () => {
it("remaps known agent keys to display names", () => { it("remaps known agent keys to display names while preserving original keys", () => {
// given agents with lowercase keys // given agents with lowercase keys
const agents = { const agents = {
sisyphus: { prompt: "test", mode: "primary" }, sisyphus: { prompt: "test", mode: "primary" },
@@ -12,10 +12,11 @@ describe("remapAgentKeysToDisplayNames", () => {
// when remapping // when remapping
const result = remapAgentKeysToDisplayNames(agents) const result = remapAgentKeysToDisplayNames(agents)
// then known agents get display name keys // then known agents get display name keys and original keys remain accessible
expect(result["Sisyphus (Ultraworker)"]).toBeDefined() expect(result["Sisyphus (Ultraworker)"]).toBeDefined()
expect(result["oracle"]).toBeDefined() expect(result["oracle"]).toBeDefined()
expect(result["sisyphus"]).toBeUndefined() expect(result["sisyphus"]).toBeDefined()
expect(result["Sisyphus (Ultraworker)"]).toBe(result["sisyphus"])
}) })
it("preserves unknown agent keys unchanged", () => { it("preserves unknown agent keys unchanged", () => {
@@ -31,7 +32,7 @@ describe("remapAgentKeysToDisplayNames", () => {
expect(result["custom-agent"]).toBeDefined() expect(result["custom-agent"]).toBeDefined()
}) })
it("remaps all core agents", () => { it("remaps all core agents while preserving original keys", () => {
// given all core agents // given all core agents
const agents = { const agents = {
sisyphus: {}, sisyphus: {},
@@ -46,15 +47,20 @@ describe("remapAgentKeysToDisplayNames", () => {
// when remapping // when remapping
const result = remapAgentKeysToDisplayNames(agents) const result = remapAgentKeysToDisplayNames(agents)
// then all get display name keys // then all get display name keys while original keys still work
expect(Object.keys(result)).toEqual([ expect(result["Sisyphus (Ultraworker)"]).toBeDefined()
"Sisyphus (Ultraworker)", expect(result["sisyphus"]).toBeDefined()
"Hephaestus (Deep Agent)", expect(result["Hephaestus (Deep Agent)"]).toBeDefined()
"Prometheus (Plan Builder)", expect(result["hephaestus"]).toBeDefined()
"Atlas (Plan Executor)", expect(result["Prometheus (Plan Builder)"]).toBeDefined()
"Metis (Plan Consultant)", expect(result["prometheus"]).toBeDefined()
"Momus (Plan Critic)", expect(result["Atlas (Plan Executor)"]).toBeDefined()
"Sisyphus-Junior", expect(result["atlas"]).toBeDefined()
]) expect(result["Metis (Plan Consultant)"]).toBeDefined()
expect(result["metis"]).toBeDefined()
expect(result["Momus (Plan Critic)"]).toBeDefined()
expect(result["momus"]).toBeDefined()
expect(result["Sisyphus-Junior"]).toBeDefined()
expect(result["sisyphus-junior"]).toBeDefined()
}) })
}) })

View File

@@ -9,6 +9,7 @@ export function remapAgentKeysToDisplayNames(
const displayName = AGENT_DISPLAY_NAMES[key] const displayName = AGENT_DISPLAY_NAMES[key]
if (displayName && displayName !== key) { if (displayName && displayName !== key) {
result[displayName] = value result[displayName] = value
result[key] = value
} else { } else {
result[key] = value result[key] = value
} }

View File

@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import type { OhMyOpenCodeConfig } from "../config"
import { createConfigHandler } from "./config-handler"
import * as agentConfigHandler from "./agent-config-handler"
import * as commandConfigHandler from "./command-config-handler"
import * as mcpConfigHandler from "./mcp-config-handler"
import * as pluginComponentsLoader from "./plugin-components-loader"
import * as providerConfigHandler from "./provider-config-handler"
import * as shared from "../shared"
import * as toolConfigHandler from "./tool-config-handler"
let logSpy: ReturnType<typeof spyOn>
let loadPluginComponentsSpy: ReturnType<typeof spyOn>
let applyAgentConfigSpy: ReturnType<typeof spyOn>
let applyToolConfigSpy: ReturnType<typeof spyOn>
let applyMcpConfigSpy: ReturnType<typeof spyOn>
let applyCommandConfigSpy: ReturnType<typeof spyOn>
let applyProviderConfigSpy: ReturnType<typeof spyOn>
beforeEach(() => {
logSpy = spyOn(shared, "log").mockImplementation(() => {})
loadPluginComponentsSpy = spyOn(
pluginComponentsLoader,
"loadPluginComponents",
).mockResolvedValue({
commands: {},
skills: {},
agents: {},
mcpServers: {},
hooksConfigs: [],
plugins: [],
errors: [],
})
applyAgentConfigSpy = spyOn(agentConfigHandler, "applyAgentConfig").mockResolvedValue(
{},
)
applyToolConfigSpy = spyOn(toolConfigHandler, "applyToolConfig").mockImplementation(
() => {},
)
applyMcpConfigSpy = spyOn(mcpConfigHandler, "applyMcpConfig").mockResolvedValue()
applyCommandConfigSpy = spyOn(
commandConfigHandler,
"applyCommandConfig",
).mockResolvedValue()
applyProviderConfigSpy = spyOn(
providerConfigHandler,
"applyProviderConfig",
).mockImplementation(() => {})
})
afterEach(() => {
logSpy.mockRestore()
loadPluginComponentsSpy.mockRestore()
applyAgentConfigSpy.mockRestore()
applyToolConfigSpy.mockRestore()
applyMcpConfigSpy.mockRestore()
applyCommandConfigSpy.mockRestore()
applyProviderConfigSpy.mockRestore()
})
describe("createConfigHandler formatter pass-through", () => {
test("preserves formatter object configured in opencode config", async () => {
// given
const pluginConfig: OhMyOpenCodeConfig = {}
const formatterConfig = {
prettier: {
command: ["prettier", "--write"],
extensions: [".ts", ".tsx"],
environment: {
PRETTIERD_DEFAULT_CONFIG: ".prettierrc",
},
},
eslint: {
disabled: false,
command: ["eslint", "--fix"],
extensions: [".js", ".ts"],
},
}
const config: Record<string, unknown> = {
formatter: formatterConfig,
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// when
await handler(config)
// then
expect(config.formatter).toEqual(formatterConfig)
})
test("preserves formatter=false configured in opencode config", async () => {
// given
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
formatter: false,
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// when
await handler(config)
// then
expect(config.formatter).toBe(false)
})
})

View File

@@ -20,6 +20,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
const { ctx, pluginConfig, modelCacheState } = deps; const { ctx, pluginConfig, modelCacheState } = deps;
return async (config: Record<string, unknown>) => { return async (config: Record<string, unknown>) => {
const formatterConfig = config.formatter;
applyProviderConfig({ config, modelCacheState }); applyProviderConfig({ config, modelCacheState });
const pluginComponents = await loadPluginComponents({ pluginConfig }); const pluginComponents = await loadPluginComponents({ pluginConfig });
@@ -35,6 +37,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
await applyMcpConfig({ config, pluginConfig, pluginComponents }); await applyMcpConfig({ config, pluginConfig, pluginComponents });
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents }); await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
config.formatter = formatterConfig;
log("[config-handler] config handler applied", { log("[config-handler] config handler applied", {
agentCount: Object.keys(agentResult).length, agentCount: Object.keys(agentResult).length,
commandCount: Object.keys((config.command as Record<string, unknown>) ?? {}) commandCount: Object.keys((config.command as Record<string, unknown>) ?? {})

View File

@@ -1,10 +1,10 @@
# src/plugin/ — 8 OpenCode Hook Handlers + Hook Composition # src/plugin/ — 8 OpenCode Hook Handlers + Hook Composition
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 44 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type. Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 46 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
## HANDLER FILES ## HANDLER FILES
@@ -25,10 +25,10 @@ Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and com
|------|------|-------| |------|------|-------|
| `create-session-hooks.ts` | Session | 21 | | `create-session-hooks.ts` | Session | 21 |
| `create-tool-guard-hooks.ts` | Tool Guard | 10 | | `create-tool-guard-hooks.ts` | Tool Guard | 10 |
| `create-transform-hooks.ts` | Transform | 4 | | `create-session-hooks.ts` | Session | 23 |
| `create-continuation-hooks.ts` | Continuation | 7 | | `create-tool-guard-hooks.ts` | Tool Guard | 10 |
| `create-skill-hooks.ts` | Skill | 2 | | `create-skill-hooks.ts` | Skill | 2 |
| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 35 | | `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 37 |
## SUPPORT FILES ## SUPPORT FILES

View File

@@ -19,6 +19,7 @@ function createMockHandlerArgs(overrides?: {
}, },
hooks: { hooks: {
stopContinuationGuard: null, stopContinuationGuard: null,
backgroundNotificationHook: null,
keywordDetector: null, keywordDetector: null,
claudeCodeHooks: null, claudeCodeHooks: null,
autoSlashCommand: null, autoSlashCommand: null,
@@ -115,4 +116,30 @@ describe("createChatMessageHandler - TUI variant passthrough", () => {
//#then - gate should still be marked as applied //#then - gate should still be marked as applied
expect(args._appliedSessions).toContain("test-session") expect(args._appliedSessions).toContain("test-session")
}) })
test("injects queued background notifications through chat.message hook", async () => {
//#given
const args = createMockHandlerArgs()
args.hooks.backgroundNotificationHook = {
"chat.message": async (
_input: { sessionID: string },
output: ChatMessageHandlerOutput,
): Promise<void> => {
output.parts.push({
type: "text",
text: "<system-reminder>[BACKGROUND TASK COMPLETED]</system-reminder>",
})
},
}
const handler = createChatMessageHandler(args)
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
const output = createMockOutput()
//#when
await handler(input, output)
//#then
expect(output.parts).toHaveLength(1)
expect(output.parts[0].text).toContain("[BACKGROUND TASK COMPLETED]")
})
}) })

View File

@@ -97,8 +97,10 @@ export function createChatMessageHandler(args: {
setSessionModel(input.sessionID, input.model) setSessionModel(input.sessionID, input.model)
} }
await hooks.stopContinuationGuard?.["chat.message"]?.(input) await hooks.stopContinuationGuard?.["chat.message"]?.(input)
await hooks.backgroundNotificationHook?.["chat.message"]?.(input, output)
await hooks.runtimeFallback?.["chat.message"]?.(input, output) await hooks.runtimeFallback?.["chat.message"]?.(input, output)
await hooks.keywordDetector?.["chat.message"]?.(input, output) await hooks.keywordDetector?.["chat.message"]?.(input, output)
await hooks.thinkMode?.["chat.message"]?.(input, output)
await hooks.claudeCodeHooks?.["chat.message"]?.(input, output) await hooks.claudeCodeHooks?.["chat.message"]?.(input, output)
await hooks.autoSlashCommand?.["chat.message"]?.(input, output) await hooks.autoSlashCommand?.["chat.message"]?.(input, output)
await hooks.noSisyphusGpt?.["chat.message"]?.(input, output) await hooks.noSisyphusGpt?.["chat.message"]?.(input, output)

View File

@@ -232,7 +232,10 @@ export function createSessionHooks(args: {
: null : null
const noHephaestusNonGpt = isHookEnabled("no-hephaestus-non-gpt") const noHephaestusNonGpt = isHookEnabled("no-hephaestus-non-gpt")
? safeHook("no-hephaestus-non-gpt", () => createNoHephaestusNonGptHook(ctx)) ? safeHook("no-hephaestus-non-gpt", () =>
createNoHephaestusNonGptHook(ctx, {
allowNonGptModel: pluginConfig.agents?.hephaestus?.allow_non_gpt_model,
}))
: null : null
const questionLabelTruncator = isHookEnabled("question-label-truncator") const questionLabelTruncator = isHookEnabled("question-label-truncator")

View File

@@ -21,11 +21,10 @@ function tryUpdateMessageModel(
) )
const result = stmt.run(targetModel.providerID, targetModel.modelID, messageId) const result = stmt.run(targetModel.providerID, targetModel.modelID, messageId)
if (result.changes === 0) return false if (result.changes === 0) return false
if (variant) { if (variant) {
db.prepare( db.prepare(
`UPDATE message SET data = json_set(data, '$.variant', ?, '$.thinking', ?) WHERE id = ?`, `UPDATE message SET data = json_set(data, '$.variant', ?) WHERE id = ?`,
).run(variant, variant, messageId) ).run(variant, messageId)
} }
return true return true
} }

View File

@@ -308,7 +308,6 @@ describe("applyUltraworkModelOverrideOnMessage", () => {
//#then //#then
expect(output.message.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" }) expect(output.message.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
expect(output.message["variant"]).toBe("max") expect(output.message["variant"]).toBe("max")
expect(output.message["thinking"]).toBe("max")
expect(dbOverrideSpy).not.toHaveBeenCalled() expect(dbOverrideSpy).not.toHaveBeenCalled()
}) })
@@ -324,7 +323,6 @@ describe("applyUltraworkModelOverrideOnMessage", () => {
//#then //#then
expect(output.message.model).toBeUndefined() expect(output.message.model).toBeUndefined()
expect(output.message["variant"]).toBe("high") expect(output.message["variant"]).toBe("high")
expect(output.message["thinking"]).toBe("high")
expect(dbOverrideSpy).not.toHaveBeenCalled() expect(dbOverrideSpy).not.toHaveBeenCalled()
}) })

View File

@@ -117,7 +117,6 @@ export function applyUltraworkModelOverrideOnMessage(
if (!override.providerID || !override.modelID) { if (!override.providerID || !override.modelID) {
if (override.variant) { if (override.variant) {
output.message["variant"] = override.variant output.message["variant"] = override.variant
output.message["thinking"] = override.variant
} }
return return
} }
@@ -134,7 +133,6 @@ export function applyUltraworkModelOverrideOnMessage(
output.message.model = targetModel output.message.model = targetModel
if (override.variant) { if (override.variant) {
output.message["variant"] = override.variant output.message["variant"] = override.variant
output.message["thinking"] = override.variant
} }
return return
} }

View File

@@ -1,6 +1,6 @@
# src/shared/ — 101 Utility Files in 13 Categories # src/shared/ — 101 Utility Files in 13 Categories
**Generated:** 2026-02-21 **Generated:** 2026-02-24
## OVERVIEW ## OVERVIEW

View File

@@ -168,14 +168,14 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("opencode") expect(primary.providers[0]).toBe("opencode")
}) })
test("hephaestus requires openai/github-copilot/opencode provider", () => { test("hephaestus supports openai, github-copilot, venice, and opencode providers", () => {
// #given - hephaestus agent requirement // #given - hephaestus agent requirement
const hephaestus = AGENT_MODEL_REQUIREMENTS["hephaestus"] const hephaestus = AGENT_MODEL_REQUIREMENTS["hephaestus"]
// #when - accessing hephaestus requirement // #when - accessing hephaestus requirement
// #then - requiresProvider is set to openai, github-copilot, opencode (not requiresModel) // #then - requiresProvider includes openai, github-copilot, venice, and opencode
expect(hephaestus).toBeDefined() expect(hephaestus).toBeDefined()
expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "opencode"]) expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "venice", "opencode"])
expect(hephaestus.requiresModel).toBeUndefined() expect(hephaestus.requiresModel).toBeUndefined()
}) })
@@ -497,3 +497,35 @@ describe("requiresModel field in categories", () => {
expect(artistry.requiresModel).toBe("gemini-3-pro") expect(artistry.requiresModel).toBe("gemini-3-pro")
}) })
}) })
describe("gpt-5.3-codex provider restrictions", () => {
test("no gpt-5.3-codex entry in AGENT_MODEL_REQUIREMENTS includes github-copilot as provider", () => {
// given - all agent requirements
const allAgentEntries = Object.values(AGENT_MODEL_REQUIREMENTS).flatMap(
(req) => req.fallbackChain
)
// when - filtering entries with gpt-5.3-codex model
const codexEntries = allAgentEntries.filter((entry) => entry.model === "gpt-5.3-codex")
// then - none of them include github-copilot as a provider
for (const entry of codexEntries) {
expect(entry.providers).not.toContain("github-copilot")
}
})
test("no gpt-5.3-codex entry in CATEGORY_MODEL_REQUIREMENTS includes github-copilot as provider", () => {
// given - all category requirements
const allCategoryEntries = Object.values(CATEGORY_MODEL_REQUIREMENTS).flatMap(
(req) => req.fallbackChain
)
// when - filtering entries with gpt-5.3-codex model
const codexEntries = allCategoryEntries.filter((entry) => entry.model === "gpt-5.3-codex")
// then - none of them include github-copilot as a provider
for (const entry of codexEntries) {
expect(entry.providers).not.toContain("github-copilot")
}
})
})

View File

@@ -24,9 +24,10 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
}, },
hephaestus: { hephaestus: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "venice", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["github-copilot"], model: "gpt-5.2", variant: "medium" },
], ],
requiresProvider: ["openai", "github-copilot", "opencode"], requiresProvider: ["openai", "github-copilot", "venice", "opencode"],
}, },
oracle: { oracle: {
fallbackChain: [ fallbackChain: [
@@ -101,14 +102,14 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
}, },
ultrabrain: { ultrabrain: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" }, { providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
], ],
}, },
deep: { deep: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
], ],
@@ -132,7 +133,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"unspecified-low": { "unspecified-low": {
fallbackChain: [ fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
], ],
}, },

View File

@@ -399,6 +399,43 @@ describe("promptSyncWithModelSuggestionRetry", () => {
expect(promptAsyncMock).toHaveBeenCalledTimes(0) expect(promptAsyncMock).toHaveBeenCalledTimes(0)
}) })
it("should abort and throw timeout error when sync prompt hangs", async () => {
// given a client where sync prompt never resolves unless aborted
let receivedSignal: AbortSignal | undefined
const promptMock = mock((input: { signal?: AbortSignal }) => {
receivedSignal = input.signal
return new Promise((_, reject) => {
const signal = input.signal
if (!signal) {
return
}
signal.addEventListener("abort", () => {
reject(signal.reason)
})
})
})
const client = {
session: {
prompt: promptMock,
promptAsync: mock(() => Promise.resolve()),
},
}
// when calling with short timeout
// then should abort the request and throw timeout error
await expect(
promptSyncWithModelSuggestionRetry(client as any, {
path: { id: "session-1" },
body: {
parts: [{ type: "text", text: "hello" }],
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
},
}, { timeoutMs: 1 })
).rejects.toThrow("prompt timed out after 1ms")
expect(receivedSignal?.aborted).toBe(true)
})
it("should retry with suggested model on ProviderModelNotFoundError", async () => { it("should retry with suggested model on ProviderModelNotFoundError", async () => {
// given a client that fails first with model-not-found, then succeeds // given a client that fails first with model-not-found, then succeeds
const promptMock = mock() const promptMock = mock()

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