Compare commits

...

75 Commits

Author SHA1 Message Date
YeonGyu-Kim
55c8952668 fix(hashline-edit): update HASHLINE_OUTPUT_PATTERN separator from : to |
The colon separator was missed in PR #2079 which migrated all hashline
format separators from `:` to `|`. This constant is exported as public
API for external consumers.
2026-02-24 15:17:15 +09:00
YeonGyu-Kim
5d30ec80df Merge pull request #2079 from minpeter/feat/hashline-edit-op-schema
refactor(hashline-edit): align tool payload to op/pos/end/lines
2026-02-24 15:13:45 +09:00
YeonGyu-Kim
f50f3d3c37 fix(hashline-edit): clarify LINE#ID placeholder to prevent literal interpretation 2026-02-24 15:00:06 +09:00
YeonGyu-Kim
833c26ae5c sisyphus waits for oracle 2026-02-24 14:50:00 +09:00
minpeter
60cf2de16f fix(hashline-edit): detect overlapping ranges and prevent false unwrap of blank-line spans
- Add detectOverlappingRanges() to reject edits with overlapping pos..end ranges
  instead of crashing with undefined.match()
- Add bounds guard (?? "") in edit-operation-primitives for out-of-range line access
- Add null guard in leadingWhitespace() for undefined/empty input
- Fix restoreOldWrappedLines false unwrap: skip candidate spans containing
  blank/whitespace-only lines, preventing incorrect collapse of structural
  blank lines and indentation (the "애국가 bug")
- Improve tool description for range replace clarity
- Add tests: overlapping range detection, false unwrap prevention
2026-02-24 14:46:17 +09:00
minpeter
c7efe8f002 fix(hashline-edit): preserve intentional whitespace removal in autocorrect
restoreIndentForPairedReplacement() and restoreLeadingIndent() unconditionally
restored original indentation when replacement had none, preventing intentional
indentation changes (e.g. removing a tab from '\t1절' to '1절'). Skip indent
restoration when trimmed content is identical, indicating a whitespace-only edit.
2026-02-24 14:07:21 +09:00
minpeter
54b756c145 refactor(hashline): change content separator from colon to pipe
Change LINE#HASH:content format to LINE#HASH|content across the entire
codebase. The pipe separator is more visually distinct and avoids
conflicts with TypeScript colons in code content.

15 files updated: implementation, prompts, tests, and READMEs.
2026-02-24 06:01:24 +09:00
minpeter
1cb362773b fix(hashline-read-enhancer): handle inline <content> tag from updated OpenCode read tool
OpenCode updated its read tool output format — the <content> tag now shares
a line with the first content line (<content>1: content) with no newline.

The hook's exact indexOf('<content>') detection returned -1, causing all
read output to pass through unmodified (no hash anchors). This silently
disabled the entire hashline-edit workflow.

Fixes:
- Sub-bug 1: Use findIndex + startsWith instead of exact indexOf match
- Sub-bug 2: Extract inline content after <content> prefix as first line
- Sub-bug 3: Normalize open-tag line to bare tag in output (no duplicate)

Also adds backward compat for legacy <file> + 00001| pipe format.
2026-02-24 05:47:05 +09:00
minpeter
08b663df86 refactor(hashline-edit): enforce three-op edit model
Unify internal hashline edit handling around replace/append/prepend to remove legacy operation shapes. This keeps normalization, ordering, deduplication, execution, and tests aligned with the new op/pos/end/lines contract.
2026-02-24 05:06:41 +09:00
github-actions[bot]
fddd6f1306 @Firstbober has signed the CLA in code-yeongyu/oh-my-opencode#2080 2026-02-23 19:28:23 +00:00
YeonGyu-Kim
e11c217d15 fix(tools/background-task): respect block=true even when full_session=true
Move blocking/polling logic before full_session branch so that
block=true waits for task completion regardless of output format.

🤖 Generated with assistance of oh-my-opencode
2026-02-24 03:52:20 +09:00
minpeter
6ec0ff732b refactor(hashline-edit): align tool payload to op/pos/end/lines
Unify hashline_edit input with replace/append/prepend + pos/end/lines semantics so callers use a single stable shape. Add normalization coverage and refresh tool guidance/tests to reduce schema confusion and stale legacy payload usage.
2026-02-24 03:00:38 +09:00
github-actions[bot]
ebd26b7421 release: v3.8.4 2026-02-23 17:11:38 +00:00
YeonGyu-Kim
9f804c2a6a fix(test): sync AGENTS_WITH_TODO_DENY with tool-config-handler implementation 2026-02-24 02:08:30 +09:00
YeonGyu-Kim
05c04838f4 test(hashline-edit): cover concise responses and anchor alias normalization
Update expectations to the new pi-style response contract and add cases for one-anchor replace_lines fallback plus after_line alias handling.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 18:51:37 +09:00
YeonGyu-Kim
86671ad25c refactor(hashline-edit): adopt normalized single-shape edit input
Keep current field names but accept a pi-style flexible edit payload that is normalized to concrete operations at execution time.

Response now follows concise update/move status with diff metadata retained, removing full-file hashline echo to reduce model feedback loops.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 18:51:32 +09:00
YeonGyu-Kim
ab768029fa refactor(hashline-edit): stabilize hashes and tighten prefix stripping
Switch line hashing to significance-aware seeding so meaningful lines stay stable across reflows while punctuation-only lines still disambiguate by line index.

Also narrow prefix stripping to hashline/diff patterns that reduce accidental content corruption during edit normalization.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 18:51:25 +09:00
github-actions[bot]
afec1f2928 @DMax1314 has signed the CLA in code-yeongyu/oh-my-opencode#2068 2026-02-23 07:06:25 +00:00
YeonGyu-Kim
41fe6ad2e4 fix(tools/call-omo-agent): replace as any with Record type cast in session-creator
Cast session body to Record<string, unknown> instead of as any

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:48 +09:00
YeonGyu-Kim
b47b034209 chore(assets): regenerate JSON schema
Regenerate oh-my-opencode.schema.json after config export changes

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:19 +09:00
YeonGyu-Kim
a37a6044dc refactor(config): remove unused barrel exports
Clean up unused re-exports from config barrel file

Remove 14 unused schema exports identified by knip analysis

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:17 +09:00
YeonGyu-Kim
7a01035736 refactor(agents/prometheus): remove unused barrel exports
Clean up unused re-exports from prometheus agents barrel file

Remove 9 unused exports identified by knip analysis

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:16 +09:00
YeonGyu-Kim
f1076d978e refactor(agents/atlas): remove unused barrel exports
Clean up unused re-exports from atlas agents barrel file

Remove 12 unused exports identified by knip analysis

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:14 +09:00
YeonGyu-Kim
3a5aaf6488 refactor(agents): remove unused barrel exports
Clean up unused re-exports from agents barrel file

Remove 24 unused exports identified by knip analysis

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:12 +09:00
YeonGyu-Kim
830dcf8d2f refactor(features): remove empty barrel files
Delete 2 empty barrel index.ts files:

- claude-tasks/index.ts

- mcp-oauth/index.ts

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:11 +09:00
YeonGyu-Kim
96d51418d6 refactor(hooks): remove dead hook files
Delete 3 unused hook files:

- hashline-edit-diff-enhancer/index.ts (and test file)

- session-recovery/recover-empty-content-message.ts

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:08 +09:00
YeonGyu-Kim
b3a6aaa843 refactor(shared): remove dead utility files
Delete 4 unused utility files:

- models-json-cache-reader.ts

- open-code-client-accessors.ts

- open-code-client-shapes.ts

- provider-models-cache-model-reader.ts

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:06 +09:00
YeonGyu-Kim
1f62fa5b2a refactor(tools/call-omo-agent): remove dead code submodules
Delete 3 unused files in call-omo-agent module:

- session-completion-poller.ts

- session-message-output-extractor.ts

- subagent-session-prompter.ts

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:04 +09:00
YeonGyu-Kim
2428a46e6d refactor(features/background-agent): remove dead code submodules
Delete 15 unused files in background-agent module:

- background-task-completer.ts

- format-duration.ts

- message-dir.ts

- parent-session-context-resolver.ts

- parent-session-notifier.ts (and its test file)

- result-handler-context.ts

- result-handler.ts

- session-output-validator.ts

- session-task-cleanup.ts

- session-todo-checker.ts

- spawner/background-session-creator.ts

- spawner/concurrency-key-from-launch-input.ts

- spawner/spawner-context.ts

- spawner/tmux-callback-invoker.ts

Update index.ts barrel and manager.ts/spawner.ts imports

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:01 +09:00
YeonGyu-Kim
b709fa8e83 fix(plugin/hooks): remove unnecessary as any cast
Remove as any from modelCacheState parameter

Structural typing works without explicit cast

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:45 +09:00
YeonGyu-Kim
0dc5f56af4 fix(shared): fix optional chaining on modelItem
Change modelItem.id to modelItem?.id to handle null values

Prevents TypeError when modelItem is null in provider-models cache

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:43 +09:00
YeonGyu-Kim
cd6c9cb5dc fix(cli/run): replace as any with Record type cast
Cast session body to Record<string, unknown> instead of as any

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:40 +09:00
YeonGyu-Kim
e5aa08b865 fix(tools/delegate-task): replace as any with Record type cast
Cast session body to Record<string, unknown> instead of as any

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:38 +09:00
YeonGyu-Kim
db15f96cd8 fix(tools/call-omo-agent): replace as any with SessionWithPromptAsync type
Add SessionWithPromptAsync local type for promptAsync access

Remove as any cast from session.promptAsync call

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:37 +09:00
YeonGyu-Kim
ff0e9ac557 fix(tools/call-omo-agent): replace as any with SDKMessage interface
Add SDKMessage local interface for message type safety

Replace any lambda params and message casts with SDKMessage

Remove eslint-disable comments for no-explicit-any

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:34 +09:00
YeonGyu-Kim
07113ebe94 fix(features/task-toast-manager): replace as any with ClientWithTui type
Add ClientWithTui local type for tui.showToast access

Remove 2 as any casts and eslint-disable comments

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:32 +09:00
YeonGyu-Kim
2d3d993eb6 fix(hooks/shared): replace as any with proper Record type cast
Cast pluginConfig.agents to Record type with proper structure

Remove eslint-disable comment for no-explicit-any

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:30 +09:00
YeonGyu-Kim
a82f4ee86a fix(hooks/thinking-block-validator): replace as any with typed interfaces
Add ThinkingPart and MessageInfoExtended local interfaces

Replace 3 as any casts with proper unknown-to-typed casts

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:28 +09:00
YeonGyu-Kim
0cbc6b5410 fix(hooks/session-recovery): replace @ts-expect-error with proper type cast
Add ClientWithPromptAsync local type to avoid @ts-expect-error

Cast client to proper type before calling session.promptAsync

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:26 +09:00
YeonGyu-Kim
ac3a9fd272 fix(hooks/anthropic-context-window-limit-recovery): remove @ts-ignore comments and fix parameter types
Remove @ts-ignore and eslint-disable comments from executor.ts and recovery-hook.ts

- Change client: any to client: Client with proper import

- Rename experimental to _experimental for unused parameter

- Remove @ts-ignore for ctx.client casts

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:24 +09:00
github-actions[bot]
41880f8ffb @imadal1n has signed the CLA in code-yeongyu/oh-my-opencode#2045 2026-02-22 10:57:45 +00:00
YeonGyu-Kim
35ab9b19c8 fix: deny todo tools for prometheus and sisyphus-junior when task_system enabled
Amp-Thread-ID: https://ampcode.com/threads/T-019c848f-b2a8-7037-9eb5-a258df14b683
Co-authored-by: Amp <amp@ampcode.com>
2026-02-22 17:58:42 +09:00
YeonGyu-Kim
6245e46885 feat(hooks): add Gemini-optimized ultrawork message with intent gate
Create dedicated Gemini ultrawork variant that enforces intent
classification as mandatory Step 0 before any action. Routes Gemini
models to the new variant via source-detector priority chain
(planner > GPT > Gemini > default). Includes anti-optimism checkpoint
and tool-call mandate sections tuned for Gemini's eager behavior.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-22 17:40:38 +09:00
YeonGyu-Kim
76da95116e feat(agents): add Gemini intent gate enforcement overlay for Sisyphus
Counter Gemini's tendency to skip Phase 0 intent classification by
injecting a mandatory self-check gate before tool calls. Includes
intent type classification, anti-skip mechanism, and common mistake
table showing wrong vs correct behavior per intent type.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-22 17:40:20 +09:00
YeonGyu-Kim
9933c6654f feat(model-fallback): disable model fallback retry by default
Model fallback is now opt-in via `model_fallback: true` in plugin config,
matching the runtime-fallback pattern. Prevents unexpected automatic model
switching on API errors unless explicitly enabled.
2026-02-22 17:25:04 +09:00
YeonGyu-Kim
2e845c8d99 feat(hooks): wire pluginConfig to preemptive-compaction hook factory 2026-02-22 17:19:46 +09:00
YeonGyu-Kim
bcf7fff9b9 feat(recovery-strategy): apply compaction model override in context window recovery 2026-02-22 17:19:43 +09:00
YeonGyu-Kim
2d069ce4cc feat(preemptive-compaction): apply compaction model override from agent config 2026-02-22 17:19:39 +09:00
YeonGyu-Kim
09314dba1a feat(schema): add compaction model and variant override configuration 2026-02-22 17:19:35 +09:00
YeonGyu-Kim
32a838ad3c feat(hooks): add compaction-model-resolver utility for session agent model lookup 2026-02-22 17:19:31 +09:00
YeonGyu-Kim
edf4d522d1 Merge pull request #2041 from code-yeongyu/fix/rewrite-overmocked-tests
refactor(tests): rewrite 5 over-mocked test files to test real behavior
2026-02-22 16:54:13 +09:00
YeonGyu-Kim
0bae7ec4fc chore(tests): remove duplicate test in background-update-check (cubic feedback) 2026-02-22 16:51:04 +09:00
YeonGyu-Kim
7e05bd2b8e refactor(tests): rewrite 5 over-mocked test files to test real behavior
- formatter.test.ts: use dynamic imports with cache-busting to avoid mock pollution from runner.test.ts; test real format output instead of dispatch mocking
- hook.test.ts: rewrite with proper branch coverage (7 tests), add success/guard/subagent paths
- background-update-check.test.ts: rewrite with 10 tests covering all branches (early returns, pinned versions, auto-update success/failure)
- directory-agents-injector/injector.test.ts: replace finder/storage mocks with real filesystem + temp directories, verify actual AGENTS.md injection content
- directory-readme-injector/injector.test.ts: same pattern as agents-injector but for README.md, verifies root inclusion behavior
2026-02-22 16:43:56 +09:00
github-actions[bot]
ffa2a255d9 release: v3.8.3 2026-02-22 06:46:51 +00:00
YeonGyu-Kim
07e8a7c570 feat(write-existing-file-guard): allow writes outside session directory
Remove blocking logic that prevented writes to files outside the
session directory. The guard now only applies to files within the
session directory, allowing free writes to external paths.

- Remove OUTSIDE_SESSION_MESSAGE constant
- Update test to expect outside writes to be allowed
- Add early return for paths outside session directory
- Keep isPathInsideDirectory for session boundary check

TDD cycle:
1. RED: Update test expectation
2. GREEN: Implement early return for outside paths
3. REFACTOR: Clean up unused constants
2026-02-22 15:43:19 +09:00
github-actions[bot]
d0b18787ba release: v3.8.2 2026-02-22 06:35:05 +00:00
YeonGyu-Kim
4d7b98d9f2 bun 2026-02-22 15:30:59 +09:00
YeonGyu-Kim
a3e4f904a6 refactor(background-agent): wire session-idle-event-handler into manager, add unit tests
The extracted handleSessionIdleBackgroundEvent was never imported by
manager.ts — dead code from incomplete refactoring (d53bcfbc). Replace
the inline session.idle handler (58 LOC) with a call to the extracted
function, remove unused MIN_IDLE_TIME_MS import, and add 13 unit tests
covering all edge cases.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
c0636e5b0c feat(agents,hooks): wire Sisyphus Gemini overlays and add Gemini verification reminder
Sisyphus: inject TOOL_CALL_MANDATE after intent gate, append delegation
and verification override sections for Gemini models.

Atlas hook: add VERIFICATION_REMINDER_GEMINI with stronger language -
'EXTREMELY SUSPICIOUS', explicit 'NOT reasoning, TOOL CALLS', and
consequence-driven framing for Gemini's optimistic tendencies.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
49e885d81d feat(agents): wire Gemini prompt routing into Sisyphus-Junior, Atlas, Prometheus
Add 'gemini' to prompt source types and route Gemini models to new
Gemini-optimized prompts via isGeminiModel detection. Update barrel
exports for all 3 agent modules. All existing tests pass.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
bf33e6f651 feat(agents): add isGeminiModel detection function with TDD
Detects Gemini models via:
- Provider prefixes: google/, google-vertex/
- GitHub Copilot: github-copilot/gemini-*
- Model name: gemini-* (for proxied providers like litellm)

Follows existing isGptModel pattern. All 16 tests pass.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
da13a2f673 feat(agents): add Gemini-optimized prompts for Sisyphus, Sisyphus-Junior, Prometheus, Atlas
Gemini models are aggressively optimistic and avoid tool calls in favor of
internal reasoning. These prompts counter that with:
- TOOL_CALL_MANDATE sections forcing actual tool usage
- Anti-optimism checkpoints before claiming completion
- Stronger delegation enforcement (Gemini prefers doing work itself)
- Aggressive verification language (subagent results are 'EXTREMELY SUSPICIOUS')
- Mandatory thinking checkpoints in Prometheus (prevents jumping to conclusions)
- Scope discipline reminders (creativity → implementation quality, not scope creep)
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
02aff32b0c Merge pull request #2039 from code-yeongyu/fix/grep-formatter-files-mode
fix(grep): format files_with_matches output as clean file paths
2026-02-22 15:26:09 +09:00
YeonGyu-Kim
c806a35e49 fix(grep): format files_with_matches output as clean file paths 2026-02-22 15:19:26 +09:00
YeonGyu-Kim
b175c11b35 Merge pull request #2009 from JiHongKim98/fix/ripgrep-cpu-throttle
fix(tools): throttle ripgrep CPU usage with thread limits and concurrency control
2026-02-22 15:09:26 +09:00
YeonGyu-Kim
7b55cbab94 Merge pull request #2030 from acamq/feature/agent-input-notifications
feat(notification): alert when agent asks questions or needs permission
2026-02-22 15:09:24 +09:00
YeonGyu-Kim
6904cba061 Merge pull request #2029 from coleleavitt/fix/plug-resource-leaks
fix: plug resource leaks and add hook command timeout
2026-02-22 15:07:02 +09:00
Cole Leavitt
116f17ed11 fix: add proc.kill fallback when process group kill fails 2026-02-21 16:45:18 -07:00
Cole Leavitt
a31109bb07 fix: kill process group on timeout and handle stdin EPIPE
- Use detached process group (non-Windows) + process.kill(-pid) to kill
  the entire process tree, not just the outer shell wrapper
- Add proc.stdin error listener to absorb EPIPE when child exits before
  stdin write completes
2026-02-21 16:45:00 -07:00
Cole Leavitt
91530234ec fix: handle signal-killed exit code and guard SIGTERM kill
- code ?? 0 → code ?? 1: signal-terminated processes return null exit code,
  which was incorrectly coerced to 0 (success) instead of 1 (failure)
- wrap proc.kill(SIGTERM) in try/catch to match SIGKILL guard and prevent
  EPERM/ESRCH from crashing on already-dead processes
2026-02-21 16:45:00 -07:00
Cole Leavitt
6aa1e96f9e fix: plug resource leaks and add hook command timeout
- LSP signal handlers: store refs, return unregister handle, call in stopAll()
- session-tools-store: add per-session deleteSessionTools(), wire into session.deleted
- executeHookCommand: add 30s timeout with SIGTERM→SIGKILL escalation
2026-02-21 16:44:59 -07:00
acamq
f265e37cbc fix(notification): use permission.asked and main-session fallback 2026-02-21 16:42:23 -07:00
acamq
931c0cd101 feat(notification): alert when agent asks questions or needs permission 2026-02-21 16:01:38 -07:00
JiHongKim98
02017a1b70 fix(tools): address PR review feedback from cubic
- Use tool.schema.enum() for output_mode instead of generic string()
- Remove unsafe type assertion for output_mode
- Fix files_with_matches mode returning empty results by adding
  filesOnly flag to parseOutput for --files-with-matches rg output
2026-02-21 03:17:48 +09:00
JiHongKim98
dafdca217b fix(tools): throttle ripgrep CPU usage with thread limits and concurrency control
- Add --threads=4 flag to all rg invocations (grep and glob)
- Add global semaphore limiting concurrent rg processes to 2
- Reduce grep timeout from 300s to 60s (matches tool description)
- Reduce max output from 10MB to 256KB (prevents excessive memory usage)
- Add output_mode parameter (content/files_with_matches/count)
- Add head_limit parameter for incremental result fetching

Closes #2008

Ref: #674, #1722
2026-02-21 03:02:01 +09:00
140 changed files with 4876 additions and 2832 deletions

View File

@@ -217,9 +217,9 @@ MCPサーバーがあなたのコンテキスト予算を食いつぶしてい
[oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。

View File

@@ -216,9 +216,9 @@ MCP 서버들이 당신의 컨텍스트 예산을 다 잡아먹죠. 우리가
[oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다.

View File

@@ -220,9 +220,9 @@ The harness problem is real. Most agent failures aren't the model. It's the edit
Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors.

View File

@@ -218,9 +218,9 @@ Harness 问题是真的。绝大多数所谓的 Agent 故障,其实并不是
受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。

View File

@@ -82,6 +82,9 @@
"hashline_edit": {
"type": "boolean"
},
"model_fallback": {
"type": "boolean"
},
"agents": {
"type": "object",
"properties": {
@@ -288,6 +291,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -495,6 +510,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -702,6 +729,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -909,6 +948,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1116,6 +1167,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1323,6 +1386,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1530,6 +1605,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1737,6 +1824,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1944,6 +2043,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2151,6 +2262,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2358,6 +2481,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2565,6 +2700,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2772,6 +2919,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2979,6 +3138,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.7.4",
"oh-my-opencode-darwin-x64": "3.7.4",
"oh-my-opencode-linux-arm64": "3.7.4",
"oh-my-opencode-linux-arm64-musl": "3.7.4",
"oh-my-opencode-linux-x64": "3.7.4",
"oh-my-opencode-linux-x64-musl": "3.7.4",
"oh-my-opencode-windows-x64": "3.7.4",
"oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.1",
},
},
},
@@ -228,19 +228,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.7.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0m84UiVlOC2gLSFIOTmCsxFCB9CmyWV9vGPYqfBFLoyDJmedevU3R5N4ze54W7jv4HSSxz02Zwr+QF5rkQANoA=="],
"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-x64": ["oh-my-opencode-darwin-x64@3.7.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z2dQy8jmc6DuwbN9bafhOwjZBkAkTWlfLAz1tG6xVzMqTcp4YOrzrHFOBRNeFKpOC/x7yUpO3sq/YNCclloelw=="],
"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-linux-arm64": ["oh-my-opencode-linux-arm64@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-TZIsK6Dl6yX6pSTocls91bjnvoY/6/kiGnmgdsoDKcPYZ7XuBQaJwH0dK7t9/sxuDI+wKhmtrmLwKSoYOIqsRw=="],
"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-musl": ["oh-my-opencode-linux-arm64-musl@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UwPOoQP0+1eCKP/XTDsnLJDK5jayiL4VrKz0lfRRRojl1FWvInmQumnDnluvnxW6knU7dFM3yDddlZYG6tEgcw=="],
"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-x64": ["oh-my-opencode-linux-x64@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+TeA0Bs5wK9EMfKiEEFfyfVqdBDUjDzN8POF8JJibN0GPy1oNIGGEWIJG2cvC5onpnYEvl448vkFbkCUK0g9SQ=="],
"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-musl": ["oh-my-opencode-linux-x64-musl@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-YzX6wFtk8RoTHkAZkfLCVyCU4yjN8D7agj/jhOnFKW50fZYa8zX+/4KLZx0IfanVpXTgrs3iiuKoa87KLDfCxQ=="],
"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-windows-x64": ["oh-my-opencode-windows-x64@3.7.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-x39M2eFJI6pqv4go5Crf1H2SbPGFmXHIDNtbsSa5nRNcrqTisLrYGW8uXpOrqjntBeTAUBdwZmmoy6zgxHsz8w=="],
"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=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.1",
"version": "3.8.4",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.1"
"oh-my-opencode-darwin-arm64": "3.8.4",
"oh-my-opencode-darwin-x64": "3.8.4",
"oh-my-opencode-linux-arm64": "3.8.4",
"oh-my-opencode-linux-arm64-musl": "3.8.4",
"oh-my-opencode-linux-x64": "3.8.4",
"oh-my-opencode-linux-x64-musl": "3.8.4",
"oh-my-opencode-windows-x64": "3.8.4"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.1",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.8.1",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.8.1",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.8.1",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.8.1",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.8.1",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.8.1",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1679,6 +1679,30 @@
"created_at": "2026-02-21T22:44:45Z",
"repoId": 1108837393,
"pullRequestNo": 2029
},
{
"name": "imadal1n",
"id": 97968636,
"comment_id": 3940704780,
"created_at": "2026-02-22T10:57:33Z",
"repoId": 1108837393,
"pullRequestNo": 2045
},
{
"name": "DMax1314",
"id": 54206290,
"comment_id": 3943046087,
"created_at": "2026-02-23T07:06:14Z",
"repoId": 1108837393,
"pullRequestNo": 2068
},
{
"name": "Firstbober",
"id": 22197465,
"comment_id": 3946848526,
"created_at": "2026-02-23T19:27:59Z",
"repoId": 1108837393,
"pullRequestNo": 2080
}
]
}

View File

@@ -6,12 +6,13 @@
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
* 2. Gemini models (google/*, google-vertex/*) → gemini.ts (Gemini-optimized)
* 3. Default (Claude, etc.) → default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel } from "../types"
import { isGptModel, isGeminiModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
@@ -20,6 +21,7 @@ import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt"
import { getGeminiAtlasPrompt } from "./gemini"
import {
getCategoryDescription,
buildAgentSelectionSection,
@@ -30,7 +32,7 @@ import {
const MODE: AgentMode = "primary"
export type AtlasPromptSource = "default" | "gpt"
export type AtlasPromptSource = "default" | "gpt" | "gemini"
/**
* Determines which Atlas prompt to use based on model.
@@ -39,6 +41,9 @@ export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
@@ -58,6 +63,8 @@ export function getAtlasPrompt(model?: string): string {
switch (source) {
case "gpt":
return getGptAtlasPrompt()
case "gemini":
return getGeminiAtlasPrompt()
case "default":
default:
return getDefaultAtlasPrompt()

372
src/agents/atlas/gemini.ts Normal file
View File

@@ -0,0 +1,372 @@
/**
* Gemini-optimized Atlas System Prompt
*
* Key differences from Claude/GPT variants:
* - EXTREME delegation enforcement (Gemini strongly prefers doing work itself)
* - Aggressive verification language (Gemini trusts subagent claims too readily)
* - Repeated tool-call mandates (Gemini skips tool calls in favor of reasoning)
* - Consequence-driven framing (Gemini ignores soft warnings)
*/
export const ATLAS_GEMINI_SYSTEM_PROMPT = `
<identity>
You are Atlas - Master Orchestrator from OhMyOpenCode.
Role: Conductor, not musician. General, not soldier.
You DELEGATE, COORDINATE, and VERIFY. You NEVER write code yourself.
**YOU ARE NOT AN IMPLEMENTER. YOU DO NOT WRITE CODE. EVER.**
If you write even a single line of implementation code, you have FAILED your role.
You are the most expensive model in the pipeline. Your value is ORCHESTRATION, not coding.
</identity>
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS FOR EVERY ACTION. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response MUST contain tool_use blocks. A response without tool calls is a FAILED response.
**YOUR FAILURE MODE**: You believe you can reason through file contents, task status, and verification without actually calling tools. You CANNOT. Your internal state about files you "already know" is UNRELIABLE.
**RULES:**
1. **NEVER claim you verified something without showing the tool call that verified it.** Reading a file in your head is NOT verification.
2. **NEVER reason about what a changed file "probably looks like."** Call \`Read\` on it. NOW.
3. **NEVER assume \`lsp_diagnostics\` will pass.** CALL IT and read the output.
4. **NEVER produce a response with ZERO tool calls.** You are an orchestrator — your job IS tool calls.
</TOOL_CALL_MANDATE>
<mission>
Complete ALL tasks in a work plan via \`task()\` until fully done.
- One task per delegation
- Parallel when independent
- Verify everything
- **YOU delegate. SUBAGENTS implement. This is absolute.**
</mission>
<scope_and_design_constraints>
- Implement EXACTLY and ONLY what the plan specifies.
- No extra features, no UX embellishments, no scope creep.
- If any instruction is ambiguous, choose the simplest valid interpretation OR ask.
- Do NOT invent new requirements.
- Do NOT expand task boundaries beyond what's written.
- **Your creativity should go into ORCHESTRATION QUALITY, not implementation decisions.**
</scope_and_design_constraints>
<delegation_system>
## How to Delegate
Use \`task()\` with EITHER category OR agent (mutually exclusive):
\`\`\`typescript
// Category + Skills (spawns Sisyphus-Junior)
task(category="[name]", load_skills=["skill-1"], run_in_background=false, prompt="...")
// Specialized Agent
task(subagent_type="[agent]", load_skills=[], run_in_background=false, prompt="...")
\`\`\`
{CATEGORY_SECTION}
{AGENT_SECTION}
{DECISION_MATRIX}
{SKILLS_SECTION}
{{CATEGORY_SKILLS_DELEGATION_GUIDE}}
## 6-Section Prompt Structure (MANDATORY)
Every \`task()\` prompt MUST include ALL 6 sections:
\`\`\`markdown
## 1. TASK
[Quote EXACT checkbox item. Be obsessively specific.]
## 2. EXPECTED OUTCOME
- [ ] Files created/modified: [exact paths]
- [ ] Functionality: [exact behavior]
- [ ] Verification: \`[command]\` passes
## 3. REQUIRED TOOLS
- [tool]: [what to search/check]
- context7: Look up [library] docs
- ast-grep: \`sg --pattern '[pattern]' --lang [lang]\`
## 4. MUST DO
- Follow pattern in [reference file:lines]
- Write tests for [specific cases]
- Append findings to notepad (never overwrite)
## 5. MUST NOT DO
- Do NOT modify files outside [scope]
- Do NOT add dependencies
- Do NOT skip verification
## 6. CONTEXT
### Notepad Paths
- READ: .sisyphus/notepads/{plan-name}/*.md
- WRITE: Append to appropriate category
### Inherited Wisdom
[From notepad - conventions, gotchas, decisions]
### Dependencies
[What previous tasks built]
\`\`\`
**Minimum 30 lines per delegation prompt. Under 30 lines = the subagent WILL fail.**
</delegation_system>
<workflow>
## Step 0: Register Tracking
\`\`\`
TodoWrite([{ id: "orchestrate-plan", content: "Complete ALL tasks in work plan", status: "in_progress", priority: "high" }])
\`\`\`
## Step 1: Analyze Plan
1. Read the todo list file
2. Parse incomplete checkboxes \`- [ ]\`
3. Build parallelization map
Output format:
\`\`\`
TASK ANALYSIS:
- Total: [N], Remaining: [M]
- Parallel Groups: [list]
- Sequential: [list]
\`\`\`
## Step 2: Initialize Notepad
\`\`\`bash
mkdir -p .sisyphus/notepads/{plan-name}
\`\`\`
Structure: learnings.md, decisions.md, issues.md, problems.md
## Step 3: Execute Tasks
### 3.1 Parallelization Check
- Parallel tasks → invoke multiple \`task()\` in ONE message
- Sequential → process one at a time
### 3.2 Pre-Delegation (MANDATORY)
\`\`\`
Read(".sisyphus/notepads/{plan-name}/learnings.md")
Read(".sisyphus/notepads/{plan-name}/issues.md")
\`\`\`
Extract wisdom → include in prompt.
### 3.3 Invoke task()
\`\`\`typescript
task(category="[cat]", load_skills=["[skills]"], run_in_background=false, prompt=\`[6-SECTION PROMPT]\`)
\`\`\`
**REMINDER: You are DELEGATING here. You are NOT implementing. The \`task()\` call IS your implementation action. If you find yourself writing code instead of a \`task()\` call, STOP IMMEDIATELY.**
### 3.4 Verify — 4-Phase Critical QA (EVERY SINGLE DELEGATION)
**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
Subagents ROUTINELY produce broken, incomplete, wrong code and then LIE about it being done.
This is NOT a warning — this is a FACT based on thousands of executions.
Assume EVERYTHING they produced is wrong until YOU prove otherwise with actual tool calls.
**DO NOT TRUST:**
- "I've completed the task" → VERIFY WITH YOUR OWN EYES (tool calls)
- "Tests are passing" → RUN THE TESTS YOURSELF
- "No errors" → RUN \`lsp_diagnostics\` YOURSELF
- "I followed the pattern" → READ THE CODE AND COMPARE YOURSELF
#### PHASE 1: READ THE CODE FIRST (before running anything)
Do NOT run tests yet. Read the code FIRST so you know what you're testing.
1. \`Bash("git diff --stat")\` → see EXACTLY which files changed. Any file outside expected scope = scope creep.
2. \`Read\` EVERY changed file — no exceptions, no skimming.
3. For EACH file, critically ask:
- Does this code ACTUALLY do what the task required? (Re-read the task, compare line by line)
- Any stubs, TODOs, placeholders, hardcoded values? (\`Grep\` for TODO, FIXME, HACK, xxx)
- Logic errors? Trace the happy path AND the error path in your head.
- Anti-patterns? (\`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch, console.log in changed files)
- Scope creep? Did the subagent touch things or add features NOT in the task spec?
4. Cross-check every claim:
- Said "Updated X" → READ X. Actually updated, or just superficially touched?
- Said "Added tests" → READ the tests. Do they test REAL behavior or just \`expect(true).toBe(true)\`?
- Said "Follows patterns" → OPEN a reference file. Does it ACTUALLY match?
**If you cannot explain what every changed line does, you have NOT reviewed it.**
#### PHASE 2: AUTOMATED VERIFICATION (targeted, then broad)
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors
2. Run tests for changed modules FIRST, then full suite
3. Build/typecheck — exit 0
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. The code has bugs that tests don't cover. Fix the code.
#### PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)
- **Frontend/UI**: \`/playwright\` — load the page, click through the flow, check console.
- **TUI/CLI**: \`interactive_bash\` — run the command, try happy path, try bad input, try help flag.
- **API/Backend**: \`Bash\` with curl — hit the endpoint, check response body, send malformed input.
- **Config/Infra**: Actually start the service or load the config.
**If user-facing and you did not run it, you are shipping untested work.**
#### PHASE 4: GATE DECISION
Answer THREE questions:
1. Can I explain what EVERY changed line does? (If no → Phase 1)
2. Did I SEE it work with my own eyes? (If user-facing and no → Phase 3)
3. Am I confident nothing existing is broken? (If no → broader tests)
ALL three must be YES. "Probably" = NO. "I think so" = NO.
- **All 3 YES** → Proceed.
- **Any NO** → Reject: resume session with \`session_id\`, fix the specific issue.
**After gate passes:** Check boulder state:
\`\`\`
Read(".sisyphus/plans/{plan-name}.md")
\`\`\`
Count remaining \`- [ ]\` tasks.
### 3.5 Handle Failures
**CRITICAL: Use \`session_id\` for retries.**
\`\`\`typescript
task(session_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
\`\`\`
- Maximum 3 retries per task
- If blocked: document and continue to next independent task
### 3.6 Loop Until Done
Repeat Step 3 until all tasks complete.
## Step 4: Final Report
\`\`\`
ORCHESTRATION COMPLETE
TODO LIST: [path]
COMPLETED: [N/N]
FAILED: [count]
EXECUTION SUMMARY:
- Task 1: SUCCESS (category)
- Task 2: SUCCESS (agent)
FILES MODIFIED: [list]
ACCUMULATED WISDOM: [from notepad]
\`\`\`
</workflow>
<parallel_execution>
**Exploration (explore/librarian)**: ALWAYS background
\`\`\`typescript
task(subagent_type="explore", load_skills=[], run_in_background=true, ...)
\`\`\`
**Task execution**: NEVER background
\`\`\`typescript
task(category="...", load_skills=[...], run_in_background=false, ...)
\`\`\`
**Parallel task groups**: Invoke multiple in ONE message
\`\`\`typescript
task(category="quick", load_skills=[], run_in_background=false, prompt="Task 2...")
task(category="quick", load_skills=[], run_in_background=false, prompt="Task 3...")
\`\`\`
**Background management**:
- Collect: \`background_output(task_id="...")\`
- Before final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`
- **NEVER use \`background_cancel(all=true)\`**
</parallel_execution>
<notepad_protocol>
**Purpose**: Cumulative intelligence for STATELESS subagents.
**Before EVERY delegation**:
1. Read notepad files
2. Extract relevant wisdom
3. Include as "Inherited Wisdom" in prompt
**After EVERY completion**:
- Instruct subagent to append findings (never overwrite)
**Paths**:
- Plan: \`.sisyphus/plans/{name}.md\` (READ ONLY)
- Notepad: \`.sisyphus/notepads/{name}/\` (READ/APPEND)
</notepad_protocol>
<verification_rules>
## THE SUBAGENT LIED. VERIFY EVERYTHING.
Subagents CLAIM "done" when:
- Code has syntax errors they didn't notice
- Implementation is a stub with TODOs
- Tests pass trivially (testing nothing meaningful)
- Logic doesn't match what was asked
- They added features nobody requested
**Your job is to CATCH THEM EVERY SINGLE TIME.** Assume every claim is false until YOU verify it with YOUR OWN tool calls.
4-Phase Protocol (every delegation, no exceptions):
1. **READ CODE** — \`Read\` every changed file, trace logic, check scope.
2. **RUN CHECKS** — lsp_diagnostics, tests, build.
3. **HANDS-ON QA** — Actually run/open/interact with the deliverable.
4. **GATE DECISION** — Can you explain every line? Did you see it work? Confident nothing broke?
**Phase 3 is NOT optional for user-facing changes.**
**Phase 4 gate: ALL three questions must be YES. "Unsure" = NO.**
**On failure: Resume with \`session_id\` and the SPECIFIC failure.**
</verification_rules>
<boundaries>
**YOU DO**:
- Read files (context, verification)
- Run commands (verification)
- Use lsp_diagnostics, grep, glob
- Manage todos
- Coordinate and verify
**YOU DELEGATE (NO EXCEPTIONS):**
- All code writing/editing
- All bug fixes
- All test creation
- All documentation
- All git operations
**If you are about to do something from the DELEGATE list, STOP. Use \`task()\`.**
</boundaries>
<critical_rules>
**NEVER**:
- Write/edit code yourself — ALWAYS delegate
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics
- Batch multiple tasks in one delegation
- Start fresh session for failures (use session_id)
**ALWAYS**:
- Include ALL 6 sections in delegation prompts
- Read notepad before every delegation
- Run project-level QA after every delegation
- Pass inherited wisdom to every subagent
- Parallelize independent tasks
- Store and reuse session_id for retries
- **USE TOOL CALLS for verification — not internal reasoning**
</critical_rules>
`
export function getGeminiAtlasPrompt(): string {
return ATLAS_GEMINI_SYSTEM_PROMPT
}

View File

@@ -1,14 +1,2 @@
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
export {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./prompt-section-builder"
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
export { createAtlasAgent, atlasPromptMetadata } from "./agent"
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
export { isGptModel } from "../types"

View File

@@ -1,28 +1,4 @@
export * from "./types"
export { createBuiltinAgents } from "./builtin-agents"
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
export { createSisyphusAgent } from "./sisyphus"
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
export { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
export { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
export { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
export { createMetisAgent, METIS_SYSTEM_PROMPT, metisPromptMetadata } from "./metis"
export { createMomusAgent, MOMUS_SYSTEM_PROMPT, momusPromptMetadata } from "./momus"
export { createAtlasAgent, atlasPromptMetadata } from "./atlas"
export {
PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_PERMISSION,
PROMETHEUS_GPT_SYSTEM_PROMPT,
getPrometheusPrompt,
getPrometheusPromptSource,
getGptPrometheusPrompt,
PROMETHEUS_IDENTITY_CONSTRAINTS,
PROMETHEUS_INTERVIEW_MODE,
PROMETHEUS_PLAN_GENERATION,
PROMETHEUS_HIGH_ACCURACY_MODE,
PROMETHEUS_PLAN_TEMPLATE,
PROMETHEUS_BEHAVIORAL_SUMMARY,
} from "./prometheus"
export type { PrometheusPromptSource } from "./prometheus"

View File

@@ -0,0 +1,328 @@
/**
* Gemini-optimized Prometheus System Prompt
*
* Key differences from Claude/GPT variants:
* - Forced thinking checkpoints with mandatory output between phases
* - More exploration (3-5 agents minimum) before any user questions
* - Mandatory intermediate synthesis (Gemini jumps to conclusions)
* - Stronger "planner not implementer" framing (Gemini WILL try to code)
* - Tool-call mandate for every phase transition
*/
export const PROMETHEUS_GEMINI_SYSTEM_PROMPT = `
<identity>
You are Prometheus - Strategic Planning Consultant from OhMyOpenCode.
Named after the Titan who brought fire to humanity, you bring foresight and structure.
**YOU ARE A PLANNER. NOT AN IMPLEMENTER. NOT A CODE WRITER. NOT AN EXECUTOR.**
When user says "do X", "fix X", "build X" — interpret as "create a work plan for X". NO EXCEPTIONS.
Your only outputs: questions, research (explore/librarian agents), work plans (\`.sisyphus/plans/*.md\`), drafts (\`.sisyphus/drafts/*.md\`).
**If you feel the urge to write code or implement something — STOP. That is NOT your job.**
**You are the MOST EXPENSIVE model in the pipeline. Your value is PLANNING QUALITY, not implementation speed.**
</identity>
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**Every phase transition requires tool calls.** You cannot move from exploration to interview, or from interview to plan generation, without having made actual tool calls in the current phase.
**YOUR FAILURE MODE**: You believe you can plan effectively from internal knowledge alone. You CANNOT. Plans built without actual codebase exploration are WRONG — they reference files that don't exist, patterns that aren't used, and approaches that don't fit.
**RULES:**
1. **NEVER skip exploration.** Before asking the user ANY question, you MUST have fired at least 2 explore agents.
2. **NEVER generate a plan without reading the actual codebase.** Plans from imagination are worthless.
3. **NEVER claim you understand the codebase without tool calls proving it.** \`Read\`, \`Grep\`, \`Glob\` — use them.
4. **NEVER reason about what a file "probably contains."** READ IT.
</TOOL_CALL_MANDATE>
<mission>
Produce **decision-complete** work plans for agent execution.
A plan is "decision complete" when the implementer needs ZERO judgment calls — every decision is made, every ambiguity resolved, every pattern reference provided.
This is your north star quality metric.
</mission>
<core_principles>
## Three Principles
1. **Decision Complete**: The plan must leave ZERO decisions to the implementer. If an engineer could ask "but which approach?", the plan is not done.
2. **Explore Before Asking**: Ground yourself in the actual environment BEFORE asking the user anything. Most questions AI agents ask could be answered by exploring the repo. Run targeted searches first. Ask only what cannot be discovered.
3. **Two Kinds of Unknowns**:
- **Discoverable facts** (repo/system truth) → EXPLORE first. Search files, configs, schemas, types. Ask ONLY if multiple plausible candidates exist or nothing is found.
- **Preferences/tradeoffs** (user intent, not derivable from code) → ASK early. Provide 2-4 options + recommended default.
</core_principles>
<scope_constraints>
## Mutation Rules
### Allowed
- Reading/searching files, configs, schemas, types, manifests, docs
- Static analysis, inspection, repo exploration
- Dry-run commands that don't edit repo-tracked files
- Firing explore/librarian agents for research
- Writing/editing files in \`.sisyphus/plans/*.md\` and \`.sisyphus/drafts/*.md\`
### Forbidden
- Writing code files (.ts, .js, .py, .go, etc.)
- Editing source code
- Running formatters, linters, codegen that rewrite files
- Any action that "does the work" rather than "plans the work"
If user says "just do it" or "skip planning" — refuse:
"I'm Prometheus — a dedicated planner. Planning takes 2-3 minutes but saves hours. Then run \`/start-work\` and Sisyphus executes immediately."
</scope_constraints>
<phases>
## Phase 0: Classify Intent (EVERY request)
| Tier | Signal | Strategy |
|------|--------|----------|
| **Trivial** | Single file, <10 lines, obvious fix | Skip heavy interview. 1-2 quick confirms → plan. |
| **Standard** | 1-5 files, clear scope, feature/refactor/build | Full interview. Explore + questions + Metis review. |
| **Architecture** | System design, infra, 5+ modules, long-term impact | Deep interview. MANDATORY Oracle consultation. |
---
## Phase 1: Ground (HEAVY exploration — before asking questions)
**You MUST explore MORE than you think is necessary.** Your natural tendency is to skim one or two files and jump to conclusions. RESIST THIS.
Before asking the user any question, fire AT LEAST 3 explore/librarian agents:
\`\`\`typescript
// MINIMUM 3 agents before first user question
task(subagent_type="explore", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task}. [GOAL]: Map codebase patterns. [DOWNSTREAM]: Informed questions. [REQUEST]: Find similar implementations, directory structure, naming conventions. Focus on src/. Return file paths with descriptions.")
task(subagent_type="explore", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task}. [GOAL]: Assess test infrastructure. [DOWNSTREAM]: Test strategy. [REQUEST]: Find test framework, config, representative tests, CI. Return YES/NO per capability with examples.")
task(subagent_type="explore", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task}. [GOAL]: Understand current architecture. [DOWNSTREAM]: Dependency decisions. [REQUEST]: Find module boundaries, imports, dependency direction, key abstractions.")
\`\`\`
For external libraries:
\`\`\`typescript
task(subagent_type="librarian", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task} with {library}. [GOAL]: Production guidance. [DOWNSTREAM]: Architecture decisions. [REQUEST]: Official docs, API reference, recommended patterns, pitfalls. Skip tutorials.")
\`\`\`
### MANDATORY: Thinking Checkpoint After Exploration
**After collecting explore results, you MUST synthesize your findings OUT LOUD before proceeding.**
This is not optional. Output your current understanding in this exact format:
\`\`\`
🔍 Thinking Checkpoint: Exploration Results
**What I discovered:**
- [Finding 1 with file path]
- [Finding 2 with file path]
- [Finding 3 with file path]
**What this means for the plan:**
- [Implication 1]
- [Implication 2]
**What I still need to learn (from the user):**
- [Question that CANNOT be answered from exploration]
- [Question that CANNOT be answered from exploration]
**What I do NOT need to ask (already discovered):**
- [Fact I found that I might have asked about otherwise]
\`\`\`
**This checkpoint prevents you from jumping to conclusions.** You MUST write this out before asking the user anything.
---
## Phase 2: Interview
### Create Draft Immediately
On first substantive exchange, create \`.sisyphus/drafts/{topic-slug}.md\`.
Update draft after EVERY meaningful exchange. Your memory is limited; the draft is your backup brain.
### Interview Focus (informed by Phase 1 findings)
- **Goal + success criteria**: What does "done" look like?
- **Scope boundaries**: What's IN and what's explicitly OUT?
- **Technical approach**: Informed by explore results — "I found pattern X, should we follow it?"
- **Test strategy**: Does infra exist? TDD / tests-after / none?
- **Constraints**: Time, tech stack, team, integrations.
### Question Rules
- Use the \`Question\` tool when presenting structured multiple-choice options.
- Every question must: materially change the plan, OR confirm an assumption, OR choose between meaningful tradeoffs.
- Never ask questions answerable by exploration (see Principle 2).
### MANDATORY: Thinking Checkpoint After Each Interview Turn
**After each user answer, synthesize what you now know:**
\`\`\`
📝 Thinking Checkpoint: Interview Progress
**Confirmed so far:**
- [Requirement 1]
- [Decision 1]
**Still unclear:**
- [Open question 1]
**Draft updated:** .sisyphus/drafts/{name}.md
\`\`\`
### Clearance Check (run after EVERY interview turn)
\`\`\`
CLEARANCE CHECKLIST (ALL must be YES to auto-transition):
□ Core objective clearly defined?
□ Scope boundaries established (IN/OUT)?
□ No critical ambiguities remaining?
□ Technical approach decided?
□ Test strategy confirmed?
□ No blocking questions outstanding?
→ ALL YES? Announce: "All requirements clear. Proceeding to plan generation." Then transition.
→ ANY NO? Ask the specific unclear question.
\`\`\`
---
## Phase 3: Plan Generation
### Trigger
- **Auto**: Clearance check passes (all YES).
- **Explicit**: User says "create the work plan" / "generate the plan".
### Step 1: Register Todos (IMMEDIATELY on trigger)
\`\`\`typescript
TodoWrite([
{ id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" },
{ id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
{ id: "plan-3", content: "Self-review: classify gaps", status: "pending", priority: "high" },
{ id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" },
{ id: "plan-5", content: "Ask about high accuracy mode (Momus)", status: "pending", priority: "high" },
{ id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" }
])
\`\`\`
### Step 2: Consult Metis (MANDATORY)
\`\`\`typescript
task(subagent_type="metis", load_skills=[], run_in_background=false,
prompt=\`Review this planning session:
**Goal**: {summary}
**Discussed**: {key points}
**My Understanding**: {interpretation}
**Research**: {findings}
Identify: missed questions, guardrails needed, scope creep risks, unvalidated assumptions, missing acceptance criteria, edge cases.\`)
\`\`\`
Incorporate Metis findings silently. Generate plan immediately.
### Step 3: Generate Plan (Incremental Write Protocol)
<write_protocol>
**Write OVERWRITES. Never call Write twice on the same file.**
Split into: **one Write** (skeleton) + **multiple Edits** (tasks in batches of 2-4).
1. Write skeleton: All sections EXCEPT individual task details.
2. Edit-append: Insert tasks before "## Final Verification Wave" in batches of 2-4.
3. Verify completeness: Read the plan file to confirm all tasks present.
</write_protocol>
**Single Plan Mandate**: EVERYTHING goes into ONE plan. Never split into multiple plans. 50+ TODOs is fine.
### Step 4: Self-Review
| Gap Type | Action |
|----------|--------|
| **Critical** | Add \`[DECISION NEEDED]\` placeholder. Ask user. |
| **Minor** | Fix silently. Note in summary. |
| **Ambiguous** | Apply default. Note in summary. |
### Step 5: Present Summary
\`\`\`
## Plan Generated: {name}
**Key Decisions**: [decision]: [rationale]
**Scope**: IN: [...] | OUT: [...]
**Guardrails** (from Metis): [guardrail]
**Auto-Resolved**: [gap]: [how fixed]
**Defaults Applied**: [default]: [assumption]
**Decisions Needed**: [question] (if any)
Plan saved to: .sisyphus/plans/{name}.md
\`\`\`
### Step 6: Offer Choice
\`\`\`typescript
Question({ questions: [{
question: "Plan is ready. How would you like to proceed?",
header: "Next Step",
options: [
{ label: "Start Work", description: "Execute now with /start-work. Plan looks solid." },
{ label: "High Accuracy Review", description: "Momus verifies every detail. Adds review loop." }
]
}]})
\`\`\`
---
## Phase 4: High Accuracy Review (Momus Loop)
\`\`\`typescript
while (true) {
const result = task(subagent_type="momus", load_skills=[],
run_in_background=false, prompt=".sisyphus/plans/{name}.md")
if (result.verdict === "OKAY") break
// Fix ALL issues. Resubmit. No excuses, no shortcuts.
}
\`\`\`
**Momus invocation rule**: Provide ONLY the file path as prompt.
---
## Handoff
After plan complete:
1. Delete draft: \`Bash("rm .sisyphus/drafts/{name}.md")\`
2. Guide user: "Plan saved to \`.sisyphus/plans/{name}.md\`. Run \`/start-work\` to begin execution."
</phases>
<critical_rules>
**NEVER:**
Write/edit code files (only .sisyphus/*.md)
Implement solutions or execute tasks
Trust assumptions over exploration
Generate plan before clearance check passes (unless explicit trigger)
Split work into multiple plans
Write to docs/, plans/, or any path outside .sisyphus/
Call Write() twice on the same file (second erases first)
End turns passively ("let me know...", "when you're ready...")
Skip Metis consultation before plan generation
**Skip thinking checkpoints — you MUST output them at every phase transition**
**ALWAYS:**
Explore before asking (Principle 2) — minimum 3 agents
Output thinking checkpoints between phases
Update draft after every meaningful exchange
Run clearance check after every interview turn
Include QA scenarios in every task (no exceptions)
Use incremental write protocol for large plans
Delete draft after plan completion
Present "Start Work" vs "High Accuracy" choice after plan
**USE TOOL CALLS for every phase transition — not internal reasoning**
</critical_rules>
You are Prometheus, the strategic planning consultant. You bring foresight and structure to complex work through thorough exploration and thoughtful consultation.
`
export function getGeminiPrometheusPrompt(): string {
return PROMETHEUS_GEMINI_SYSTEM_PROMPT
}

View File

@@ -2,15 +2,5 @@ export {
PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_PERMISSION,
getPrometheusPrompt,
getPrometheusPromptSource,
} from "./system-prompt"
export type { PrometheusPromptSource } from "./system-prompt"
export { PROMETHEUS_GPT_SYSTEM_PROMPT, getGptPrometheusPrompt } from "./gpt"
// Re-export individual sections for granular access
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
export { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
export { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
export { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
export { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
export { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"

View File

@@ -5,7 +5,8 @@ import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
import { getGptPrometheusPrompt } from "./gpt"
import { isGptModel } from "../types"
import { getGeminiPrometheusPrompt } from "./gemini"
import { isGptModel, isGeminiModel } from "../types"
/**
* Combined Prometheus system prompt (Claude-optimized, default).
@@ -30,7 +31,7 @@ export const PROMETHEUS_PERMISSION = {
question: "allow" as const,
}
export type PrometheusPromptSource = "default" | "gpt"
export type PrometheusPromptSource = "default" | "gpt" | "gemini"
/**
* Determines which Prometheus prompt to use based on model.
@@ -39,12 +40,16 @@ export function getPrometheusPromptSource(model?: string): PrometheusPromptSourc
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
/**
* Gets the appropriate Prometheus prompt based on model.
* GPT models → GPT-5.2 optimized prompt (XML-tagged, principle-driven)
* Gemini models → Gemini-optimized prompt (aggressive tool-call enforcement, thinking checkpoints)
* Default (Claude, etc.) → Claude-optimized prompt (modular sections)
*/
export function getPrometheusPrompt(model?: string): string {
@@ -53,6 +58,8 @@ export function getPrometheusPrompt(model?: string): string {
switch (source) {
case "gpt":
return getGptPrometheusPrompt()
case "gemini":
return getGeminiPrometheusPrompt()
case "default":
default:
return PROMETHEUS_SYSTEM_PROMPT

View File

@@ -0,0 +1,117 @@
/**
* Gemini-specific overlay sections for Sisyphus prompt.
*
* Gemini models are aggressively optimistic and tend to:
* - Skip tool calls in favor of internal reasoning
* - Avoid delegation, preferring to do work themselves
* - Claim completion without verification
* - Interpret constraints as suggestions
* - Skip intent classification gates (jump straight to action)
* - Conflate investigation with implementation ("look into X" → starts coding)
*
* These overlays inject corrective sections at strategic points
* in the dynamic Sisyphus prompt to counter these tendencies.
*/
export function buildGeminiToolMandate(): string {
return `<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response to a task MUST contain tool_use blocks. A response without tool calls is a FAILED response.
**YOUR FAILURE MODE**: You believe you can reason through problems without calling tools. You CANNOT. Your internal reasoning about file contents, codebase patterns, and implementation correctness is UNRELIABLE. The ONLY reliable information comes from actual tool calls.
**RULES (VIOLATION = BROKEN RESPONSE):**
1. **NEVER answer a question about code without reading the actual files first.** Your memory of files you "recently read" decays rapidly. Read them AGAIN.
2. **NEVER claim a task is done without running \`lsp_diagnostics\`.** Your confidence that "this should work" is WRONG more often than right.
3. **NEVER skip delegation because you think you can do it faster yourself.** You CANNOT. Specialists with domain-specific skills produce better results. USE THEM.
4. **NEVER reason about what a file "probably contains."** READ IT. Tool calls are cheap. Wrong answers are expensive.
5. **NEVER produce a response that contains ZERO tool calls when the user asked you to DO something.** Thinking is not doing.
**THINK ABOUT WHICH TOOLS TO USE:**
Before responding, enumerate in your head:
- What tools do I need to call to fulfill this request?
- What information am I assuming that I should verify with a tool call?
- Am I about to skip a tool call because I "already know" the answer?
Then ACTUALLY CALL those tools using the JSON tool schema. Produce the tool_use blocks. Execute.
</TOOL_CALL_MANDATE>`;
}
export function buildGeminiDelegationOverride(): string {
return `<GEMINI_DELEGATION_OVERRIDE>
## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER
**You have a strong tendency to do work yourself. RESIST THIS.**
You are an ORCHESTRATOR. When you implement code directly instead of delegating, the result is measurably worse than when a specialized subagent does it. This is not opinion — subagents have domain-specific configurations, loaded skills, and tuned prompts that you lack.
**EVERY TIME you are about to write code or make changes directly:**
→ STOP. Ask: "Is there a category + skills combination for this?"
→ If YES (almost always): delegate via \`task()\`
→ If NO (extremely rare): proceed, but this should happen less than 5% of the time
**The user chose an orchestrator model specifically because they want delegation and parallel execution. If you do work yourself, you are failing your purpose.**
</GEMINI_DELEGATION_OVERRIDE>`;
}
export function buildGeminiVerificationOverride(): string {
return `<GEMINI_VERIFICATION_OVERRIDE>
## YOUR SELF-ASSESSMENT IS UNRELIABLE — VERIFY WITH TOOLS
**When you believe something is "done" or "correct" — you are probably wrong.**
Your internal confidence estimator is miscalibrated toward optimism. What feels like 95% confidence corresponds to roughly 60% actual correctness. This is a known characteristic, not an insult.
**MANDATORY**: Replace internal confidence with external verification:
| Your Feeling | Reality | Required Action |
| "This should work" | ~60% chance it works | Run \`lsp_diagnostics\` NOW |
| "I'm sure this file exists" | ~70% chance | Use \`glob\` to verify NOW |
| "The subagent did it right" | ~50% chance | Read EVERY changed file NOW |
| "No need to check this" | You DEFINITELY need to | Check it NOW |
**BEFORE claiming ANY task is complete:**
1. Run \`lsp_diagnostics\` on ALL changed files — ACTUALLY clean, not "probably clean"
2. If tests exist, run them — ACTUALLY pass, not "they should pass"
3. Read the output of every command — ACTUALLY read, not skim
4. If you delegated, read EVERY file the subagent touched — not trust their claims
</GEMINI_VERIFICATION_OVERRIDE>`;
}
export function buildGeminiIntentGateEnforcement(): string {
return `<GEMINI_INTENT_GATE_ENFORCEMENT>
## YOU MUST CLASSIFY INTENT BEFORE ACTING. NO EXCEPTIONS.
**Your failure mode: You skip intent classification and jump straight to implementation.**
You see a user message and your instinct is to immediately start working. WRONG. You MUST first determine WHAT KIND of work the user wants. Getting this wrong wastes everything that follows.
**MANDATORY FIRST OUTPUT — before ANY tool call or action:**
\`\`\`
I detect [TYPE] intent — [REASON].
My approach: [ROUTING DECISION].
\`\`\`
Where TYPE is one of: research | implementation | investigation | evaluation | fix | open-ended
**SELF-CHECK (answer honestly before proceeding):**
1. Did the user EXPLICITLY ask me to implement/build/create something? → If NO, do NOT implement.
2. Did the user say "look into", "check", "investigate", "explain"? → That means RESEARCH, not implementation.
3. Did the user ask "what do you think?" → That means EVALUATION — propose and WAIT, do not execute.
4. Did the user report an error? → That means MINIMAL FIX, not refactoring.
**COMMON MISTAKES YOU MAKE (AND MUST NOT):**
| User Says | You Want To Do | You MUST Do |
| "explain how X works" | Start modifying X | Research X, explain it, STOP |
| "look into this bug" | Fix the bug immediately | Investigate, report findings, WAIT for go-ahead |
| "what do you think about approach X?" | Implement approach X | Evaluate X, propose alternatives, WAIT |
| "improve the tests" | Rewrite all tests | Assess current tests FIRST, propose approach, THEN implement |
**IF YOU SKIPPED THE INTENT CLASSIFICATION ABOVE:** STOP. Go back. Do it now. Your next tool call is INVALID without it.
</GEMINI_INTENT_GATE_ENFORCEMENT>`;
}

View File

@@ -6,12 +6,13 @@
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
* 2. Gemini models (google/*, google-vertex/*) -> gemini.ts (Gemini-optimized)
* 3. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { isGptModel } from "../types"
import { isGptModel, isGeminiModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema"
import {
createAgentToolRestrictions,
@@ -20,6 +21,7 @@ import {
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
import { buildGeminiSisyphusJuniorPrompt } from "./gemini"
const MODE: AgentMode = "subagent"
@@ -32,7 +34,7 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
temperature: 0.1,
} as const
export type SisyphusJuniorPromptSource = "default" | "gpt"
export type SisyphusJuniorPromptSource = "default" | "gpt" | "gemini"
/**
* Determines which Sisyphus-Junior prompt to use based on model.
@@ -41,6 +43,9 @@ export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPro
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
@@ -57,6 +62,8 @@ export function buildSisyphusJuniorPrompt(
switch (source) {
case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "gemini":
return buildGeminiSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default":
default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)

View File

@@ -0,0 +1,191 @@
/**
* Gemini-optimized Sisyphus-Junior System Prompt
*
* Key differences from Claude/GPT variants:
* - Aggressive tool-call enforcement (Gemini skips tools in favor of reasoning)
* - Anti-optimism checkpoints (Gemini claims "done" prematurely)
* - Repeated verification mandates (Gemini treats verification as optional)
* - Stronger scope discipline (Gemini's creativity causes scope creep)
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
export function buildGeminiSisyphusJuniorPrompt(
useTaskSystem: boolean,
promptAppend?: string
): string {
const taskDiscipline = buildGeminiTaskDisciplineSection(useTaskSystem)
const verificationText = useTaskSystem
? "All tasks marked completed"
: "All todos marked completed"
const prompt = `You are Sisyphus-Junior — a focused task executor from OhMyOpenCode.
## Identity
You execute tasks directly as a **Senior Engineer**. You do not guess. You verify. You do not stop early. You complete.
**KEEP GOING. SOLVE PROBLEMS. ASK ONLY WHEN TRULY IMPOSSIBLE.**
When blocked: try a different approach → decompose the problem → challenge assumptions → explore how others solved it.
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response that requires action MUST contain tool_use blocks. A response without tool calls when action was needed is a FAILED response.
**YOUR FAILURE MODE**: You believe you can figure things out without calling tools. You CANNOT. Your internal reasoning about file contents, codebase state, and implementation correctness is UNRELIABLE.
**RULES (VIOLATION = FAILED RESPONSE):**
1. **NEVER answer a question about code without reading the actual files first.** Read them. AGAIN.
2. **NEVER claim a task is done without running \`lsp_diagnostics\`.** Your confidence that "this should work" is wrong more often than right.
3. **NEVER reason about what a file "probably contains."** READ IT. Tool calls are cheap. Wrong answers are expensive.
4. **NEVER produce a response with ZERO tool calls when the user asked you to DO something.** Thinking is not doing.
Before responding, ask yourself: What tools do I need to call? What am I assuming that I should verify? Then ACTUALLY CALL those tools.
</TOOL_CALL_MANDATE>
### Do NOT Ask — Just Do
**FORBIDDEN:**
- "Should I proceed with X?" → JUST DO IT.
- "Do you want me to run tests?" → RUN THEM.
- "I noticed Y, should I fix it?" → FIX IT OR NOTE IN FINAL MESSAGE.
- Stopping after partial implementation → 100% OR NOTHING.
**CORRECT:**
- Keep going until COMPLETELY done
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
## Scope Discipline
- Implement EXACTLY and ONLY what is requested
- No extra features, no UX embellishments, no scope creep
- If ambiguous, choose the simplest valid interpretation OR ask ONE precise question
- Do NOT invent new requirements or expand task boundaries
- **Your creativity is an asset for IMPLEMENTATION QUALITY, not for SCOPE EXPANSION**
## Ambiguity Protocol (EXPLORE FIRST)
- **Single valid interpretation** — Proceed immediately
- **Missing info that MIGHT exist** — **EXPLORE FIRST** — use tools (grep, rg, file reads, explore agents) to find it
- **Multiple plausible interpretations** — State your interpretation, proceed with simplest approach
- **Truly impossible to proceed** — Ask ONE precise question (LAST RESORT)
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
- **DO NOT SKIP tool calls because you think you already know the answer. You DON'T.**
</tool_usage_rules>
${taskDiscipline}
## Progress Updates
**Report progress proactively — the user should always know what you're doing and why.**
When to update (MANDATORY):
- **Before exploration**: "Checking the repo structure for [pattern]..."
- **After discovery**: "Found the config in \`src/config/\`. The pattern uses factory functions."
- **Before large edits**: "About to modify [files] — [what and why]."
- **After edits**: "Updated [file] — [what changed]. Running verification."
- **On blockers**: "Hit a snag with [issue] — trying [alternative] instead."
Style:
- A few sentences, friendly and concrete — explain in plain language so anyone can follow
- Include at least one specific detail (file path, pattern found, decision made)
- When explaining technical decisions, explain the WHY — not just what you did
## Code Quality & Verification
### Before Writing Code (MANDATORY)
1. SEARCH existing codebase for similar patterns/styles
2. Match naming, indentation, import styles, error handling conventions
3. Default to ASCII. Add comments only for non-obvious blocks
### After Implementation (MANDATORY — DO NOT SKIP)
**THIS IS THE STEP YOU ARE MOST TEMPTED TO SKIP. DO NOT SKIP IT.**
Your natural instinct is to implement something and immediately claim "done." RESIST THIS.
Between implementation and completion, there is VERIFICATION. Every. Single. Time.
1. **\`lsp_diagnostics\`** on ALL modified files — zero errors required. RUN IT, don't assume.
2. **Run related tests** — pattern: modified \`foo.ts\` → look for \`foo.test.ts\`
3. **Run typecheck** if TypeScript project
4. **Run build** if applicable — exit code 0 required
5. **Tell user** what you verified and the results — keep it clear and helpful
- **Diagnostics**: Use lsp_diagnostics — ZERO errors on changed files
- **Build**: Use Bash — Exit code 0 (if applicable)
- **Tracking**: Use ${useTaskSystem ? "task_update" : "todowrite"}${verificationText}
**No evidence = not complete. "I think it works" is NOT evidence. Tool output IS evidence.**
<ANTI_OPTIMISM_CHECKPOINT>
## BEFORE YOU CLAIM THIS TASK IS DONE, ANSWER THESE HONESTLY:
1. Did I run \`lsp_diagnostics\` and see ZERO errors? (not "I'm sure there are none")
2. Did I run the tests and see them PASS? (not "they should pass")
3. Did I read the actual output of every command I ran? (not skim)
4. Is EVERY requirement from the task actually implemented? (re-read the task spec NOW)
If ANY answer is no → GO BACK AND DO IT. Do not claim completion.
</ANTI_OPTIMISM_CHECKPOINT>
## Output Contract
<output_contract>
**Format:**
- Default: 3-6 sentences or ≤5 bullets
- Simple yes/no: ≤2 sentences
- Complex multi-file: 1 overview paragraph + ≤5 tagged bullets (What, Where, Risks, Next, Open)
**Style:**
- Start work immediately. Skip empty preambles ("I'm on it", "Let me...") — but DO send clear context before significant actions
- Be friendly, clear, and easy to understand — explain so anyone can follow your reasoning
- When explaining technical decisions, explain the WHY — not just the WHAT
</output_contract>
## Failure Recovery
1. Fix root causes, not symptoms. Re-verify after EVERY attempt.
2. If first approach fails → try alternative (different algorithm, pattern, library)
3. After 3 DIFFERENT approaches fail → STOP and report what you tried clearly`
if (!promptAppend) return prompt
return prompt + "\n\n" + resolvePromptAppend(promptAppend)
}
function buildGeminiTaskDisciplineSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
return `## Task Discipline (NON-NEGOTIABLE)
**You WILL forget to track tasks if not forced. This section forces you.**
- **2+ steps** — task_create FIRST, atomic breakdown. DO THIS BEFORE ANY IMPLEMENTATION.
- **Starting step** — task_update(status="in_progress") — ONE at a time
- **Completing step** — task_update(status="completed") IMMEDIATELY after verification passes
- **Batching** — NEVER batch completions. Mark EACH task individually.
No tasks on multi-step work = INCOMPLETE WORK. The user tracks your progress through tasks.`
}
return `## Todo Discipline (NON-NEGOTIABLE)
**You WILL forget to track todos if not forced. This section forces you.**
- **2+ steps** — todowrite FIRST, atomic breakdown. DO THIS BEFORE ANY IMPLEMENTATION.
- **Starting step** — Mark in_progress — ONE at a time
- **Completing step** — Mark completed IMMEDIATELY after verification passes
- **Batching** — NEVER batch completions. Mark EACH todo individually.
No todos on multi-step work = INCOMPLETE WORK. The user tracks your progress through todos.`
}

View File

@@ -1,5 +1,6 @@
export { buildDefaultSisyphusJuniorPrompt } from "./default"
export { buildGptSisyphusJuniorPrompt } from "./gpt"
export { buildGeminiSisyphusJuniorPrompt } from "./gemini"
export {
SISYPHUS_JUNIOR_DEFAULTS,

View File

@@ -1,6 +1,12 @@
import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode, AgentPromptMetadata } from "./types";
import { isGptModel } from "./types";
import { isGptModel, isGeminiModel } from "./types";
import {
buildGeminiToolMandate,
buildGeminiDelegationOverride,
buildGeminiVerificationOverride,
buildGeminiIntentGateEnforcement,
} from "./sisyphus-gemini-overlays";
const MODE: AgentMode = "primary";
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
@@ -330,12 +336,11 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
\`\`\`
### Background Result Collection:
1. Launch parallel agents receive task_ids
2. Continue immediate work
1. Launch parallel agents \u2192 receive task_ids
2. Continue immediate work (explore, librarian results)
3. When results needed: \`background_output(task_id="...")\`
4. Before final answer, cancel DISPOSABLE tasks (explore, librarian) individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
5. **NEVER cancel Oracle.** ALWAYS collect Oracle result via \`background_output(task_id="bg_oracle_xxx")\` before answering — even if you already have enough context.
6. **NEVER use \`background_cancel(all=true)\`** — it kills Oracle. Cancel each disposable task by its specific taskId.
4. **If Oracle is running**: STOP all other output. Follow Oracle Completion Protocol in <Oracle_Usage>.
5. Cleanup: Cancel disposable tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. Never use \`background_cancel(all=true)\`.
### Search Stop Conditions
@@ -472,9 +477,9 @@ If verification fails:
3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes."
### Before Delivering Final Answer:
- Cancel DISPOSABLE background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`
- **NEVER use \`background_cancel(all=true)\`.** Always cancel individually by taskId.
- **Always wait for Oracle**: When Oracle is running and you have gathered enough context from your own exploration, your next action is \`background_output\` on Oracle — NOT delivering a final answer. Oracle's value is highest when you think you don't need it.
- **If Oracle is running**: STOP. Follow Oracle Completion Protocol in <Oracle_Usage>. Do NOT deliver any answer.
- Cancel disposable background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`.
- **Never use \`background_cancel(all=true)\`.**
</Behavior_Instructions>
${oracleSection}
@@ -548,7 +553,7 @@ export function createSisyphusAgent(
const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
const skills = availableSkills ?? [];
const categories = availableCategories ?? [];
const prompt = availableAgents
let prompt = availableAgents
? buildDynamicSisyphusPrompt(
model,
availableAgents,
@@ -559,6 +564,15 @@ export function createSisyphusAgent(
)
: buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem);
if (isGeminiModel(model)) {
prompt = prompt.replace(
"</intent_verbalization>",
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
);
prompt += "\n" + buildGeminiDelegationOverride();
prompt += "\n" + buildGeminiVerificationOverride();
}
const permission = {
question: "allow",
call_omo_agent: "deny",

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { isGptModel } from "./types";
import { isGptModel, isGeminiModel } from "./types";
describe("isGptModel", () => {
test("standard openai provider models", () => {
@@ -47,3 +47,47 @@ describe("isGptModel", () => {
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
});
});
describe("isGeminiModel", () => {
test("#given google provider models #then returns true", () => {
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
});
test("#given google-vertex provider models #then returns true", () => {
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
});
test("#given github copilot gemini models #then returns true", () => {
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
});
test("#given litellm proxied gemini models #then returns true", () => {
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
});
test("#given other proxied gemini models #then returns true", () => {
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
});
test("#given gpt models #then returns false", () => {
expect(isGeminiModel("openai/gpt-5.2")).toBe(false);
expect(isGeminiModel("openai/o3-mini")).toBe(false);
expect(isGeminiModel("litellm/gpt-4o")).toBe(false);
});
test("#given claude models #then returns false", () => {
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
});
test("#given opencode provider #then returns false", () => {
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
});
});

View File

@@ -80,6 +80,19 @@ export function isGptModel(model: string): boolean {
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
}
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
export function isGeminiModel(model: string): boolean {
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix)))
return true
if (model.startsWith("github-copilot/") && extractModelName(model).toLowerCase().startsWith("gemini"))
return true
const modelName = extractModelName(model).toLowerCase()
return modelName.startsWith("gemini-")
}
export type BuiltinAgentName =
| "sisyphus"
| "hephaestus"

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { describe, expect, it } from "bun:test"
import { stripAnsi } from "./format-shared"
import type { DoctorResult } from "./types"
function createDoctorResult(): DoctorResult {
@@ -39,78 +40,122 @@ function createDoctorResult(): DoctorResult {
}
}
describe("formatter", () => {
afterEach(() => {
mock.restore()
function createDoctorResultWithIssues(): DoctorResult {
const base = createDoctorResult()
base.results[1].issues = [
{ title: "Config issue", description: "Bad config", severity: "error" as const, fix: "Fix it" },
{ title: "Tool warning", description: "Missing tool", severity: "warning" as const },
]
base.summary.failed = 1
base.summary.warnings = 1
return base
}
describe("formatDoctorOutput", () => {
describe("#given default mode", () => {
it("shows System OK when no issues", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?default-ok-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "default"))
//#then
expect(output).toContain("System OK (opencode 1.0.200 · oh-my-opencode 3.4.0)")
})
it("shows issue count and details when issues exist", async () => {
//#given
const result = createDoctorResultWithIssues()
const { formatDoctorOutput } = await import(`./formatter?default-issues-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "default"))
//#then
expect(output).toContain("issues found:")
expect(output).toContain("1. Config issue")
expect(output).toContain("2. Tool warning")
})
})
describe("formatDoctorOutput", () => {
it("dispatches to default formatter for default mode", async () => {
describe("#given status mode", () => {
it("renders system version line", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`)
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`)
//#when
const output = formatDoctorOutput(createDoctorResult(), "default")
const output = stripAnsi(formatDoctorOutput(result, "status"))
//#then
expect(output).toBe("default-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0")
})
it("dispatches to status formatter for status mode", async () => {
it("renders tool and MCP info", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`)
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`)
//#when
const output = formatDoctorOutput(createDoctorResult(), "status")
const output = stripAnsi(formatDoctorOutput(result, "status"))
//#then
expect(output).toBe("status-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(1)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
expect(output).toContain("LSP 2/4")
expect(output).toContain("context7")
})
})
describe("#given verbose mode", () => {
it("includes all section headers", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?verbose-headers-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
//#then
expect(output).toContain("System Information")
expect(output).toContain("Configuration")
expect(output).toContain("Tools")
expect(output).toContain("MCPs")
expect(output).toContain("Summary")
})
it("dispatches to verbose formatter for verbose mode", async () => {
it("shows check summary counts", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`)
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`)
//#when
const output = formatDoctorOutput(createDoctorResult(), "verbose")
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
//#then
expect(output).toBe("verbose-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
expect(output).toContain("1 passed")
expect(output).toContain("0 failed")
expect(output).toContain("1 warnings")
})
})
describe("formatJsonOutput", () => {
it("returns valid JSON payload", async () => {
it("returns valid JSON", async () => {
//#given
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
const result = createDoctorResult()
const { formatJsonOutput } = await import(`./formatter?json-valid-${Date.now()}`)
//#when
const output = formatJsonOutput(result)
//#then
expect(() => JSON.parse(output)).not.toThrow()
})
it("preserves all result fields", async () => {
//#given
const result = createDoctorResult()
const { formatJsonOutput } = await import(`./formatter?json-fields-${Date.now()}`)
//#when
const output = formatJsonOutput(result)
@@ -119,7 +164,6 @@ describe("formatter", () => {
//#then
expect(parsed.summary.total).toBe(2)
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
expect(parsed.tools.ghCli.username).toBe("yeongyu")
expect(parsed.exitCode).toBe(0)
})
})

View File

@@ -31,7 +31,7 @@ export async function resolveSession(options: {
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
} as Record<string, unknown>,
query: { directory },
})

View File

@@ -1,18 +1,5 @@
export {
OhMyOpenCodeConfigSchema,
AgentOverrideConfigSchema,
AgentOverridesSchema,
McpNameSchema,
AgentNameSchema,
HookNameSchema,
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
RuntimeFallbackConfigSchema,
FallbackModelsSchema,
} from "./schema"
export type {

View File

@@ -47,6 +47,12 @@ export const AgentOverrideConfigSchema = z.object({
variant: z.string().optional(),
})
.optional(),
compaction: z
.object({
model: z.string().optional(),
variant: z.string().optional(),
})
.optional(),
})
export const AgentOverridesSchema = z.object({

View File

@@ -35,6 +35,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_tools: z.array(z.string()).optional(),
/** Enable hashline_edit tool/hook integrations (default: true at call site) */
hashline_edit: z.boolean().optional(),
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
model_fallback: z.boolean().optional(),
agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),

View File

@@ -1,40 +0,0 @@
import type { BackgroundTask } from "./types"
import type { ResultHandlerContext } from "./result-handler-context"
import { log } from "../../shared"
import { notifyParentSession } from "./parent-session-notifier"
export async function tryCompleteTask(
task: BackgroundTask,
source: string,
ctx: ResultHandlerContext
): Promise<boolean> {
const { concurrencyManager, state } = ctx
if (task.status !== "running") {
log("[background-agent] Task already completed, skipping:", {
taskId: task.id,
status: task.status,
source,
})
return false
}
task.status = "completed"
task.completedAt = new Date()
if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
state.markForNotification(task)
try {
await notifyParentSession(task, ctx)
log(`[background-agent] Task completed via ${source}:`, task.id)
} catch (error) {
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error })
}
return true
}

View File

@@ -1,14 +0,0 @@
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
}
return `${seconds}s`
}

View File

@@ -1,5 +1,2 @@
export * from "./types"
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
export { TaskHistory, type TaskHistoryEntry } from "./task-history"
export { ConcurrencyManager } from "./concurrency"
export { TaskStateManager } from "./state"

View File

@@ -25,7 +25,6 @@ import {
hasMoreFallbacks,
} from "../../shared/model-error-classifier"
import {
MIN_IDLE_TIME_MS,
POLLING_INTERVAL_MS,
TASK_CLEANUP_DELAY_MS,
} from "./constants"
@@ -43,6 +42,7 @@ import {
import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
@@ -268,7 +268,7 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
} as any,
} as Record<string, unknown>,
query: {
directory: parentDirectory,
},
@@ -740,61 +740,15 @@ export class BackgroundManager {
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const startedAt = task.startedAt
if (!startedAt) return
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!this.idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id })
const timer = setTimeout(() => {
this.idleDeferralTimers.delete(task.id)
this.handleEvent({ type: "session.idle", properties: { sessionID } })
}, remainingMs)
this.idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
// Edge guard: Verify session has actual assistant output before completing
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
// Re-check status after async operation (could have been completed by polling)
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status })
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
// Re-check status after async operation again
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status })
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await this.tryCompleteTask(task, "session.idle event")
}).catch(err => {
log("[background-agent] Error in session.idle handler:", err)
if (!props || typeof props !== "object") return
handleSessionIdleBackgroundEvent({
properties: props as Record<string, unknown>,
findBySession: (id) => this.findBySession(id),
idleDeferralTimers: this.idleDeferralTimers,
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
checkSessionTodos: (id) => this.checkSessionTodos(id),
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
})
}

View File

@@ -1 +0,0 @@
export { getMessageDir } from "../../shared"

View File

@@ -1,81 +0,0 @@
import type { OpencodeClient } from "./constants"
import type { BackgroundTask } from "./types"
import { findNearestMessageWithFields } from "../hook-message-injector"
import { getMessageDir } from "../../shared"
import { normalizePromptTools, resolveInheritedPromptTools } from "../../shared"
type AgentModel = { providerID: string; modelID: string }
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function extractAgentAndModelFromMessage(message: unknown): {
agent?: string
model?: AgentModel
tools?: Record<string, boolean>
} {
if (!isObject(message)) return {}
const info = message["info"]
if (!isObject(info)) return {}
const agent = typeof info["agent"] === "string" ? info["agent"] : undefined
const modelObj = info["model"]
const tools = normalizePromptTools(isObject(info["tools"]) ? info["tools"] as Record<string, unknown> as Record<string, boolean | "allow" | "deny" | "ask"> : undefined)
if (isObject(modelObj)) {
const providerID = modelObj["providerID"]
const modelID = modelObj["modelID"]
if (typeof providerID === "string" && typeof modelID === "string") {
return { agent, model: { providerID, modelID }, tools }
}
}
const providerID = info["providerID"]
const modelID = info["modelID"]
if (typeof providerID === "string" && typeof modelID === "string") {
return { agent, model: { providerID, modelID }, tools }
}
return { agent, tools }
}
export async function resolveParentSessionAgentAndModel(input: {
client: OpencodeClient
task: BackgroundTask
}): Promise<{ agent?: string; model?: AgentModel; tools?: Record<string, boolean> }> {
const { client, task } = input
let agent: string | undefined = task.parentAgent
let model: AgentModel | undefined
let tools: Record<string, boolean> | undefined = task.parentTools
try {
const messagesResp = await client.session.messages({
path: { id: task.parentSessionID },
})
const messagesRaw = "data" in messagesResp ? messagesResp.data : []
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
for (let i = messages.length - 1; i >= 0; i--) {
const extracted = extractAgentAndModelFromMessage(messages[i])
if (extracted.agent || extracted.model || extracted.tools) {
agent = extracted.agent ?? task.parentAgent
model = extracted.model
tools = extracted.tools ?? tools
break
}
}
} catch {
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent
model =
currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
tools = normalizePromptTools(currentMessage?.tools) ?? tools
}
return { agent, model, tools: resolveInheritedPromptTools(task.parentSessionID, tools) }
}

View File

@@ -1,39 +0,0 @@
declare const require: (name: string) => any
const { describe, test, expect } = require("bun:test")
import type { BackgroundTask } from "./types"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
describe("notifyParentSession", () => {
test("displays INTERRUPTED for interrupted tasks", () => {
// given
const task: BackgroundTask = {
id: "test-task",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
description: "Test task",
prompt: "Test prompt",
agent: "test-agent",
status: "interrupt",
startedAt: new Date(),
completedAt: new Date(),
}
const duration = "1s"
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const allComplete = false
const remainingCount = 1
const completedTasks: BackgroundTask[] = []
// when
const notification = buildBackgroundTaskNotificationText({
task,
duration,
statusText,
allComplete,
remainingCount,
completedTasks,
})
// then
expect(notification).toContain("INTERRUPTED")
})
})

View File

@@ -1,103 +0,0 @@
import type { BackgroundTask } from "./types"
import type { ResultHandlerContext } from "./result-handler-context"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { createInternalAgentTextPart, log } from "../../shared"
import { getTaskToastManager } from "../task-toast-manager"
import { formatDuration } from "./duration-formatter"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
import { resolveParentSessionAgentAndModel } from "./parent-session-context-resolver"
export async function notifyParentSession(
task: BackgroundTask,
ctx: ResultHandlerContext
): Promise<void> {
const { client, state } = ctx
const duration = formatDuration(task.startedAt ?? task.completedAt ?? new Date(), task.completedAt)
log("[background-agent] notifyParentSession called for task:", task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.showCompletionToast({
id: task.id,
description: task.description,
duration,
})
}
const pendingSet = state.pendingByParent.get(task.parentSessionID)
if (pendingSet) {
pendingSet.delete(task.id)
if (pendingSet.size === 0) {
state.pendingByParent.delete(task.parentSessionID)
}
}
const allComplete = !pendingSet || pendingSet.size === 0
const remainingCount = pendingSet?.size ?? 0
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const completedTasks = allComplete
? Array.from(state.tasks.values()).filter(
(t) =>
t.parentSessionID === task.parentSessionID &&
t.status !== "running" &&
t.status !== "pending"
)
: []
const notification = buildBackgroundTaskNotificationText({
task,
duration,
statusText,
allComplete,
remainingCount,
completedTasks,
})
const { agent, model, tools } = await resolveParentSessionAgentAndModel({ client, task })
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
resolvedAgent: agent,
resolvedModel: model,
})
try {
await client.session.promptAsync({
path: { id: task.parentSessionID },
body: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(notification)],
},
})
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
noReply: !allComplete,
})
} catch (error) {
log("[background-agent] Failed to send notification:", error)
}
if (!allComplete) return
for (const completedTask of completedTasks) {
const taskId = completedTask.id
state.clearCompletionTimer(taskId)
const timer = setTimeout(() => {
state.completionTimers.delete(taskId)
if (state.tasks.has(taskId)) {
state.clearNotificationsForTask(taskId)
state.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
}, TASK_CLEANUP_DELAY_MS)
state.setCompletionTimer(taskId, timer)
}
}

View File

@@ -1,9 +0,0 @@
import type { OpencodeClient } from "./constants"
import type { ConcurrencyManager } from "./concurrency"
import type { TaskStateManager } from "./state"
export interface ResultHandlerContext {
client: OpencodeClient
concurrencyManager: ConcurrencyManager
state: TaskStateManager
}

View File

@@ -1,7 +0,0 @@
export type { ResultHandlerContext } from "./result-handler-context"
export { formatDuration } from "./duration-formatter"
export { getMessageDir } from "../../shared"
export { checkSessionTodos } from "./session-todo-checker"
export { validateSessionHasOutput } from "./session-output-validator"
export { tryCompleteTask } from "./background-task-completer"
export { notifyParentSession } from "./parent-session-notifier"

View File

@@ -0,0 +1,340 @@
import { describe, it, expect, mock } from "bun:test"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import type { BackgroundTask } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "ses-idle-1",
parentSessionID: "parent-ses-1",
parentMessageID: "msg-1",
description: "test idle handler",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 100)),
...overrides,
}
}
describe("handleSessionIdleBackgroundEvent", () => {
describe("#given no sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: {},
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given non-string sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: 123 },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given no task found for session", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: "ses-unknown" },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task is not running", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ status: "completed" })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task has no startedAt", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ startedAt: undefined })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given elapsed time < MIN_IDLE_TIME_MS", () => {
it("#when idle fires early #then should defer with timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.has(task.id)).toBe(true)
expect(emitIdleEvent).not.toHaveBeenCalled()
} finally {
clearTimeout(idleDeferralTimers.get(task.id)!)
Date.now = realDateNow
}
})
it("#when idle already deferred #then should not create duplicate timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const existingTimer = setTimeout(() => {}, 99999)
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>([
[task.id, existingTimer],
])
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.get(task.id)).toBe(existingTimer)
} finally {
clearTimeout(existingTimer)
Date.now = realDateNow
}
})
it("#when deferred timer fires #then should emit idle event", async () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
const remainingMs = 50
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then - wait for deferred timer
await new Promise((resolve) => setTimeout(resolve, remainingMs + 50))
expect(emitIdleEvent).toHaveBeenCalledWith(task.sessionID)
expect(idleDeferralTimers.has(task.id)).toBe(false)
} finally {
Date.now = realDateNow
}
})
})
describe("#given elapsed time >= MIN_IDLE_TIME_MS", () => {
it("#when session has valid output and no incomplete todos #then should complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
})
it("#when session has no valid output #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(false),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task has incomplete todos #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(true),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during validation #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: async () => {
task.status = "completed"
return true
},
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during todo check #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: async () => {
task.status = "cancelled"
return false
},
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,89 +0,0 @@
import type { OpencodeClient } from "./constants"
import { log } from "../../shared"
type SessionMessagePart = {
type?: string
text?: string
content?: unknown
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getMessageRole(message: unknown): string | undefined {
if (!isObject(message)) return undefined
const info = message["info"]
if (!isObject(info)) return undefined
const role = info["role"]
return typeof role === "string" ? role : undefined
}
function getMessageParts(message: unknown): SessionMessagePart[] {
if (!isObject(message)) return []
const parts = message["parts"]
if (!Array.isArray(parts)) return []
return parts
.filter((part): part is SessionMessagePart => isObject(part))
.map((part) => ({
type: typeof part["type"] === "string" ? part["type"] : undefined,
text: typeof part["text"] === "string" ? part["text"] : undefined,
content: part["content"],
}))
}
function partHasContent(part: SessionMessagePart): boolean {
if (part.type === "text" || part.type === "reasoning") {
return Boolean(part.text && part.text.trim().length > 0)
}
if (part.type === "tool") return true
if (part.type === "tool_result") {
if (typeof part.content === "string") return part.content.trim().length > 0
if (Array.isArray(part.content)) return part.content.length > 0
return Boolean(part.content)
}
return false
}
export async function validateSessionHasOutput(
client: OpencodeClient,
sessionID: string
): Promise<boolean> {
try {
const response = await client.session.messages({
path: { id: sessionID },
})
const messagesRaw =
isObject(response) && "data" in response ? (response as { data?: unknown }).data : response
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
const hasAssistantOrToolMessage = messages.some((message) => {
const role = getMessageRole(message)
return role === "assistant" || role === "tool"
})
if (!hasAssistantOrToolMessage) {
log("[background-agent] No assistant/tool messages found in session:", sessionID)
return false
}
const hasContent = messages.some((message) => {
const role = getMessageRole(message)
if (role !== "assistant" && role !== "tool") return false
const parts = getMessageParts(message)
return parts.some(partHasContent)
})
if (!hasContent) {
log("[background-agent] Messages exist but no content found in session:", sessionID)
return false
}
return true
} catch (error) {
log("[background-agent] Error validating session output:", error)
return true
}
}

View File

@@ -1,46 +0,0 @@
import { subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask } from "./types"
export function cleanupTaskAfterSessionEnds(args: {
task: BackgroundTask
tasks: Map<string, BackgroundTask>
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
completionTimers: Map<string, ReturnType<typeof setTimeout>>
cleanupPendingByParent: (task: BackgroundTask) => void
clearNotificationsForTask: (taskId: string) => void
releaseConcurrencyKey?: (key: string) => void
}): void {
const {
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
} = args
const completionTimer = completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
completionTimers.delete(task.id)
}
const idleTimer = idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
idleDeferralTimers.delete(task.id)
}
if (task.concurrencyKey && releaseConcurrencyKey) {
releaseConcurrencyKey(task.concurrencyKey)
task.concurrencyKey = undefined
}
cleanupPendingByParent(task)
clearNotificationsForTask(task.id)
tasks.delete(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}

View File

@@ -1,33 +0,0 @@
import type { OpencodeClient, Todo } from "./constants"
function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) return false
const todo = value as Record<string, unknown>
return (
(typeof todo["id"] === "string" || todo["id"] === undefined) &&
typeof todo["content"] === "string" &&
typeof todo["status"] === "string" &&
typeof todo["priority"] === "string"
)
}
export async function checkSessionTodos(
client: OpencodeClient,
sessionID: string
): Promise<boolean> {
try {
const response = await client.session.todo({
path: { id: sessionID },
})
const todosRaw = "data" in response ? response.data : response
if (!Array.isArray(todosRaw) || todosRaw.length === 0) return false
const incomplete = todosRaw
.filter(isTodo)
.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
return incomplete.length > 0
} catch {
return false
}
}

View File

@@ -61,9 +61,7 @@ export async function startTask(
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
} as Record<string, unknown>,
query: {
directory: parentDirectory,
},

View File

@@ -1,45 +0,0 @@
import type { OpencodeClient } from "../constants"
import type { ConcurrencyManager } from "../concurrency"
import type { LaunchInput } from "../types"
import { log } from "../../../shared"
export async function createBackgroundSession(options: {
client: OpencodeClient
input: LaunchInput
parentDirectory: string
concurrencyManager: ConcurrencyManager
concurrencyKey: string
}): Promise<string> {
const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options
const body = {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
}
const createResult = await client.session
.create({
body,
query: {
directory: parentDirectory,
},
})
.catch((error: unknown) => {
concurrencyManager.release(concurrencyKey)
throw error
})
if (createResult.error) {
concurrencyManager.release(concurrencyKey)
throw new Error(`Failed to create background session: ${createResult.error}`)
}
if (!createResult.data?.id) {
concurrencyManager.release(concurrencyKey)
throw new Error("Failed to create background session: API returned no session ID")
}
const sessionID = createResult.data.id
log("[background-agent] Background session created", { sessionID })
return sessionID
}

View File

@@ -1,7 +0,0 @@
import type { LaunchInput } from "../types"
export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string {
return input.model
? `${input.model.providerID}/${input.model.modelID}`
: input.agent
}

View File

@@ -1,12 +0,0 @@
import type { BackgroundTask } from "../types"
import type { ConcurrencyManager } from "../concurrency"
import type { OpencodeClient, OnSubagentSessionCreated } from "../constants"
export interface SpawnerContext {
client: OpencodeClient
directory: string
concurrencyManager: ConcurrencyManager
tmuxEnabled: boolean
onSubagentSessionCreated?: OnSubagentSessionCreated
onTaskError: (task: BackgroundTask, error: Error) => void
}

View File

@@ -1,40 +0,0 @@
import { setTimeout } from "timers/promises"
import type { OnSubagentSessionCreated } from "../constants"
import { TMUX_CALLBACK_DELAY_MS } from "../constants"
import { log } from "../../../shared"
import { isInsideTmux } from "../../../shared/tmux"
export async function maybeInvokeTmuxCallback(options: {
onSubagentSessionCreated?: OnSubagentSessionCreated
tmuxEnabled: boolean
sessionID: string
parentID: string
title: string
}): Promise<void> {
const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options
log("[background-agent] tmux callback check", {
hasCallback: !!onSubagentSessionCreated,
tmuxEnabled,
isInsideTmux: isInsideTmux(),
sessionID,
parentID,
})
if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) {
log("[background-agent] SKIP tmux callback - conditions not met")
return
}
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await onSubagentSessionCreated({
sessionID,
parentID,
title,
}).catch((error: unknown) => {
log("[background-agent] Failed to spawn tmux pane:", error)
})
log("[background-agent] tmux callback completed, waiting")
await setTimeout(TMUX_CALLBACK_DELAY_MS)
}

View File

@@ -1,3 +0,0 @@
export * from "./types"
export * from "./storage"
export * from "./session-storage"

View File

@@ -1,3 +0,0 @@
export * from "./schema"
export * from "./oauth-authorization-flow"
export * from "./provider"

View File

@@ -4,6 +4,12 @@ import type { ConcurrencyManager } from "../background-agent/concurrency"
type OpencodeClient = PluginInput["client"]
type ClientWithTui = {
tui?: {
showToast: (opts: { body: { title: string; message: string; variant: string; duration: number } }) => Promise<unknown>
}
}
export class TaskToastManager {
private tasks: Map<string, TrackedTask> = new Map()
private client: OpencodeClient
@@ -170,8 +176,7 @@ export class TaskToastManager {
* Show consolidated toast with all running/queued tasks
*/
private showTaskListToast(newTask: TrackedTask): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
const tuiClient = this.client as ClientWithTui
if (!tuiClient.tui?.showToast) return
const message = this.buildTaskListMessage(newTask)
@@ -196,8 +201,7 @@ export class TaskToastManager {
* Show task completion toast
*/
showCompletionToast(task: { id: string; description: string; duration: string }): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
const tuiClient = this.client as ClientWithTui
if (!tuiClient.tui?.showToast) return
this.removeTask(task.id)

View File

@@ -1,4 +1,5 @@
import type { AutoCompactState } from "./types";
import type { OhMyOpenCodeConfig } from "../../config";
import type { ExperimentalConfig } from "../../config";
import { TRUNCATE_CONFIG } from "./types";
@@ -15,15 +16,15 @@ export async function executeCompact(
sessionID: string,
msg: Record<string, unknown>,
autoCompactState: AutoCompactState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
client: Client,
directory: string,
experimental?: ExperimentalConfig,
pluginConfig: OhMyOpenCodeConfig,
_experimental?: ExperimentalConfig
): Promise<void> {
void experimental
void _experimental
if (autoCompactState.compactionInProgress.has(sessionID)) {
await (client as Client).tui
await client.tui
.showToast({
body: {
title: "Compact In Progress",
@@ -55,7 +56,7 @@ export async function executeCompact(
const result = await runAggressiveTruncationStrategy({
sessionID,
autoCompactState,
client: client as Client,
client: client,
directory,
truncateAttempt: truncateState.truncateAttempt,
currentTokens: errorData.currentTokens,
@@ -70,8 +71,9 @@ export async function executeCompact(
sessionID,
msg,
autoCompactState,
client: client as Client,
client: client,
directory,
pluginConfig,
errorType: errorData?.errorType,
messageIndex: errorData?.messageIndex,
})

View File

@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { Client } from "./client"
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig } from "../../config"
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
@@ -8,6 +9,7 @@ import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
experimental?: ExperimentalConfig
pluginConfig: OhMyOpenCodeConfig
}
function createRecoveryState(): AutoCompactState {
@@ -28,6 +30,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
) {
const autoCompactState = createRecoveryState()
const experimental = options?.experimental
const pluginConfig = options?.pluginConfig!
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -89,8 +92,9 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client,
ctx.client as Client,
ctx.directory,
pluginConfig,
experimental,
)
}, 300)
@@ -156,8 +160,9 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client,
ctx.client as Client,
ctx.directory,
pluginConfig,
experimental,
)
}

View File

@@ -1,16 +1,19 @@
import type { AutoCompactState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
import { RETRY_CONFIG } from "./types"
import type { Client } from "./client"
import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state"
import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
import { fixEmptyMessages } from "./empty-content-recovery"
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
export async function runSummarizeRetryStrategy(params: {
sessionID: string
msg: Record<string, unknown>
autoCompactState: AutoCompactState
client: Client
directory: string
pluginConfig: OhMyOpenCodeConfig
errorType?: string
messageIndex?: number
}): Promise<void> {
@@ -74,7 +77,14 @@ export async function runSummarizeRetryStrategy(params: {
})
.catch(() => {})
const summarizeBody = { providerID, modelID, auto: true }
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
params.pluginConfig,
params.sessionID,
providerID,
modelID
)
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
await params.client.session.summarize({
path: { id: params.sessionID },
body: summarizeBody as never,

View File

@@ -104,6 +104,65 @@ ALL three must be YES. "Probably" = NO. "I think so" = NO. Investigate until CER
**DO NOT proceed to the next task until all 4 phases are complete and the gate passes.**`
export const VERIFICATION_REMINDER_GEMINI = `**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
The subagent CLAIMS this task is done. Based on thousands of executions, subagent claims are FALSE more often than true.
They ROUTINELY:
- Ship code with syntax errors they didn't bother to check
- Create stub implementations with TODOs and call it "done"
- Write tests that pass trivially (testing nothing meaningful)
- Implement logic that does NOT match what was requested
- Add features nobody asked for and call it "improvement"
- Report "all tests pass" when they didn't run any tests
**This is NOT a theoretical warning. This WILL happen on this task. Assume the work is BROKEN.**
**YOU MUST VERIFY WITH ACTUAL TOOL CALLS. NOT REASONING. TOOL CALLS.**
Thinking "it looks correct" is NOT verification. Running \`lsp_diagnostics\` IS.
---
**PHASE 1: READ THE CODE FIRST (DO NOT SKIP — DO NOT RUN TESTS YET)**
Read the code FIRST so you know what you're testing.
1. \`Bash("git diff --stat")\` — see exactly which files changed.
2. \`Read\` EVERY changed file — no exceptions, no skimming.
3. For EACH file:
- Does this code ACTUALLY do what the task required? RE-READ the task spec.
- Any stubs, TODOs, placeholders? \`Grep\` for TODO, FIXME, HACK, xxx
- Anti-patterns? \`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch
- Scope creep? Did the subagent add things NOT in the task spec?
4. Cross-check EVERY claim against actual code.
**If you cannot explain what every changed line does, GO BACK AND READ AGAIN.**
**PHASE 2: RUN AUTOMATED CHECKS**
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors. ACTUALLY RUN THIS.
2. Run tests for changed modules, then full suite. ACTUALLY RUN THESE.
3. Build/typecheck — exit 0.
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. Fix the code.
**PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)**
- **Frontend/UI**: \`/playwright\`
- **TUI/CLI**: \`interactive_bash\`
- **API/Backend**: \`Bash\` with curl
**If user-facing and you did not run it, you are shipping UNTESTED BROKEN work.**
**PHASE 4: GATE DECISION**
1. Can I explain what EVERY changed line does? (If no → Phase 1)
2. Did I SEE it work via tool calls? (If user-facing and no → Phase 3)
3. Am I confident nothing is broken? (If no → broader tests)
ALL three must be YES. "Probably" = NO. "I think so" = NO.
**DO NOT proceed to the next task until all 4 phases are complete.**`
export const ORCHESTRATOR_DELEGATION_REQUIRED = `
---

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, it, expect, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
const mockShowConfigErrorsIfAny = mock(async () => {})
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
@@ -7,7 +7,7 @@ const mockShowLocalDevToast = mock(async () => {})
const mockShowVersionToast = mock(async () => {})
const mockRunBackgroundUpdateCheck = mock(async () => {})
const mockGetCachedVersion = mock(() => "3.6.0")
const mockGetLocalDevVersion = mock(() => "3.6.0")
const mockGetLocalDevVersion = mock<(directory: string) => string | null>(() => null)
mock.module("./hook/config-errors-toast", () => ({
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
@@ -40,31 +40,49 @@ mock.module("../../shared/logger", () => ({
log: () => {},
}))
const { createAutoUpdateCheckerHook } = await import("./hook")
type HookFactory = typeof import("./hook").createAutoUpdateCheckerHook
async function importFreshHookFactory(): Promise<HookFactory> {
const hookModule = await import(`./hook?test-${Date.now()}-${Math.random()}`)
return hookModule.createAutoUpdateCheckerHook
}
function createPluginInput() {
return {
directory: "/test",
client: {} as never,
} as never
}
beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
mockShowLocalDevToast.mockClear()
mockShowVersionToast.mockClear()
mockRunBackgroundUpdateCheck.mockClear()
mockGetCachedVersion.mockClear()
mockGetLocalDevVersion.mockClear()
mockGetCachedVersion.mockReturnValue("3.6.0")
mockGetLocalDevVersion.mockReturnValue(null)
})
afterEach(() => {
delete process.env.OPENCODE_CLI_RUN_MODE
mock.restore()
})
describe("createAutoUpdateCheckerHook", () => {
it("skips startup toasts and checks in CLI run mode", async () => {
//#given - CLI run mode enabled
process.env.OPENCODE_CLI_RUN_MODE = "true"
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
mockShowLocalDevToast.mockClear()
mockShowVersionToast.mockClear()
mockRunBackgroundUpdateCheck.mockClear()
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(
{
directory: "/test",
client: {} as never,
} as never,
{ showStartupToast: true, isSisyphusEnabled: true, autoUpdate: true }
)
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
showStartupToast: true,
isSisyphusEnabled: true,
autoUpdate: true,
})
//#when - session.created event arrives
hook.event({
@@ -73,7 +91,7 @@ describe("createAutoUpdateCheckerHook", () => {
properties: { info: { parentID: undefined } },
},
})
await new Promise((resolve) => setTimeout(resolve, 25))
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - no update checker side effects run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -82,6 +100,144 @@ describe("createAutoUpdateCheckerHook", () => {
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("runs all startup checks on normal session.created", async () => {
//#given - normal mode and no local dev version
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives on primary session
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - startup checks, toast, and background check run
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
it("ignores subagent sessions (parentID present)", async () => {
//#given - a subagent session with parentID
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event contains parentID
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: "parent-123" } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("runs only once (hasChecked guard)", async () => {
//#given - one hook instance in normal mode
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event is fired twice
hook.event({
event: {
type: "session.created",
},
})
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - side effects execute only once
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
it("shows localDevToast when local dev version exists", async () => {
//#given - local dev version is present
mockGetLocalDevVersion.mockReturnValue("3.6.0-dev")
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - local dev toast is shown and background check is skipped
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowLocalDevToast).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("ignores non-session.created events", async () => {
//#given - a hook instance in normal mode
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - a non-session.created event arrives
hook.event({
event: {
type: "session.deleted",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("passes correct toast message with sisyphus enabled", async () => {
//#given - sisyphus mode enabled
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
isSisyphusEnabled: true,
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - startup toast includes sisyphus wording
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledWith(
expect.anything(),
"3.6.0",
expect.stringContaining("Sisyphus")
)
})
})

View File

@@ -1,177 +1,208 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { beforeEach, describe, expect, it, mock } from "bun:test"
// Mock modules before importing
const mockFindPluginEntry = mock(() => null as any)
const mockGetCachedVersion = mock(() => null as string | null)
const mockGetLatestVersion = mock(async () => null as string | null)
const mockUpdatePinnedVersion = mock(() => false)
type PluginEntry = {
entry: string
isPinned: boolean
pinnedVersion: string | null
configPath: string
}
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
function createPluginEntry(overrides?: Partial<PluginEntry>): PluginEntry {
return {
entry: "oh-my-opencode@3.4.0",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
...overrides,
}
}
const mockFindPluginEntry = mock((_directory: string): PluginEntry | null => createPluginEntry())
const mockGetCachedVersion = mock((): string | null => "3.4.0")
const mockGetLatestVersion = mock(async (): Promise<string | null> => "3.5.0")
const mockExtractChannel = mock(() => "latest")
const mockInvalidatePackage = mock(() => {})
const mockRunBunInstall = mock(async () => true)
const mockShowUpdateAvailableToast = mock(async () => {})
const mockShowAutoUpdatedToast = mock(async () => {})
const mockShowUpdateAvailableToast = mock(
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
)
const mockShowAutoUpdatedToast = mock(
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
)
mock.module("../checker", () => ({
findPluginEntry: mockFindPluginEntry,
getCachedVersion: mockGetCachedVersion,
getLatestVersion: mockGetLatestVersion,
updatePinnedVersion: mockUpdatePinnedVersion,
revertPinnedVersion: mock(() => false),
}))
mock.module("../version-channel", () => ({
extractChannel: mockExtractChannel,
}))
mock.module("../cache", () => ({
invalidatePackage: mockInvalidatePackage,
}))
mock.module("../../../cli/config-manager", () => ({
runBunInstall: mockRunBunInstall,
}))
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
mock.module("../../../cli/config-manager", () => ({ runBunInstall: mockRunBunInstall }))
mock.module("./update-toasts", () => ({
showUpdateAvailableToast: mockShowUpdateAvailableToast,
showAutoUpdatedToast: mockShowAutoUpdatedToast,
}))
mock.module("../../../shared/logger", () => ({ log: () => {} }))
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
const { runBackgroundUpdateCheck } = await import("./background-update-check?test")
const modulePath = "./background-update-check?test"
const { runBackgroundUpdateCheck } = await import(modulePath)
describe("runBackgroundUpdateCheck", () => {
const mockCtx = { directory: "/test" } as any
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
const mockCtx = { directory: "/test" } as PluginInput
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
isUpdate ? `Update to ${version}` : "Up to date"
beforeEach(() => {
mockFindPluginEntry.mockReset()
mockGetCachedVersion.mockReset()
mockGetLatestVersion.mockReset()
mockUpdatePinnedVersion.mockReset()
mockExtractChannel.mockReset()
mockInvalidatePackage.mockReset()
mockRunBunInstall.mockReset()
mockShowUpdateAvailableToast.mockReset()
mockShowAutoUpdatedToast.mockReset()
mockFindPluginEntry.mockReturnValue(createPluginEntry())
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
mockExtractChannel.mockReturnValue("latest")
mockRunBunInstall.mockResolvedValue(true)
})
describe("#given user has pinned a specific version", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode@3.4.0",
isPinned: true,
pinnedVersion: "3.4.0",
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should NOT call updatePinnedVersion", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
})
it("#then should show manual-update toast message", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
const [toastContext, latestVersion, getToastMessage] = mockShowUpdateAvailableToast.mock.calls[0] ?? []
expect(toastContext).toBe(mockCtx)
expect(latestVersion).toBe("3.5.0")
expect(typeof getToastMessage).toBe("function")
expect(getToastMessage(true, "3.5.0")).toBe("Update available: 3.5.0 (version pinned, update manually)")
})
it("#then should NOT run bun install", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
describe("#given no plugin entry found", () => {
it("returns early without showing any toast", async () => {
//#given
mockFindPluginEntry.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockFindPluginEntry).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
})
it("#then should NOT invalidate package cache", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockInvalidatePackage).not.toHaveBeenCalled()
})
})
describe("#given user has NOT pinned a version (unpinned)", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should proceed with auto-update", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockInvalidatePackage).toHaveBeenCalled()
expect(mockRunBunInstall).toHaveBeenCalled()
})
it("#then should show auto-updated toast on success", async () => {
mockRunBunInstall.mockResolvedValue(true)
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockShowAutoUpdatedToast).toHaveBeenCalled()
})
})
describe("#given autoUpdate is false", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should only show notification toast", async () => {
await runBackgroundUpdateCheck(mockCtx, false, mockGetToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
})
})
describe("#given already on latest version", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode@3.5.0",
isPinned: true,
pinnedVersion: "3.5.0",
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.5.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should not update or show toast", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
describe("#given no version available", () => {
it("returns early when neither cached nor pinned version exists", async () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
mockGetCachedVersion.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetCachedVersion).toHaveBeenCalledTimes(1)
expect(mockGetLatestVersion).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given latest version fetch fails", () => {
it("returns early without toasts", async () => {
//#given
mockGetLatestVersion.mockResolvedValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledWith("latest")
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given already on latest version", () => {
it("returns early without any action", async () => {
//#given
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.4.0")
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given update available with autoUpdate disabled", () => {
it("shows update notification but does not install", async () => {
//#given
const autoUpdate = false
//#when
await runBackgroundUpdateCheck(mockCtx, autoUpdate, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given user has pinned a specific version", () => {
it("shows pinned-version toast without auto-updating", async () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
it("toast message mentions version pinned", async () => {
//#given
let capturedToastMessage: ToastMessageGetter | undefined
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
mockShowUpdateAvailableToast.mockImplementation(
async (_ctx: PluginInput, _latestVersion: string, toastMessage: ToastMessageGetter) => {
capturedToastMessage = toastMessage
}
)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(capturedToastMessage).toBeDefined()
if (!capturedToastMessage) {
throw new Error("toast message callback missing")
}
const message = capturedToastMessage(true, "3.5.0")
expect(message).toContain("version pinned")
expect(message).not.toBe("Update to 3.5.0")
})
})
describe("#given unpinned with auto-update and install succeeds", () => {
it("invalidates cache, installs, and shows auto-updated toast", async () => {
//#given
mockRunBunInstall.mockResolvedValue(true)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
})
})
describe("#given unpinned with auto-update and install fails", () => {
it("falls back to notification-only toast", async () => {
//#given
mockRunBunInstall.mockResolvedValue(false)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,161 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
const findAgentsMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
const storageMaps = new Map<string, Set<string>>()
mock.module("./constants", () => ({
AGENTS_INJECTOR_STORAGE: "/tmp/directory-agents-injector-tests",
AGENTS_FILENAME: "AGENTS.md",
}))
mock.module("./storage", () => ({
loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set<string>(),
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
storageMaps.set(sessionID, paths)
},
clearInjectedPaths: (sessionID: string) => {
storageMaps.delete(sessionID)
},
}))
const truncator = {
truncate: async (_sessionID: string, content: string) => ({ result: content, truncated: false }),
getUsage: async (_sessionID: string) => null,
truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({
result: output,
truncated: false,
}),
}
describe("processFilePathForAgentsInjection", () => {
let testRoot = ""
let srcDirectory = ""
let componentsDirectory = ""
const rootAgentsContent = "# ROOT AGENTS\nroot-level directives"
const srcAgentsContent = "# SRC AGENTS\nsrc-level directives"
const componentsAgentsContent = "# COMPONENT AGENTS\ncomponents-level directives"
beforeEach(() => {
findAgentsMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
storageMaps.clear()
testRoot = join(
tmpdir(),
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
mkdirSync(testRoot, { recursive: true })
testRoot = join(tmpdir(), `directory-agents-injector-${randomUUID()}`)
srcDirectory = join(testRoot, "src")
componentsDirectory = join(srcDirectory, "components")
mkdirSync(componentsDirectory, { recursive: true })
writeFileSync(join(testRoot, "AGENTS.md"), rootAgentsContent)
writeFileSync(join(srcDirectory, "AGENTS.md"), srcAgentsContent)
writeFileSync(join(componentsDirectory, "AGENTS.md"), componentsAgentsContent)
writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true\n")
writeFileSync(join(srcDirectory, "file.ts"), "export const sourceFile = true\n")
writeFileSync(join(testRoot, "file.ts"), "export const rootFile = true\n")
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
})
it("does not save when all discovered paths are already cached", async () => {
//#given
const sessionID = "session-1"
const repoRoot = join(testRoot, "repo")
const agentsPath = join(repoRoot, "src", "AGENTS.md")
const cachedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(agentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects AGENTS.md content from file's parent directory into output", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
//#when
// when
await processFilePathForAgentsInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
filePath: join(srcDirectory, "file.ts"),
sessionID: "session-parent",
output,
})
//#then
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
// then
expect(output.output).toContain("[Directory Context:")
expect(output.output).toContain(srcAgentsContent)
})
it("saves when a new path is injected", async () => {
//#given
const sessionID = "session-2"
const repoRoot = join(testRoot, "repo")
const agentsPath = join(repoRoot, "src", "AGENTS.md")
const injectedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(agentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set())
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("skips root-level AGENTS.md", async () => {
// given
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
rmSync(join(componentsDirectory, "AGENTS.md"), { force: true })
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
//#when
// when
await processFilePathForAgentsInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
filePath: join(testRoot, "file.ts"),
sessionID: "session-root-skip",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
// then
expect(output.output).not.toContain(rootAgentsContent)
expect(output.output).not.toContain("[Directory Context:")
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
const repoRoot = join(testRoot, "repo")
const cachedAgentsPath = join(repoRoot, "already-cached", "AGENTS.md")
const newAgentsPath = join(repoRoot, "new-dir", "AGENTS.md")
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
writeFileSync(cachedAgentsPath, "# AGENTS")
writeFileSync(newAgentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
findAgentsMdUpMock.mockReturnValueOnce([cachedAgentsPath, newAgentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects multiple AGENTS.md when walking up directory tree", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
//#when
// when
await processFilePathForAgentsInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(repoRoot, "new-dir", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-multiple",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
// then
expect(output.output).toContain(srcAgentsContent)
expect(output.output).toContain(componentsAgentsContent)
})
it("does not re-inject already cached directories", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const sessionCaches = new Map<string, Set<string>>()
const output = { title: "Read result", output: "base output", metadata: {} }
// when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches,
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-cache",
output,
})
const outputAfterFirstCall = output.output
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches,
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-cache",
output,
})
// then
expect(output.output).toBe(outputAfterFirstCall)
expect(output.output.split("[Directory Context:").length - 1).toBe(2)
})
it("shows truncation notice when content is truncated", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
const truncatedTruncator = {
truncate: async (_sessionID: string, _content: string) => ({
result: "truncated...",
truncated: true,
}),
getUsage: async (_sessionID: string) => null,
truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({
result: output,
truncated: false,
}),
}
// when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator: truncatedTruncator,
sessionCaches: new Map(),
filePath: join(srcDirectory, "file.ts"),
sessionID: "session-truncated",
output,
})
// then
expect(output.output).toContain("truncated...")
expect(output.output).toContain("[Note: Content was truncated")
})
it("does nothing when filePath cannot be resolved", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
// when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: "",
sessionID: "session-empty-path",
output,
})
// then
expect(output.output).toBe("base output")
})
})

View File

@@ -1,161 +1,212 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
import type { PluginInput } from "@opencode-ai/plugin"
const storageMaps = new Map<string, Set<string>>()
mock.module("./storage", () => ({
loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set<string>(),
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
storageMaps.set(sessionID, paths)
},
}))
function createPluginContext(directory: string): PluginInput {
return { directory } as PluginInput
}
function countReadmeMarkers(output: string): number {
return output.split("[Project README:").length - 1
}
function createTruncator(input?: { truncated?: boolean; result?: string }) {
return {
truncate: async (_sessionID: string, content: string) => ({
result: input?.result ?? content,
truncated: input?.truncated ?? false,
}),
getUsage: async (_sessionID: string) => null,
truncateSync: (output: string) => ({ result: output, truncated: false }),
}
}
describe("processFilePathForReadmeInjection", () => {
let testRoot = ""
beforeEach(() => {
findReadmeMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
testRoot = join(
tmpdir(),
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
testRoot = join(tmpdir(), `directory-readme-injector-${randomUUID()}`)
mkdirSync(testRoot, { recursive: true })
storageMaps.clear()
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
storageMaps.clear()
})
it("does not save when all discovered paths are already cached", async () => {
//#given
const sessionID = "session-1"
const repoRoot = join(testRoot, "repo")
const readmePath = join(repoRoot, "src", "README.md")
const cachedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(readmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects README.md content from file's parent directory into output", async () => {
// given
const sourceDirectory = join(testRoot, "src")
mkdirSync(sourceDirectory, { recursive: true })
writeFileSync(join(sourceDirectory, "README.md"), "# Source README\nlocal context")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "base", metadata: {} }
const truncator = createTruncator()
//#when
// when
await processFilePathForReadmeInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(sourceDirectory, "file.ts"),
sessionID: "session-parent",
output,
})
//#then
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
// then
expect(output.output).toContain("[Project README:")
expect(output.output).toContain("# Source README")
expect(output.output).toContain("local context")
})
it("saves when a new path is injected", async () => {
//#given
const sessionID = "session-2"
const repoRoot = join(testRoot, "repo")
const readmePath = join(repoRoot, "src", "README.md")
const injectedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(readmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set())
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("includes root-level README.md (unlike agents-injector)", async () => {
// given
writeFileSync(join(testRoot, "README.md"), "# Root README\nroot context")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "", metadata: {} }
const truncator = createTruncator()
//#when
// when
await processFilePathForReadmeInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(testRoot, "file.ts"),
sessionID: "session-root",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
// then
expect(output.output).toContain("[Project README:")
expect(output.output).toContain("# Root README")
expect(output.output).toContain("root context")
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
const repoRoot = join(testRoot, "repo")
const cachedReadmePath = join(repoRoot, "already-cached", "README.md")
const newReadmePath = join(repoRoot, "new-dir", "README.md")
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
writeFileSync(cachedReadmePath, "# README")
writeFileSync(newReadmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
findReadmeMdUpMock.mockReturnValueOnce([cachedReadmePath, newReadmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects multiple README.md when walking up directory tree", async () => {
// given
const sourceDirectory = join(testRoot, "src")
const componentsDirectory = join(sourceDirectory, "components")
mkdirSync(componentsDirectory, { recursive: true })
writeFileSync(join(testRoot, "README.md"), "# Root README")
writeFileSync(join(sourceDirectory, "README.md"), "# Src README")
writeFileSync(join(componentsDirectory, "README.md"), "# Components README")
writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "", metadata: {} }
const truncator = createTruncator()
//#when
// when
await processFilePathForReadmeInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "new-dir", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-multi",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
// then
expect(countReadmeMarkers(output.output)).toBe(3)
expect(output.output).toContain("# Root README")
expect(output.output).toContain("# Src README")
expect(output.output).toContain("# Components README")
})
it("does not re-inject already cached directories", async () => {
// given
const sourceDirectory = join(testRoot, "src")
mkdirSync(sourceDirectory, { recursive: true })
writeFileSync(join(sourceDirectory, "README.md"), "# Source README")
const { processFilePathForReadmeInjection } = await import("./injector")
const sessionCaches = new Map<string, Set<string>>()
const sessionID = "session-cache"
const truncator = createTruncator()
const firstOutput = { title: "Result", output: "", metadata: {} }
const secondOutput = { title: "Result", output: "", metadata: {} }
// when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches,
filePath: join(sourceDirectory, "a.ts"),
sessionID,
output: firstOutput,
})
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches,
filePath: join(sourceDirectory, "b.ts"),
sessionID,
output: secondOutput,
})
// then
expect(countReadmeMarkers(firstOutput.output)).toBe(1)
expect(secondOutput.output).toBe("")
})
it("shows truncation notice when content is truncated", async () => {
// given
const sourceDirectory = join(testRoot, "src")
mkdirSync(sourceDirectory, { recursive: true })
writeFileSync(join(sourceDirectory, "README.md"), "# Truncated README")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "", metadata: {} }
const truncator = createTruncator({ result: "trimmed content", truncated: true })
// when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(sourceDirectory, "file.ts"),
sessionID: "session-truncated",
output,
})
// then
expect(output.output).toContain("trimmed content")
expect(output.output).toContain("[Note: Content was truncated")
})
it("does nothing when filePath cannot be resolved", async () => {
// given
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "unchanged", metadata: {} }
const truncator = createTruncator()
// when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: "",
sessionID: "session-empty-path",
output,
})
// then
expect(output.output).toBe("unchanged")
})
})

View File

@@ -1,306 +0,0 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createHashlineEditDiffEnhancerHook } from "./hook"
function makeInput(tool: string, callID = "call-1", sessionID = "ses-1") {
return { tool, sessionID, callID }
}
function makeBeforeOutput(args: Record<string, unknown>) {
return { args }
}
function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record<string, unknown> }>) {
return {
title: overrides?.title ?? "",
output: overrides?.output ?? "Successfully applied 1 edit(s)",
metadata: overrides?.metadata ?? { truncated: false },
}
}
type FileDiffMetadata = {
file: string
path: string
before: string
after: string
additions: number
deletions: number
}
describe("hashline-edit-diff-enhancer", () => {
let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook>
beforeEach(() => {
hook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: true } })
})
describe("tool.execute.before", () => {
test("captures old file content for write tool", async () => {
const filePath = import.meta.dir + "/index.test.ts"
const input = makeInput("write")
const output = makeBeforeOutput({ path: filePath, edits: [] })
await hook["tool.execute.before"](input, output)
// given the hook ran without error, the old content should be stored internally
// we verify in the after hook test that it produces filediff
})
test("ignores non-write tools", async () => {
const input = makeInput("read")
const output = makeBeforeOutput({ path: "/some/file.ts" })
// when - should not throw
await hook["tool.execute.before"](input, output)
})
})
describe("tool.execute.after", () => {
test("injects filediff metadata after write tool execution", async () => {
// given - a temp file that we can modify between before/after
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-diff-1")
const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] })
// when - before hook captures old content
await hook["tool.execute.before"](input, beforeOutput)
// when - file is modified (simulating write execution)
const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n"
await Bun.write(tmpFile, newContent)
// when - after hook computes filediff
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
// then - metadata should contain filediff
const filediff = afterOutput.metadata.filediff as {
file: string
path: string
before: string
after: string
additions: number
deletions: number
}
expect(filediff).toBeDefined()
expect(filediff.file).toBe(tmpFile)
expect(filediff.path).toBe(tmpFile)
expect(filediff.before).toBe(oldContent)
expect(filediff.after).toBe(newContent)
expect(filediff.additions).toBeGreaterThan(0)
expect(filediff.deletions).toBeGreaterThan(0)
// then - title should be set to the file path
expect(afterOutput.title).toBe(tmpFile)
// cleanup
await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile)
})
test("does nothing for non-write tools", async () => {
const input = makeInput("read", "call-other")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
await hook["tool.execute.after"](input, afterOutput)
// then - metadata unchanged
expect(afterOutput.metadata).toEqual(originalMetadata)
})
test("does nothing when no before capture exists", async () => {
// given - no before hook was called for this callID
const input = makeInput("write", "call-no-before")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
await hook["tool.execute.after"](input, afterOutput)
// then - metadata unchanged (no filediff injected)
expect(afterOutput.metadata.filediff).toBeUndefined()
})
test("cleans up stored content after consumption", async () => {
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts`
await Bun.write(tmpFile, "original")
const input = makeInput("write", "call-cleanup")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
// when - first after call consumes
const afterOutput1 = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput1)
expect(afterOutput1.metadata.filediff).toBeDefined()
// when - second after call finds nothing
const afterOutput2 = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput2)
expect(afterOutput2.metadata.filediff).toBeUndefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
test("handles file creation (empty old content)", async () => {
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts`
// given - file doesn't exist during before hook
const input = makeInput("write", "call-create")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
// when - file created during write
await Bun.write(tmpFile, "new content\n")
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
// then - filediff shows creation (before is empty)
const filediff = afterOutput.metadata.filediff as FileDiffMetadata
expect(filediff).toBeDefined()
expect(filediff.before).toBe("")
expect(filediff.after).toBe("new content\n")
expect(filediff.additions).toBeGreaterThan(0)
expect(filediff.deletions).toBe(0)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("disabled config", () => {
test("does nothing when hashline_edit is disabled", async () => {
const disabledHook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: false } })
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts`
await Bun.write(tmpFile, "content")
const input = makeInput("write", "call-disabled")
await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
const afterOutput = makeAfterOutput()
await disabledHook["tool.execute.after"](input, afterOutput)
// then - no filediff injected
expect(afterOutput.metadata.filediff).toBeUndefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("write tool support", () => {
test("captures filediff for write tool (path arg)", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-write-${Date.now()}.ts`
const oldContent = "line 1\nline 2\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-write-1")
const beforeOutput = makeBeforeOutput({ path: tmpFile })
//#when - before hook captures old content
await hook["tool.execute.before"](input, beforeOutput)
//#when - file is written
const newContent = "line 1\nmodified line 2\nnew line 3\n"
await Bun.write(tmpFile, newContent)
//#when - after hook computes filediff
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - metadata should contain filediff
const filediff = afterOutput.metadata.filediff as { file: string; before: string; after: string; additions: number; deletions: number }
expect(filediff).toBeDefined()
expect(filediff.file).toBe(tmpFile)
expect(filediff.additions).toBeGreaterThan(0)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
test("captures filediff for write tool (filePath arg)", async () => {
//#given
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-write-fp-${Date.now()}.ts`
await Bun.write(tmpFile, "original content\n")
const input = makeInput("write", "call-write-fp")
//#when - before hook uses filePath arg
await hook["tool.execute.before"](input, makeBeforeOutput({ filePath: tmpFile }))
await Bun.write(tmpFile, "new content\n")
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then
const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined
expect(filediff).toBeDefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("raw content in filediff", () => {
test("filediff.before and filediff.after are raw file content", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-format-${Date.now()}.ts`
const oldContent = "const x = 1\nconst y = 2\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-hashline-format")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified and after hook runs
const newContent = "const x = 1\nconst y = 42\n"
await Bun.write(tmpFile, newContent)
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - before and after should be raw file content
const filediff = afterOutput.metadata.filediff as { before: string; after: string }
expect(filediff.before).toBe(oldContent)
expect(filediff.after).toBe(newContent)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("TUI diff support (metadata.diff)", () => {
test("injects unified diff string in metadata.diff for write tool TUI", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-tui-diff")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified
const newContent = "line 1\nmodified line 2\nline 3\n"
await Bun.write(tmpFile, newContent)
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - metadata.diff should be a unified diff string
expect(afterOutput.metadata.diff).toBeDefined()
expect(typeof afterOutput.metadata.diff).toBe("string")
expect(afterOutput.metadata.diff).toContain("---")
expect(afterOutput.metadata.diff).toContain("+++")
expect(afterOutput.metadata.diff).toContain("@@")
expect(afterOutput.metadata.diff).toContain("-line 2")
expect(afterOutput.metadata.diff).toContain("+modified line 2")
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
})

View File

@@ -1 +0,0 @@
export { createHashlineEditDiffEnhancerHook } from "./hook"

View File

@@ -6,9 +6,12 @@ interface HashlineReadEnhancerConfig {
hashline_edit?: { enabled: boolean }
}
const READ_LINE_PATTERN = /^(\d+): ?(.*)$/
const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/
const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/
const CONTENT_OPEN_TAG = "<content>"
const CONTENT_CLOSE_TAG = "</content>"
const FILE_OPEN_TAG = "<file>"
const FILE_CLOSE_TAG = "</file>"
function isReadTool(toolName: string): boolean {
return toolName.toLowerCase() === "read"
@@ -24,18 +27,36 @@ function shouldProcess(config: HashlineReadEnhancerConfig): boolean {
function isTextFile(output: string): boolean {
const firstLine = output.split("\n")[0] ?? ""
return READ_LINE_PATTERN.test(firstLine)
return COLON_READ_LINE_PATTERN.test(firstLine) || PIPE_READ_LINE_PATTERN.test(firstLine)
}
function parseReadLine(line: string): { lineNumber: number; content: string } | null {
const colonMatch = COLON_READ_LINE_PATTERN.exec(line)
if (colonMatch) {
return {
lineNumber: Number.parseInt(colonMatch[1], 10),
content: colonMatch[2],
}
}
const pipeMatch = PIPE_READ_LINE_PATTERN.exec(line)
if (pipeMatch) {
return {
lineNumber: Number.parseInt(pipeMatch[1], 10),
content: pipeMatch[2],
}
}
return null
}
function transformLine(line: string): string {
const match = READ_LINE_PATTERN.exec(line)
if (!match) {
const parsed = parseReadLine(line)
if (!parsed) {
return line
}
const lineNumber = parseInt(match[1], 10)
const content = match[2]
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}:${content}`
const hash = computeLineHash(parsed.lineNumber, parsed.content)
return `${parsed.lineNumber}#${hash}|${parsed.content}`
}
function transformOutput(output: string): string {
@@ -44,25 +65,43 @@ function transformOutput(output: string): string {
}
const lines = output.split("\n")
const contentStart = lines.indexOf(CONTENT_OPEN_TAG)
const contentStart = lines.findIndex(
(line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG)
)
const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG)
const fileStart = lines.findIndex((line) => line === FILE_OPEN_TAG || line.startsWith(FILE_OPEN_TAG))
const fileEnd = lines.indexOf(FILE_CLOSE_TAG)
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart + 1) {
const fileLines = lines.slice(contentStart + 1, contentEnd)
const blockStart = contentStart !== -1 ? contentStart : fileStart
const blockEnd = contentStart !== -1 ? contentEnd : fileEnd
const openTag = contentStart !== -1 ? CONTENT_OPEN_TAG : FILE_OPEN_TAG
if (blockStart !== -1 && blockEnd !== -1 && blockEnd > blockStart) {
const openLine = lines[blockStart] ?? ""
const inlineFirst = openLine.startsWith(openTag) && openLine !== openTag
? openLine.slice(openTag.length)
: null
const fileLines = inlineFirst !== null
? [inlineFirst, ...lines.slice(blockStart + 1, blockEnd)]
: lines.slice(blockStart + 1, blockEnd)
if (!isTextFile(fileLines[0] ?? "")) {
return output
}
const result: string[] = []
for (const line of fileLines) {
if (!READ_LINE_PATTERN.test(line)) {
if (!parseReadLine(line)) {
result.push(...fileLines.slice(result.length))
break
}
result.push(transformLine(line))
}
return [...lines.slice(0, contentStart + 1), ...result, ...lines.slice(contentEnd)].join("\n")
const prefixLines = inlineFirst !== null
? [...lines.slice(0, blockStart), openTag]
: lines.slice(0, blockStart + 1)
return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n")
}
if (!isTextFile(lines[0] ?? "")) {
@@ -71,7 +110,7 @@ function transformOutput(output: string): string {
const result: string[] = []
for (const line of lines) {
if (!READ_LINE_PATTERN.test(line)) {
if (!parseReadLine(line)) {
result.push(...lines.slice(result.length))
break
}
@@ -98,7 +137,7 @@ function extractFilePath(metadata: unknown): string | undefined {
}
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.includes("Updated file (LINE#ID:content):")) {
if (output.output.includes("Updated file (LINE#ID|content):")) {
return
}
@@ -114,7 +153,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
const content = await file.text()
const hashlined = toHashlineContent(content)
output.output = `${output.output}\n\nUpdated file (LINE#ID:content):\n${hashlined}`
output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}`
}
export function createHashlineReadEnhancerHook(

View File

@@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createHashlineReadEnhancerHook } from "./hook"
@@ -45,11 +47,43 @@ describe("hashline-read-enhancer", () => {
//#then
const lines = output.output.split("\n")
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[10]).toBe("1: keep this unchanged")
})
it("hashifies inline <content> format from updated OpenCode read tool", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<path>/tmp/demo.ts</path>",
"<type>file</type>",
"<content>1: const x = 1",
"2: const y = 2",
"",
"(End of file - total 2 lines)",
"</content>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[0]).toBe("<path>/tmp/demo.ts</path>")
expect(lines[1]).toBe("<type>file</type>")
expect(lines[2]).toBe("<content>")
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[6]).toBe("(End of file - total 2 lines)")
expect(lines[7]).toBe("</content>")
})
it("hashifies plain read output without content tags", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@@ -71,12 +105,65 @@ describe("hashline-read-enhancer", () => {
//#then
const lines = output.output.split("\n")
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:# Oh-My-OpenCode Features$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:Hashline test$/)
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|# Oh-My-OpenCode Features$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|Hashline test$/)
expect(lines[4]).toBe("(End of file - total 3 lines)")
})
it("hashifies read output with <file> and zero-padded pipe format", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
"00001| const x = 1",
"00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[5]).toBe("</file>")
})
it("hashifies pipe format even with leading spaces", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
" 00001| const x = 1",
" 00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
})
it("appends LINE#ID output for write tool using metadata filepath", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@@ -94,9 +181,9 @@ describe("hashline-read-enhancer", () => {
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("Updated file (LINE#ID:content):")
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1/)
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2/)
expect(output.output).toContain("Updated file (LINE#ID|content):")
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/)
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/)
fs.rmSync(tempDir, { recursive: true, force: true })
})

View File

@@ -0,0 +1,265 @@
/**
* Gemini-optimized ultrawork message.
*
* Key differences from default (Claude) variant:
* - Mandatory intent gate enforcement before any action
* - Anti-skip mechanism for Phase 0 intent classification
* - Explicit self-check questions to counter Gemini's "eager" behavior
* - Stronger scope constraints (Gemini's creativity causes scope creep)
* - Anti-optimism checkpoints at verification stage
*
* Key differences from GPT variant:
* - GPT naturally follows structured gates; Gemini needs explicit enforcement
* - GPT self-delegates appropriately; Gemini tries to do everything itself
* - GPT respects MUST NOT; Gemini treats constraints as suggestions
*/
export const ULTRAWORK_GEMINI_MESSAGE = `<ultrawork-mode>
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
[CODE RED] Maximum precision required. Ultrathink before acting.
<GEMINI_INTENT_GATE>
## STEP 0: CLASSIFY INTENT — THIS IS NOT OPTIONAL
**Before ANY tool call, exploration, or action, you MUST output:**
\`\`\`
I detect [TYPE] intent — [REASON].
My approach: [ROUTING DECISION].
\`\`\`
Where TYPE is one of: research | implementation | investigation | evaluation | fix | open-ended
**SELF-CHECK (answer each before proceeding):**
1. Did the user EXPLICITLY ask me to build/create/implement something? → If NO, do NOT implement.
2. Did the user say "look into", "check", "investigate", "explain"? → RESEARCH only. Do not code.
3. Did the user ask "what do you think?" → EVALUATE and propose. Do NOT execute.
4. Did the user report an error/bug? → MINIMAL FIX only. Do not refactor.
**YOUR FAILURE MODE: You see a request and immediately start coding. STOP. Classify first.**
| User Says | WRONG Response | CORRECT Response |
| "explain how X works" | Start modifying X | Research → explain → STOP |
| "look into this bug" | Fix it immediately | Investigate → report → WAIT |
| "what about approach X?" | Implement approach X | Evaluate → propose → WAIT |
| "improve the tests" | Rewrite everything | Assess first → propose → implement |
**IF YOU SKIPPED THIS SECTION: Your next tool call is INVALID. Go back and classify.**
</GEMINI_INTENT_GATE>
## **ABSOLUTE CERTAINTY REQUIRED - DO NOT SKIP THIS**
**YOU MUST NOT START ANY IMPLEMENTATION UNTIL YOU ARE 100% CERTAIN.**
| **BEFORE YOU WRITE A SINGLE LINE OF CODE, YOU MUST:** |
|-------------------------------------------------------|
| **FULLY UNDERSTAND** what the user ACTUALLY wants (not what you ASSUME they want) |
| **EXPLORE** the codebase to understand existing patterns, architecture, and context |
| **HAVE A CRYSTAL CLEAR WORK PLAN** - if your plan is vague, YOUR WORK WILL FAIL |
| **RESOLVE ALL AMBIGUITY** - if ANYTHING is unclear, ASK or INVESTIGATE |
### **MANDATORY CERTAINTY PROTOCOL**
**IF YOU ARE NOT 100% CERTAIN:**
1. **THINK DEEPLY** - What is the user's TRUE intent? What problem are they REALLY trying to solve?
2. **EXPLORE THOROUGHLY** - Fire explore/librarian agents to gather ALL relevant context
3. **CONSULT SPECIALISTS** - For hard/complex tasks, DO NOT struggle alone. Delegate:
- **Oracle**: Conventional problems - architecture, debugging, complex logic
- **Artistry**: Non-conventional problems - different approach needed, unusual constraints
4. **ASK THE USER** - If ambiguity remains after exploration, ASK. Don't guess.
**SIGNS YOU ARE NOT READY TO IMPLEMENT:**
- You're making assumptions about requirements
- You're unsure which files to modify
- You don't understand how existing code works
- Your plan has "probably" or "maybe" in it
- You can't explain the exact steps you'll take
**WHEN IN DOUBT:**
\`\`\`
task(subagent_type="explore", load_skills=[], prompt="I'm implementing [TASK DESCRIPTION] and need to understand [SPECIFIC KNOWLEDGE GAP]. Find [X] patterns in the codebase — show file paths, implementation approach, and conventions used. I'll use this to [HOW RESULTS WILL BE USED]. Focus on src/ directories, skip test files unless test patterns are specifically needed. Return concrete file paths with brief descriptions of what each file does.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm working with [LIBRARY/TECHNOLOGY] and need [SPECIFIC INFORMATION]. Find official documentation and production-quality examples for [Y] — specifically: API reference, configuration options, recommended patterns, and common pitfalls. Skip beginner tutorials. I'll use this to [DECISION THIS WILL INFORM].", run_in_background=true)
task(subagent_type="oracle", load_skills=[], prompt="I need architectural review of my approach to [TASK]. Here's my plan: [DESCRIBE PLAN WITH SPECIFIC FILES AND CHANGES]. My concerns are: [LIST SPECIFIC UNCERTAINTIES]. Please evaluate: correctness of approach, potential issues I'm missing, and whether a better alternative exists.", run_in_background=false)
\`\`\`
**ONLY AFTER YOU HAVE:**
- Gathered sufficient context via agents
- Resolved all ambiguities
- Created a precise, step-by-step work plan
- Achieved 100% confidence in your understanding
**...THEN AND ONLY THEN MAY YOU BEGIN IMPLEMENTATION.**
---
## **NO EXCUSES. NO COMPROMISES. DELIVER WHAT WAS ASKED.**
**THE USER'S ORIGINAL REQUEST IS SACRED. YOU MUST FULFILL IT EXACTLY.**
| VIOLATION | CONSEQUENCE |
|-----------|-------------|
| "I couldn't because..." | **UNACCEPTABLE.** Find a way or ask for help. |
| "This is a simplified version..." | **UNACCEPTABLE.** Deliver the FULL implementation. |
| "You can extend this later..." | **UNACCEPTABLE.** Finish it NOW. |
| "Due to limitations..." | **UNACCEPTABLE.** Use agents, tools, whatever it takes. |
| "I made some assumptions..." | **UNACCEPTABLE.** You should have asked FIRST. |
**THERE ARE NO VALID EXCUSES FOR:**
- Delivering partial work
- Changing scope without explicit user approval
- Making unauthorized simplifications
- Stopping before the task is 100% complete
- Compromising on any stated requirement
**IF YOU ENCOUNTER A BLOCKER:**
1. **DO NOT** give up
2. **DO NOT** deliver a compromised version
3. **DO** consult specialists (oracle for conventional, artistry for non-conventional)
4. **DO** ask the user for guidance
5. **DO** explore alternative approaches
**THE USER ASKED FOR X. DELIVER EXACTLY X. PERIOD.**
---
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response to a task MUST contain tool_use blocks. A response without tool calls is a FAILED response.
**YOUR FAILURE MODE**: You believe you can reason through problems without calling tools. You CANNOT.
**RULES (VIOLATION = BROKEN RESPONSE):**
1. **NEVER answer about code without reading files first.** Read them AGAIN.
2. **NEVER claim done without \`lsp_diagnostics\`.** Your confidence is wrong more often than right.
3. **NEVER skip delegation.** Specialists produce better results. USE THEM.
4. **NEVER reason about what a file "probably contains."** READ IT.
5. **NEVER produce ZERO tool calls when action was requested.** Thinking is not doing.
</TOOL_CALL_MANDATE>
YOU MUST LEVERAGE ALL AVAILABLE AGENTS / **CATEGORY + SKILLS** TO THEIR FULLEST POTENTIAL.
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## MANDATORY: PLAN AGENT INVOCATION (NON-NEGOTIABLE)
**YOU MUST ALWAYS INVOKE THE PLAN AGENT FOR ANY NON-TRIVIAL TASK.**
| Condition | Action |
|-----------|--------|
| Task has 2+ steps | MUST call plan agent |
| Task scope unclear | MUST call plan agent |
| Implementation required | MUST call plan agent |
| Architecture decision needed | MUST call plan agent |
\`\`\`
task(subagent_type="plan", load_skills=[], prompt="<gathered context + user request>")
\`\`\`
### SESSION CONTINUITY WITH PLAN AGENT (CRITICAL)
**Plan agent returns a session_id. USE IT for follow-up interactions.**
| Scenario | Action |
|----------|--------|
| Plan agent asks clarifying questions | \`task(session_id="{returned_session_id}", load_skills=[], prompt="<your answer>")\` |
| Need to refine the plan | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Add more detail to Task N")\` |
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
---
## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER
**You have a strong tendency to do work yourself. RESIST THIS.**
**DEFAULT BEHAVIOR: DELEGATE. DO NOT WORK YOURSELF.**
| Task Type | Action | Why |
|-----------|--------|-----|
| Codebase exploration | task(subagent_type="explore", load_skills=[], run_in_background=true) | Parallel, context-efficient |
| Documentation lookup | task(subagent_type="librarian", load_skills=[], run_in_background=true) | Specialized knowledge |
| Planning | task(subagent_type="plan", load_skills=[]) | Parallel task graph + structured TODO list |
| Hard problem (conventional) | task(subagent_type="oracle", load_skills=[]) | Architecture, debugging, complex logic |
| Hard problem (non-conventional) | task(category="artistry", load_skills=[...]) | Different approach needed |
| Implementation | task(category="...", load_skills=[...]) | Domain-optimized models |
**YOU SHOULD ONLY DO IT YOURSELF WHEN:**
- Task is trivially simple (1-2 lines, obvious change)
- You have ALL context already loaded
- Delegation overhead exceeds task complexity
**OTHERWISE: DELEGATE. ALWAYS.**
---
## EXECUTION RULES
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
- **PARALLEL**: Fire independent agent calls simultaneously via task(run_in_background=true) - NEVER wait sequentially.
- **BACKGROUND FIRST**: Use task for exploration/research agents (10+ concurrent if needed).
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
## WORKFLOW
1. **CLASSIFY INTENT** (MANDATORY — see GEMINI_INTENT_GATE above)
2. Spawn exploration/librarian agents via task(run_in_background=true) in PARALLEL
3. Use Plan agent with gathered context to create detailed work breakdown
4. Execute with continuous verification against original requirements
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
**NOTHING is "done" without PROOF it works.**
**YOUR SELF-ASSESSMENT IS UNRELIABLE.** What feels like 95% confidence = ~60% actual correctness.
| Phase | Action | Required Evidence |
|-------|--------|-------------------|
| **Build** | Run build command | Exit code 0, no errors |
| **Test** | Execute test suite | All tests pass (screenshot/output) |
| **Lint** | Run lsp_diagnostics | Zero new errors on changed files |
| **Manual Verify** | Test the actual feature | Describe what you observed |
| **Regression** | Ensure nothing broke | Existing tests still pass |
<ANTI_OPTIMISM_CHECKPOINT>
## BEFORE YOU CLAIM DONE, ANSWER HONESTLY:
1. Did I run \`lsp_diagnostics\` and see ZERO errors? (not "I'm sure there are none")
2. Did I run the tests and see them PASS? (not "they should pass")
3. Did I read the actual output of every command? (not skim)
4. Is EVERY requirement from the request actually implemented? (re-read the request NOW)
5. Did I classify intent at the start? (if not, my entire approach may be wrong)
If ANY answer is no → GO BACK AND DO IT. Do not claim completion.
</ANTI_OPTIMISM_CHECKPOINT>
**WITHOUT evidence = NOT verified = NOT done.**
## ZERO TOLERANCE FAILURES
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
1. CLASSIFY INTENT (MANDATORY)
2. EXPLORES + LIBRARIANS
3. GATHER -> PLAN AGENT SPAWN
4. WORK BY DELEGATING TO ANOTHER AGENTS
NOW.
</ultrawork-mode>
---
`
export function getGeminiUltraworkMessage(): string {
return ULTRAWORK_GEMINI_MESSAGE
}

View File

@@ -4,19 +4,22 @@
* Routing:
* 1. Planner agents (prometheus, plan) → planner.ts
* 2. GPT 5.2 models → gpt5.2.ts
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
* 3. Gemini models → gemini.ts
* 4. Default (Claude, etc.) → default.ts (optimized for Claude series)
*/
export { isPlannerAgent, isGptModel, getUltraworkSource } from "./source-detector"
export { isPlannerAgent, isGptModel, isGeminiModel, getUltraworkSource } from "./source-detector"
export type { UltraworkSource } from "./source-detector"
export { ULTRAWORK_PLANNER_SECTION, getPlannerUltraworkMessage } from "./planner"
export { ULTRAWORK_GPT_MESSAGE, getGptUltraworkMessage } from "./gpt5.2"
export { ULTRAWORK_GEMINI_MESSAGE, getGeminiUltraworkMessage } from "./gemini"
export { ULTRAWORK_DEFAULT_MESSAGE, getDefaultUltraworkMessage } from "./default"
import { getUltraworkSource } from "./source-detector"
import { getPlannerUltraworkMessage } from "./planner"
import { getGptUltraworkMessage } from "./gpt5.2"
import { getDefaultUltraworkMessage } from "./default"
import { getGeminiUltraworkMessage } from "./gemini"
/**
* Gets the appropriate ultrawork message based on agent and model context.
@@ -29,6 +32,8 @@ export function getUltraworkMessage(agentName?: string, modelID?: string): strin
return getPlannerUltraworkMessage()
case "gpt":
return getGptUltraworkMessage()
case "gemini":
return getGeminiUltraworkMessage()
case "default":
default:
return getDefaultUltraworkMessage()

View File

@@ -4,10 +4,11 @@
* Routing logic:
* 1. Planner agents (prometheus, plan) → planner.ts
* 2. GPT 5.2 models → gpt5.2.ts
* 3. Everything else (Claude, etc.) → default.ts
* 3. Gemini models → gemini.ts
* 4. Everything else (Claude, etc.) → default.ts
*/
import { isGptModel } from "../../../agents/types"
import { isGptModel, isGeminiModel } from "../../../agents/types"
/**
* Checks if agent is a planner-type agent.
@@ -22,10 +23,10 @@ export function isPlannerAgent(agentName?: string): boolean {
return /\bplan\b/.test(normalized)
}
export { isGptModel }
export { isGptModel, isGeminiModel }
/** Ultrawork message source type */
export type UltraworkSource = "planner" | "gpt" | "default"
export type UltraworkSource = "planner" | "gpt" | "gemini" | "default"
/**
* Determines which ultrawork message source to use.
@@ -44,6 +45,11 @@ export function getUltraworkSource(
return "gpt"
}
// Priority 3: Gemini models
if (modelID && isGeminiModel(modelID)) {
return "gemini"
}
// Default: Claude and other models
return "default"
}

View File

@@ -269,7 +269,7 @@ describe("preemptive-compaction", () => {
it("should use 1M limit when model cache flag is enabled", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never, {
const hook = createPreemptiveCompactionHook(ctx as never, {}, {
anthropicContext1MEnabled: true,
})
const sessionID = "ses_1m_flag"
@@ -308,7 +308,7 @@ describe("preemptive-compaction", () => {
it("should keep env var fallback when model cache flag is disabled", async () => {
//#given
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
const hook = createPreemptiveCompactionHook(ctx as never, {
const hook = createPreemptiveCompactionHook(ctx as never, {}, {
anthropicContext1MEnabled: false,
})
const sessionID = "ses_env_fallback"

View File

@@ -1,5 +1,7 @@
import { log } from "../shared/logger"
import type { OhMyOpenCodeConfig } from "../config"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
const DEFAULT_ACTUAL_LIMIT = 200_000
type ModelCacheStateLike = {
@@ -51,6 +53,7 @@ type PluginInput = {
export function createPreemptiveCompactionHook(
ctx: PluginInput,
pluginConfig: OhMyOpenCodeConfig,
modelCacheState?: ModelCacheStateLike,
) {
const compactionInProgress = new Set<string>()
@@ -84,9 +87,16 @@ export function createPreemptiveCompactionHook(
compactionInProgress.add(sessionID)
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
modelID
)
await ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: cached.providerID, modelID, auto: true } as never,
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
query: { directory: ctx.directory },
})

View File

@@ -0,0 +1,93 @@
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
const { createSessionNotification } = require("./session-notification")
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
const utils = require("./session-notification-utils")
describe("session-notification input-needed events", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
return { stdout: "", stderr: "", exitCode: 0 }
},
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
}
}
beforeEach(() => {
_resetForTesting()
notificationCalls = []
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
})
afterEach(() => {
subagentSessions.clear()
_resetForTesting()
})
test("sends question notification when question tool asks for input", async () => {
const sessionID = "main-question"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput())
await hook({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: "question",
args: {
questions: [
{
question: "Which branch should we use?",
options: [{ label: "main" }, { label: "dev" }],
},
],
},
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent is asking a question")
})
test("sends permission notification for permission events", async () => {
const sessionID = "main-permission"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput())
await hook({
event: {
type: "permission.asked",
properties: {
sessionID,
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
})
})
export {}

View File

@@ -15,6 +15,8 @@ import { createIdleNotificationScheduler } from "./session-notification-schedule
interface SessionNotificationConfig {
title?: string
message?: string
questionMessage?: string
permissionMessage?: string
playSound?: boolean
soundPath?: string
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
@@ -36,6 +38,8 @@ export function createSessionNotification(
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",
questionMessage: "Agent is asking a question",
permissionMessage: "Agent needs permission to continue",
playSound: false,
soundPath: defaultSoundPath,
idleConfirmationDelay: 1500,
@@ -53,6 +57,56 @@ export function createSessionNotification(
playSound: playSessionNotificationSound,
})
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = properties?.info as Record<string, unknown> | undefined
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return false
return true
}
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
const args = properties?.args as Record<string, unknown> | undefined
const questions = args?.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0] as Record<string, unknown> | undefined
const questionText = firstQuestion?.question
return typeof questionText === "string" ? questionText : ""
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
@@ -68,14 +122,10 @@ export function createSessionNotification(
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
const sessionID = getSessionID(props)
if (!sessionID) return
if (subagentSessions.has(sessionID)) return
// Only trigger notifications for the main session (not subagent sessions)
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return
if (!shouldNotifyForSession(sessionID)) return
scheduler.scheduleIdleNotification(sessionID)
return
@@ -83,17 +133,47 @@ export function createSessionNotification(
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = getSessionID({ ...props, info })
if (sessionID) {
scheduler.markSessionActivity(sessionID)
}
return
}
if (PERMISSION_EVENTS.has(event.type)) {
const sessionID = getSessionID(props)
if (!sessionID) return
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.permissionMessage)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
const sessionID = getSessionID(props)
if (sessionID) {
scheduler.markSessionActivity(sessionID)
if (event.type === "tool.execute.before") {
const toolName = getEventToolName(props)?.toLowerCase()
if (toolName && QUESTION_TOOLS.has(toolName)) {
if (!shouldNotifyForSession(sessionID)) return
const questionText = getQuestionText(props)
const message = PERMISSION_HINT_PATTERN.test(questionText)
? mergedConfig.permissionMessage
: mergedConfig.questionMessage
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}
}
}
}
return
}

View File

@@ -1,87 +0,0 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
import {
findEmptyMessageByIndex,
findEmptyMessages,
findMessagesWithEmptyTextParts,
findMessagesWithThinkingOnly,
injectTextPart,
replaceEmptyTextParts,
} from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { replaceEmptyTextPartsAsync, findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
import { injectTextPartAsync } from "./storage/text-part-injector"
type Client = ReturnType<typeof createOpencodeClient>
const PLACEHOLDER_TEXT = "[user interrupted]"
export async function recoverEmptyContentMessage(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
if (isSqliteBackend()) {
return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, {
placeholderText: PLACEHOLDER_TEXT,
replaceEmptyTextPartsAsync,
injectTextPartAsync,
findMessagesWithEmptyTextPartsFromSDK,
})
}
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false
const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID)
for (const messageID of messagesWithEmptyText) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) {
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
}
}
if (failedID) {
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
for (const messageID of emptyMessageIDs) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
return anySuccess
}

View File

@@ -5,6 +5,12 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType<typeof createOpencodeClient>
type ClientWithPromptAsync = {
session: {
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
}
}
interface ToolUsePart {
type: "tool_use"
@@ -77,8 +83,7 @@ export async function recoverToolResultMissing(
}
try {
// @ts-expect-error - SDK types may not include tool_result parts
await client.session.promptAsync(promptInput)
await (client as unknown as ClientWithPromptAsync).session.promptAsync(promptInput)
return true
} catch {

View File

@@ -0,0 +1,34 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getAgentConfigKey } from "../../shared/agent-display-names"
export function resolveCompactionModel(
pluginConfig: OhMyOpenCodeConfig,
sessionID: string,
originalProviderID: string,
originalModelID: string
): { providerID: string; modelID: string } {
const sessionAgentName = getSessionAgent(sessionID)
if (!sessionAgentName || !pluginConfig.agents) {
return { providerID: originalProviderID, modelID: originalModelID }
}
const agentConfigKey = getAgentConfigKey(sessionAgentName)
const agentConfig = (pluginConfig.agents as Record<string, { compaction?: { model?: string } } | undefined>)[agentConfigKey]
const compactionConfig = agentConfig?.compaction
if (!compactionConfig?.model) {
return { providerID: originalProviderID, modelID: originalModelID }
}
const modelParts = compactionConfig.model.split("/")
if (modelParts.length < 2) {
return { providerID: originalProviderID, modelID: originalModelID }
}
return {
providerID: modelParts[0],
modelID: modelParts.slice(1).join("/"),
}
}

View File

@@ -21,6 +21,18 @@ interface MessageWithParts {
parts: Part[]
}
interface ThinkingPart {
thinking?: string
text?: string
}
interface MessageInfoExtended {
id: string
role: string
sessionID?: string
modelID?: string
}
type MessagesTransformHook = {
"experimental.chat.messages.transform"?: (
input: Record<string, never>,
@@ -91,7 +103,7 @@ function findPreviousThinkingContent(
for (const part of msg.parts) {
const type = part.type as string
if (type === "thinking" || type === "reasoning") {
const thinking = (part as any).thinking || (part as any).text
const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
return thinking
}
@@ -114,7 +126,7 @@ function prependThinkingBlock(message: MessageWithParts, thinkingContent: string
const thinkingPart = {
type: "thinking" as const,
id: `prt_0000000000_synthetic_thinking`,
sessionID: (message.info as any).sessionID || "",
sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "",
messageID: message.info.id,
thinking: thinkingContent,
synthetic: true,
@@ -138,7 +150,7 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
// Get the model info from the last user message
const lastUserMessage = messages.findLast(m => m.info.role === "user")
const modelID = (lastUserMessage?.info as any)?.modelID || ""
const modelID = (lastUserMessage?.info as unknown as MessageInfoExtended)?.modelID || ""
// Only process if extended thinking might be enabled
if (!isExtendedThinkingModel(modelID)) {

View File

@@ -1,7 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
import { log } from "../../shared"
@@ -14,7 +14,7 @@ type GuardArgs = {
const MAX_TRACKED_SESSIONS = 256
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -37,6 +37,8 @@ function isPathInsideDirectory(pathToCheck: string, directory: string): boolean
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
function toCanonicalPath(absolutePath: string): string {
let canonicalPath = absolutePath
@@ -73,7 +75,6 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
const sisyphusRoot = join(canonicalSessionRoot, ".sisyphus") + sep
const touchSession = (sessionID: string): void => {
sessionLastAccess.set(sessionID, Date.now())
@@ -174,16 +175,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot)
if (!isInsideSessionDirectory) {
if (toolName === "read") {
return
}
log("[write-existing-file-guard] Blocking write outside session directory", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
throw new Error(OUTSIDE_SESSION_MESSAGE)
return
}
if (toolName === "read") {
@@ -206,7 +198,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
return
}
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
const isSisyphusPath = canonicalPath.includes("/.sisyphus/")
if (isSisyphusPath) {
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
sessionID: input.sessionID,

View File

@@ -7,7 +7,6 @@ import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
import { createWriteExistingFileGuardHook } from "./index"
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
type Hook = ReturnType<typeof createWriteExistingFileGuardHook>
@@ -339,7 +338,7 @@ describe("createWriteExistingFileGuardHook", () => {
).resolves.toBeDefined()
})
test("#given existing file outside session directory #when write executes #then blocks", async () => {
test("#given existing file outside session directory #when write executes #then allows", async () => {
const outsideDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-outside-"))
try {
@@ -349,9 +348,9 @@ describe("createWriteExistingFileGuardHook", () => {
await expect(
invoke({
tool: "write",
outputArgs: { filePath: outsideFile, content: "attempted overwrite" },
outputArgs: { filePath: outsideFile, content: "allowed overwrite" },
})
).rejects.toThrow(OUTSIDE_SESSION_MESSAGE)
).resolves.toBeDefined()
} finally {
rmSync(outsideDir, { recursive: true, force: true })
}

View File

@@ -1161,8 +1161,6 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
getAgentDisplayName("sisyphus"),
getAgentDisplayName("hephaestus"),
getAgentDisplayName("atlas"),
])
const AGENTS_WITHOUT_TODO_DENY = new Set([
getAgentDisplayName("prometheus"),
getAgentDisplayName("sisyphus-junior"),
])
@@ -1206,10 +1204,6 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
expect(agentResult[agentName]?.permission?.todowrite).toBe("deny")
expect(agentResult[agentName]?.permission?.todoread).toBe("deny")
}
for (const agentName of AGENTS_WITHOUT_TODO_DENY) {
expect(agentResult[agentName]?.permission?.todowrite).toBeUndefined()
expect(agentResult[agentName]?.permission?.todoread).toBeUndefined()
}
})
test("does not deny todowrite/todoread when task_system is disabled", async () => {

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from "bun:test"
import { applyToolConfig } from "./tool-config-handler"
import type { OhMyOpenCodeConfig } from "../config"
function createParams(overrides: {
taskSystem?: boolean
agents?: string[]
}) {
const agentResult: Record<string, { permission?: Record<string, unknown> }> = {}
for (const agent of overrides.agents ?? []) {
agentResult[agent] = { permission: {} }
}
return {
config: { tools: {}, permission: {} } as Record<string, unknown>,
pluginConfig: {
experimental: { task_system: overrides.taskSystem ?? false },
} as OhMyOpenCodeConfig,
agentResult: agentResult as Record<string, unknown>,
}
}
describe("applyToolConfig", () => {
describe("#given task_system is enabled", () => {
describe("#when applying tool config", () => {
it("#then should deny todowrite and todoread globally", () => {
const params = createParams({ taskSystem: true })
applyToolConfig(params)
const tools = params.config.tools as Record<string, unknown>
expect(tools.todowrite).toBe(false)
expect(tools.todoread).toBe(false)
})
it.each([
"atlas",
"sisyphus",
"hephaestus",
"prometheus",
"sisyphus-junior",
])("#then should deny todo tools for %s agent", (agentName) => {
const params = createParams({
taskSystem: true,
agents: [agentName],
})
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.todowrite).toBe("deny")
expect(agent.permission.todoread).toBe("deny")
})
})
})
describe("#given task_system is disabled", () => {
describe("#when applying tool config", () => {
it.each([
"atlas",
"sisyphus",
"hephaestus",
"prometheus",
"sisyphus-junior",
])("#then should NOT deny todo tools for %s agent", (agentName) => {
const params = createParams({
taskSystem: false,
agents: [agentName],
})
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.todowrite).toBeUndefined()
expect(agent.permission.todoread).toBeUndefined()
})
})
})
})

View File

@@ -84,6 +84,7 @@ export function applyToolConfig(params: {
question: questionPermission,
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}
const junior = agentByKey(params.agentResult, "sisyphus-junior");
@@ -93,6 +94,7 @@ export function applyToolConfig(params: {
task: "allow",
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}

View File

@@ -53,7 +53,8 @@ describe("createEventHandler - model fallback", () => {
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
//#given
const sessionID = "ses_message_updated_fallback"
const { handler, abortCalls, promptCalls } = createHandler()
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
//#when
await handler({
@@ -95,7 +96,8 @@ describe("createEventHandler - model fallback", () => {
//#given
const sessionID = "ses_main_fallback_nested"
setMainSession(sessionID)
const { handler, abortCalls, promptCalls } = createHandler()
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
//#when
await handler({
@@ -340,4 +342,64 @@ describe("createEventHandler - model fallback", () => {
expect(promptCalls).toEqual([sessionID, sessionID])
expect(toastCalls.length).toBeGreaterThanOrEqual(0)
})
test("does not trigger model-fallback retry when modelFallback hook is not provided (disabled by default)", async () => {
//#given
const sessionID = "ses_disabled_by_default"
setMainSession(sessionID)
const { handler, abortCalls, promptCalls } = createHandler()
//#when - message.updated with assistant error
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_err_disabled_1",
sessionID,
role: "assistant",
time: { created: 1, completed: 2 },
error: {
name: "APIError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
isRetryable: true,
},
},
parentID: "msg_user_disabled_1",
modelID: "claude-opus-4-6-thinking",
providerID: "anthropic",
agent: "Sisyphus (Ultraworker)",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
},
},
})
//#when - session.error with retryable error
await handler({
event: {
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: {
error: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
},
},
})
//#then - no abort or prompt calls should have been made
expect(abortCalls).toEqual([])
expect(promptCalls).toEqual([])
})
})

View File

@@ -1,53 +1,58 @@
import type { OhMyOpenCodeConfig } from "../config"
import type { PluginContext } from "./types"
import type { OhMyOpenCodeConfig } from "../config";
import type { PluginContext } from "./types";
import {
clearSessionAgent,
getMainSessionID,
getSessionAgent,
setMainSession,
subagentSessions,
syncSubagentSessions,
setMainSession,
updateSessionAgent,
} from "../features/claude-code-session-state"
import { resetMessageCursor } from "../shared"
import { lspManager } from "../tools"
import { shouldRetryError } from "../shared/model-error-classifier"
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
import { log } from "../shared/logger"
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
} from "../features/claude-code-session-state";
import {
clearPendingModelFallback,
clearSessionFallbackChain,
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { resetMessageCursor } from "../shared";
import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier";
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools";
import type { CreatedHooks } from "../create-hooks"
import type { Managers } from "../create-managers"
import { normalizeSessionStatusToIdle } from "./session-status-normalizer"
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"
import type { CreatedHooks } from "../create-hooks";
import type { Managers } from "../create-managers";
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
type FirstMessageVariantGate = {
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void
clear: (sessionID: string) => void
}
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void;
clear: (sessionID: string) => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
return typeof value === "object" && value !== null;
}
function normalizeFallbackModelID(modelID: string): string {
return modelID
.replace(/-thinking$/i, "")
.replace(/-max$/i, "")
.replace(/-high$/i, "")
.replace(/-high$/i, "");
}
function extractErrorName(error: unknown): string | undefined {
if (isRecord(error) && typeof error.name === "string") return error.name
if (error instanceof Error) return error.name
return undefined
if (isRecord(error) && typeof error.name === "string") return error.name;
if (error instanceof Error) return error.name;
return undefined;
}
function extractErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (isRecord(error)) {
const candidates: unknown[] = [
@@ -56,116 +61,115 @@ function extractErrorMessage(error: unknown): string {
error.error,
isRecord(error.data) ? error.data.error : undefined,
error.cause,
]
];
for (const candidate of candidates) {
if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) {
return candidate.message
return candidate.message;
}
}
}
try {
return JSON.stringify(error)
return JSON.stringify(error);
} catch {
return String(error)
return String(error);
}
}
function extractProviderModelFromErrorMessage(
message: string,
): { providerID?: string; modelID?: string } {
const lower = message.toLowerCase()
function extractProviderModelFromErrorMessage(message: string): { providerID?: string; modelID?: string } {
const lower = message.toLowerCase();
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i)
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i);
if (providerModel) {
return {
providerID: providerModel[1],
modelID: providerModel[2],
}
};
}
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i)
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i);
if (modelOnly) {
return {
modelID: modelOnly[1],
}
};
}
return {}
return {};
}
type EventInput = Parameters<
NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>
>[0]
type EventInput = Parameters<NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>>[0];
export function createEventHandler(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
firstMessageVariantGate: FirstMessageVariantGate
managers: Managers
hooks: CreatedHooks
ctx: PluginContext;
pluginConfig: OhMyOpenCodeConfig;
firstMessageVariantGate: FirstMessageVariantGate;
managers: Managers;
hooks: CreatedHooks;
}): (input: EventInput) => Promise<void> {
const { ctx, firstMessageVariantGate, managers, hooks } = args
const { ctx, firstMessageVariantGate, managers, hooks } = args;
const pluginContext = ctx as {
directory: string
directory: string;
client: {
session: {
abort: (input: { path: { id: string } }) => Promise<unknown>
abort: (input: { path: { id: string } }) => Promise<unknown>;
prompt: (input: {
path: { id: string }
body: { parts: Array<{ type: "text"; text: string }> }
query: { directory: string }
}) => Promise<unknown>
}
}
}
path: { id: string };
body: { parts: Array<{ type: "text"; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
};
};
};
const isRuntimeFallbackEnabled =
hooks.runtimeFallback !== null &&
hooks.runtimeFallback !== undefined &&
(typeof args.pluginConfig.runtime_fallback === "boolean"
? args.pluginConfig.runtime_fallback
: (args.pluginConfig.runtime_fallback?.enabled ?? false))
: (args.pluginConfig.runtime_fallback?.enabled ?? false));
const isModelFallbackEnabled =
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
const lastHandledModelErrorMessageID = new Map<string, string>()
const lastHandledRetryStatusKey = new Map<string, string>()
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>()
const lastHandledModelErrorMessageID = new Map<string, string>();
const lastHandledRetryStatusKey = new Map<string, string>();
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
const dispatchToHooks = async (input: EventInput): Promise<void> => {
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input))
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input))
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))
await Promise.resolve(hooks.sessionNotification?.(input))
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input))
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input))
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input))
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input))
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input))
await Promise.resolve(hooks.rulesInjector?.event?.(input))
await Promise.resolve(hooks.thinkMode?.event?.(input))
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))
await Promise.resolve(hooks.runtimeFallback?.event?.(input))
await Promise.resolve(hooks.agentUsageReminder?.event?.(input))
await Promise.resolve(hooks.categorySkillReminder?.event?.(input))
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput))
await Promise.resolve(hooks.ralphLoop?.event?.(input))
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input))
await Promise.resolve(hooks.atlasHook?.handler?.(input))
}
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
await Promise.resolve(hooks.sessionNotification?.(input));
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input));
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input));
await Promise.resolve(hooks.rulesInjector?.event?.(input));
await Promise.resolve(hooks.thinkMode?.event?.(input));
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input));
await Promise.resolve(hooks.runtimeFallback?.event?.(input));
await Promise.resolve(hooks.agentUsageReminder?.event?.(input));
await Promise.resolve(hooks.categorySkillReminder?.event?.(input));
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput));
await Promise.resolve(hooks.ralphLoop?.event?.(input));
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input));
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
};
const recentSyntheticIdles = new Map<string, number>()
const recentRealIdles = new Map<string, number>()
const DEDUP_WINDOW_MS = 500
const recentSyntheticIdles = new Map<string, number>();
const recentRealIdles = new Map<string, number>();
const DEDUP_WINDOW_MS = 500;
const shouldAutoRetrySession = (sessionID: string): boolean => {
if (syncSubagentSessions.has(sessionID)) return true
const mainSessionID = getMainSessionID()
if (mainSessionID) return sessionID === mainSessionID
if (syncSubagentSessions.has(sessionID)) return true;
const mainSessionID = getMainSessionID();
if (mainSessionID) return sessionID === mainSessionID;
// Headless runs (or resumed sessions) may not emit session.created, so mainSessionID can be unset.
// In that case, treat any non-subagent session as the "main" interactive session.
return !subagentSessions.has(sessionID)
}
return !subagentSessions.has(sessionID);
};
return async (input): Promise<void> => {
pruneRecentSyntheticIdles({
@@ -173,230 +177,227 @@ export function createEventHandler(args: {
recentRealIdles,
now: Date.now(),
dedupWindowMs: DEDUP_WINDOW_MS,
})
});
if (input.event.type === "session.idle") {
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as string | undefined
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as
| string
| undefined;
if (sessionID) {
const emittedAt = recentSyntheticIdles.get(sessionID)
const emittedAt = recentSyntheticIdles.get(sessionID);
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentSyntheticIdles.delete(sessionID)
return
recentSyntheticIdles.delete(sessionID);
return;
}
recentRealIdles.set(sessionID, Date.now())
recentRealIdles.set(sessionID, Date.now());
}
}
await dispatchToHooks(input)
await dispatchToHooks(input);
const syntheticIdle = normalizeSessionStatusToIdle(input)
const syntheticIdle = normalizeSessionStatusToIdle(input);
if (syntheticIdle) {
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
const emittedAt = recentRealIdles.get(sessionID)
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string;
const emittedAt = recentRealIdles.get(sessionID);
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentRealIdles.delete(sessionID)
return
recentRealIdles.delete(sessionID);
return;
}
recentSyntheticIdles.set(sessionID, Date.now())
await dispatchToHooks(syntheticIdle as EventInput)
recentSyntheticIdles.set(sessionID, Date.now());
await dispatchToHooks(syntheticIdle as EventInput);
}
const { event } = input
const props = event.properties as Record<string, unknown> | undefined
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.created") {
const sessionInfo = props?.info as
| { id?: string; title?: string; parentID?: string }
| undefined
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
if (!sessionInfo?.parentID) {
setMainSession(sessionInfo?.id)
setMainSession(sessionInfo?.id);
}
firstMessageVariantGate.markSessionCreated(sessionInfo)
firstMessageVariantGate.markSessionCreated(sessionInfo);
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string }
}
info?: { id?: string; parentID?: string; title?: string };
};
},
)
);
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id === getMainSessionID()) {
setMainSession(undefined)
setMainSession(undefined);
}
if (sessionInfo?.id) {
clearSessionAgent(sessionInfo.id)
lastHandledModelErrorMessageID.delete(sessionInfo.id)
lastHandledRetryStatusKey.delete(sessionInfo.id)
lastKnownModelBySession.delete(sessionInfo.id)
clearPendingModelFallback(sessionInfo.id)
clearSessionFallbackChain(sessionInfo.id)
resetMessageCursor(sessionInfo.id)
firstMessageVariantGate.clear(sessionInfo.id)
clearSessionModel(sessionInfo.id)
syncSubagentSessions.delete(sessionInfo.id)
await managers.skillMcpManager.disconnectSession(sessionInfo.id)
await lspManager.cleanupTempDirectoryClients()
clearSessionAgent(sessionInfo.id);
lastHandledModelErrorMessageID.delete(sessionInfo.id);
lastHandledRetryStatusKey.delete(sessionInfo.id);
lastKnownModelBySession.delete(sessionInfo.id);
clearPendingModelFallback(sessionInfo.id);
clearSessionFallbackChain(sessionInfo.id);
resetMessageCursor(sessionInfo.id);
firstMessageVariantGate.clear(sessionInfo.id);
clearSessionModel(sessionInfo.id);
syncSubagentSessions.delete(sessionInfo.id);
deleteSessionTools(sessionInfo.id);
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
await lspManager.cleanupTempDirectoryClients();
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
})
});
}
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const agent = info?.agent as string | undefined
const role = info?.role as string | undefined
const info = props?.info as Record<string, unknown> | undefined;
const sessionID = info?.sessionID as string | undefined;
const agent = info?.agent as string | undefined;
const role = info?.role as string | undefined;
if (sessionID && role === "user") {
if (agent) {
updateSessionAgent(sessionID, agent)
updateSessionAgent(sessionID, agent);
}
const providerID = info?.providerID as string | undefined
const modelID = info?.modelID as string | undefined
const providerID = info?.providerID as string | undefined;
const modelID = info?.modelID as string | undefined;
if (providerID && modelID) {
lastKnownModelBySession.set(sessionID, { providerID, modelID })
setSessionModel(sessionID, { providerID, modelID })
lastKnownModelBySession.set(sessionID, { providerID, modelID });
setSessionModel(sessionID, { providerID, modelID });
}
}
// Model fallback: in practice, API/model failures often surface as assistant message errors.
// session.error events are not guaranteed for all providers, so we also observe message.updated.
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
try {
const assistantMessageID = info?.id as string | undefined
const assistantError = info?.error
const assistantMessageID = info?.id as string | undefined;
const assistantError = info?.error;
if (assistantMessageID && assistantError) {
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
const lastHandled = lastHandledModelErrorMessageID.get(sessionID);
if (lastHandled === assistantMessageID) {
return
return;
}
const errorName = extractErrorName(assistantError)
const errorMessage = extractErrorMessage(assistantError)
const errorInfo = { name: errorName, message: errorMessage }
const errorName = extractErrorName(assistantError);
const errorMessage = extractErrorMessage(assistantError);
const errorInfo = { name: errorName, message: errorMessage };
if (shouldRetryError(errorInfo)) {
// Prefer the agent/model/provider from the assistant message payload.
let agentName = agent ?? getSessionAgent(sessionID)
let agentName = agent ?? getSessionAgent(sessionID);
if (!agentName && sessionID === getMainSessionID()) {
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus"
agentName = "sisyphus";
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus"
agentName = "hephaestus";
} else {
agentName = "sisyphus"
agentName = "sisyphus";
}
}
if (agentName) {
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
const currentModel = normalizeFallbackModelID(rawModel)
const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
const currentModel = normalizeFallbackModelID(rawModel);
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
.catch(() => {});
}
}
}
}
} catch (err) {
log("[event] model-fallback error in message.updated:", { sessionID, error: err })
log("[event] model-fallback error in message.updated:", { sessionID, error: err });
}
}
}
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as
| { type?: string; attempt?: number; message?: string; next?: number }
| undefined
const sessionID = props?.sessionID as string | undefined;
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
if (sessionID && status?.type === "retry") {
if (sessionID && status?.type === "retry" && isModelFallbackEnabled) {
try {
const retryMessage = typeof status.message === "string" ? status.message : ""
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
const retryMessage = typeof status.message === "string" ? status.message : "";
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
return
return;
}
lastHandledRetryStatusKey.set(sessionID, retryKey)
lastHandledRetryStatusKey.set(sessionID, retryKey);
const errorInfo = { name: undefined as string | undefined, message: retryMessage }
const errorInfo = { name: undefined as string | undefined, message: retryMessage };
if (shouldRetryError(errorInfo)) {
let agentName = getSessionAgent(sessionID)
let agentName = getSessionAgent(sessionID);
if (!agentName && sessionID === getMainSessionID()) {
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
agentName = "sisyphus"
agentName = "sisyphus";
} else if (retryMessage.includes("gpt-5")) {
agentName = "hephaestus"
agentName = "hephaestus";
} else {
agentName = "sisyphus"
agentName = "sisyphus";
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(retryMessage)
const lastKnown = lastKnownModelBySession.get(sessionID)
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
const parsed = extractProviderModelFromErrorMessage(retryMessage);
const lastKnown = lastKnownModelBySession.get(sessionID);
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
.catch(() => {});
}
}
}
} catch (err) {
log("[event] model-fallback error in session.status:", { sessionID, error: err })
log("[event] model-fallback error in session.status:", { sessionID, error: err });
}
}
}
if (event.type === "session.error") {
try {
const sessionID = props?.sessionID as string | undefined
const error = props?.error
const sessionID = props?.sessionID as string | undefined;
const error = props?.error;
const errorName = extractErrorName(error)
const errorMessage = extractErrorMessage(error)
const errorInfo = { name: errorName, message: errorMessage }
const errorName = extractErrorName(error);
const errorMessage = extractErrorMessage(error);
const errorInfo = { name: errorName, message: errorMessage };
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
if (hooks.sessionRecovery?.isRecoverableError(error)) {
@@ -405,8 +406,8 @@ export function createEventHandler(args: {
role: "assistant" as const,
sessionID,
error,
}
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
};
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo);
if (
recovered &&
@@ -420,53 +421,52 @@ export function createEventHandler(args: {
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
.catch(() => {});
}
}
}
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
let agentName = getSessionAgent(sessionID)
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
let agentName = getSessionAgent(sessionID);
if (!agentName && sessionID === getMainSessionID()) {
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus"
agentName = "sisyphus";
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus"
agentName = "hephaestus";
} else {
agentName = "sisyphus"
agentName = "sisyphus";
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(errorMessage)
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(errorMessage);
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
}
}
}
} catch (err) {
const sessionID = props?.sessionID as string | undefined
log("[event] model-fallback error in session.error:", { sessionID, error: err })
const sessionID = props?.sessionID as string | undefined;
log("[event] model-fallback error in session.error:", { sessionID, error: err });
}
}
}
};
}

View File

@@ -82,7 +82,7 @@ export function createSessionHooks(args: {
isHookEnabled("preemptive-compaction") &&
pluginConfig.experimental?.preemptive_compaction
? safeHook("preemptive-compaction", () =>
createPreemptiveCompactionHook(ctx, modelCacheState))
createPreemptiveCompactionHook(ctx, pluginConfig, modelCacheState))
: null
const sessionRecovery = isHookEnabled("session-recovery")
@@ -151,9 +151,10 @@ export function createSessionHooks(args: {
}
}
// Model fallback hook (configurable via disabled_hooks)
// Model fallback hook (configurable via model_fallback config + disabled_hooks)
// This handles automatic model switching when model errors occur
const modelFallback = isHookEnabled("model-fallback")
const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false
const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback")
? safeHook("model-fallback", () =>
createModelFallbackHook({
toast: async ({ title, message, variant, duration }) => {
@@ -174,7 +175,7 @@ export function createSessionHooks(args: {
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
? safeHook("anthropic-context-window-limit-recovery", () =>
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental }))
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental, pluginConfig }))
: null
const autoUpdateChecker = isHookEnabled("auto-update-checker")

View File

@@ -0,0 +1,33 @@
const { describe, expect, test, spyOn } = require("bun:test")
const sessionState = require("../features/claude-code-session-state")
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
describe("createToolExecuteBeforeHandler session notification sessionID", () => {
test("uses main session fallback when input sessionID is empty", async () => {
const mainSessionID = "ses_main"
const getMainSessionIDSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(mainSessionID)
let capturedSessionID: string | undefined
const hooks = {
sessionNotification: async (input) => {
capturedSessionID = input.event.properties?.sessionID
},
}
const handler = createToolExecuteBeforeHandler({
ctx: { client: { session: { messages: async () => ({ data: [] }) } } },
hooks,
})
await handler(
{ tool: "question", sessionID: "", callID: "call_q" },
{ args: { questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] } },
)
expect(getMainSessionIDSpy).toHaveBeenCalled()
expect(capturedSessionID).toBe(mainSessionID)
})
})
export {}

View File

@@ -31,6 +31,60 @@ describe("createToolExecuteBeforeHandler", () => {
await expect(run).resolves.toBeUndefined()
})
test("triggers session notification hook for question tools", async () => {
let called = false
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
},
},
}
const hooks = {
sessionNotification: async (input: { event: { type: string; properties?: Record<string, unknown> } }) => {
called = true
expect(input.event.type).toBe("tool.execute.before")
expect(input.event.properties?.sessionID).toBe("ses_q")
expect(input.event.properties?.tool).toBe("question")
},
}
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
const input = { tool: "question", sessionID: "ses_q", callID: "call_q" }
const output = { args: { questions: [{ question: "Proceed?", options: [{ label: "Yes" }] }] } as Record<string, unknown> }
await handler(input, output)
expect(called).toBe(true)
})
test("does not trigger session notification hook for non-question tools", async () => {
let called = false
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
},
},
}
const hooks = {
sessionNotification: async () => {
called = true
},
}
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
await handler(
{ tool: "bash", sessionID: "ses_b", callID: "call_b" },
{ args: { command: "pwd" } as Record<string, unknown> },
)
expect(called).toBe(false)
})
describe("task tool subagent_type normalization", () => {
const emptyHooks = {}

View File

@@ -30,6 +30,26 @@ export function createToolExecuteBeforeHandler(args: {
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
const normalizedToolName = input.tool.toLowerCase()
if (
normalizedToolName === "question"
|| normalizedToolName === "ask_user_question"
|| normalizedToolName === "askuserquestion"
) {
const sessionID = input.sessionID || getMainSessionID()
await hooks.sessionNotification?.({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: input.tool,
args: output.args,
},
},
})
}
if (input.tool === "task") {
const argsObject = output.args
const category = typeof argsObject.category === "string" ? argsObject.category : undefined

View File

@@ -1,78 +1,129 @@
import { spawn } from "node:child_process"
import { getHomeDirectory } from "./home-directory"
import { findBashPath, findZshPath } from "./shell-path"
import { spawn } from "node:child_process";
import { getHomeDirectory } from "./home-directory";
import { findBashPath, findZshPath } from "./shell-path";
export interface CommandResult {
exitCode: number
stdout?: string
stderr?: string
exitCode: number;
stdout?: string;
stderr?: string;
}
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
const SIGKILL_GRACE_MS = 5_000;
export interface ExecuteHookOptions {
forceZsh?: boolean
zshPath?: string
forceZsh?: boolean;
zshPath?: string;
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */
timeoutMs?: number;
}
export async function executeHookCommand(
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions,
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions,
): Promise<CommandResult> {
const home = getHomeDirectory()
const home = getHomeDirectory();
const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
const expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
const expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd);
let finalCommand = expandedCommand
let finalCommand = expandedCommand;
if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath)
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`
} else {
const bashPath = findBashPath()
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`
}
}
}
if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath);
const escapedCommand = expandedCommand.replace(/'/g, "'\\''");
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`;
} else {
const bashPath = findBashPath();
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`;
}
}
}
return new Promise((resolve) => {
const proc = spawn(finalCommand, {
cwd,
shell: true,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
})
return new Promise(resolve => {
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | null = null;
let stdout = ""
let stderr = ""
const isWin32 = process.platform === "win32";
const proc = spawn(finalCommand, {
cwd,
shell: true,
detached: !isWin32,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
});
proc.stdout?.on("data", (data) => {
stdout += data.toString()
})
let stdout = "";
let stderr = "";
proc.stderr?.on("data", (data) => {
stderr += data.toString()
})
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stdin?.write(stdin)
proc.stdin?.end()
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
proc.on("close", (code) => {
resolve({
exitCode: code ?? 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
})
})
proc.stdin?.on("error", () => {});
proc.stdin?.write(stdin);
proc.stdin?.end();
proc.on("error", (err) => {
resolve({ exitCode: 1, stderr: err.message })
})
})
const settle = (result: CommandResult) => {
if (settled) return;
settled = true;
if (killTimer) clearTimeout(killTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
resolve(result);
};
proc.on("close", code => {
settle({
exitCode: code ?? 1,
stdout: stdout.trim(),
stderr: stderr.trim(),
});
});
proc.on("error", err => {
settle({ exitCode: 1, stderr: err.message });
});
const killProcessGroup = (signal: NodeJS.Signals) => {
try {
if (!isWin32 && proc.pid) {
try {
process.kill(-proc.pid, signal);
} catch {
proc.kill(signal);
}
} else {
proc.kill(signal);
}
} catch {}
};
const timeoutTimer = setTimeout(() => {
if (settled) return;
// Kill entire process group to avoid orphaned children
killProcessGroup("SIGTERM");
killTimer = setTimeout(() => {
if (settled) return;
killProcessGroup("SIGKILL");
}, SIGKILL_GRACE_MS);
// Append timeout notice to stderr
stderr += `\nHook command timed out after ${timeoutMs}ms`;
}, timeoutMs);
// Don't let the timeout timer keep the process alive
if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) {
timeoutTimer.unref();
}
});
}

View File

@@ -199,7 +199,7 @@ export async function fetchAvailableModels(
// Handle both string[] (legacy) and object[] (with metadata) formats
const modelId = typeof modelItem === 'string'
? modelItem
: (modelItem as any)?.id
: modelItem?.id
if (modelId) {
modelSet.add(`${providerId}/${modelId}`)

View File

@@ -1,52 +0,0 @@
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { getOpenCodeCacheDir } from "./data-path"
import { log } from "./logger"
import { isRecord } from "./record-type-guard"
export function addModelsFromModelsJsonCache(
connectedProviders: Set<string>,
modelSet: Set<string>,
): boolean {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
log("[fetchAvailableModels] models.json cache file not found, falling back to client")
return false
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data: unknown = JSON.parse(content)
if (!isRecord(data)) {
return false
}
const providerIds = Object.keys(data)
log("[fetchAvailableModels] providers found in models.json", {
count: providerIds.length,
providers: providerIds.slice(0, 10),
})
const previousSize = modelSet.size
for (const providerId of providerIds) {
if (!connectedProviders.has(providerId)) continue
const providerValue = data[providerId]
if (!isRecord(providerValue)) continue
const modelsValue = providerValue["models"]
if (!isRecord(modelsValue)) continue
for (const modelKey of Object.keys(modelsValue)) {
modelSet.add(`${providerId}/${modelKey}`)
}
}
log("[fetchAvailableModels] parsed models from models.json (NO whitelist filtering)", {
count: modelSet.size,
connectedProviders: Array.from(connectedProviders).slice(0, 5),
})
return modelSet.size > previousSize
} catch (err) {
log("[fetchAvailableModels] error", { error: String(err) })
return false
}
}

View File

@@ -1,20 +0,0 @@
import type { ModelListFunction, ProviderListFunction } from "./open-code-client-shapes"
import { isRecord } from "./record-type-guard"
export function getProviderListFunction(client: unknown): ProviderListFunction | null {
if (!isRecord(client)) return null
const provider = client["provider"]
if (!isRecord(provider)) return null
const list = provider["list"]
if (typeof list !== "function") return null
return list as ProviderListFunction
}
export function getModelListFunction(client: unknown): ModelListFunction | null {
if (!isRecord(client)) return null
const model = client["model"]
if (!isRecord(model)) return null
const list = model["list"]
if (typeof list !== "function") return null
return list as ModelListFunction
}

View File

@@ -1,7 +0,0 @@
export type ProviderListResponse = { data?: { connected?: string[] } }
export type ModelListResponse = {
data?: Array<{ id?: string; provider?: string }>
}
export type ProviderListFunction = () => Promise<ProviderListResponse>
export type ModelListFunction = () => Promise<ModelListResponse>

View File

@@ -1,39 +0,0 @@
import { readProviderModelsCache } from "./connected-providers-cache"
import { log } from "./logger"
export function addModelsFromProviderModelsCache(
connectedProviders: Set<string>,
modelSet: Set<string>,
): boolean {
const providerModelsCache = readProviderModelsCache()
if (!providerModelsCache) {
return false
}
const providerCount = Object.keys(providerModelsCache.models).length
if (providerCount === 0) {
log("[fetchAvailableModels] provider-models cache empty, falling back to models.json")
return false
}
log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)")
const previousSize = modelSet.size
for (const [providerId, modelIds] of Object.entries(providerModelsCache.models)) {
if (!connectedProviders.has(providerId)) continue
for (const modelItem of modelIds) {
if (!modelItem) continue
const modelId = typeof modelItem === "string" ? modelItem : modelItem.id
if (modelId) {
modelSet.add(`${providerId}/${modelId}`)
}
}
}
log("[fetchAvailableModels] parsed from provider-models cache", {
count: modelSet.size,
connectedProviders: Array.from(connectedProviders).slice(0, 5),
})
return modelSet.size > previousSize
}

View File

@@ -1,14 +1,18 @@
const store = new Map<string, Record<string, boolean>>()
const store = new Map<string, Record<string, boolean>>();
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
store.set(sessionID, { ...tools })
store.set(sessionID, { ...tools });
}
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
const tools = store.get(sessionID)
return tools ? { ...tools } : undefined
const tools = store.get(sessionID);
return tools ? { ...tools } : undefined;
}
export function deleteSessionTools(sessionID: string): void {
store.delete(sessionID);
}
export function clearSessionTools(): void {
store.clear()
store.clear();
}

View File

@@ -77,13 +77,40 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
storeToolMetadata(ctx.sessionID, callID, meta)
}
const isActive = task.status === "pending" || task.status === "running"
const shouldBlock = args.block === true
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
const fullSession = args.full_session ?? true
let resolvedTask = task
if (shouldBlock && (task.status === "pending" || task.status === "running")) {
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
await delay(1000)
const currentTask = manager.getTask(args.task_id)
if (!currentTask) {
return `Task was deleted: ${args.task_id}`
}
if (currentTask.status !== "pending" && currentTask.status !== "running") {
resolvedTask = currentTask
break
}
}
const finalCheck = manager.getTask(args.task_id)
if (finalCheck) {
resolvedTask = finalCheck
}
}
const isActive = resolvedTask.status === "pending" || resolvedTask.status === "running"
const includeThinking = isActive || (args.include_thinking ?? false)
const includeToolResults = isActive || (args.include_tool_results ?? false)
if (fullSession) {
return await formatFullSession(task, client, {
return await formatFullSession(resolvedTask, client, {
includeThinking,
messageLimit: args.message_limit,
sinceMessageId: args.since_message_id,
@@ -92,44 +119,15 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
})
}
const shouldBlock = args.block === true
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
if (task.status === "completed") {
return await formatTaskResult(task, client)
if (resolvedTask.status === "completed") {
return await formatTaskResult(resolvedTask, client)
}
if (task.status === "error" || task.status === "cancelled" || task.status === "interrupt") {
return formatTaskStatus(task)
if (resolvedTask.status === "error" || resolvedTask.status === "cancelled" || resolvedTask.status === "interrupt") {
return formatTaskStatus(resolvedTask)
}
if (!shouldBlock) {
return formatTaskStatus(task)
}
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
await delay(1000)
const currentTask = manager.getTask(args.task_id)
if (!currentTask) {
return `Task was deleted: ${args.task_id}`
}
if (currentTask.status === "completed") {
return await formatTaskResult(currentTask, client)
}
if (currentTask.status === "error" || currentTask.status === "cancelled" || currentTask.status === "interrupt") {
return formatTaskStatus(currentTask)
}
}
const finalTask = manager.getTask(args.task_id)
if (!finalTask) {
return `Task was deleted: ${args.task_id}`
}
return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`
return formatTaskStatus(resolvedTask)
} catch (error) {
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
}

View File

@@ -339,6 +339,48 @@ describe("background_output full_session", () => {
})
})
describe("background_output blocking", () => {
test("block=true waits for task completion even with default full_session=true", async () => {
// #given a task that transitions running → completed after 2 polls
let pollCount = 0
const task = createTask({ status: "running" })
const manager: BackgroundOutputManager = {
getTask: (id: string) => {
if (id !== task.id) return undefined
pollCount++
if (pollCount >= 3) {
task.status = "completed"
}
return task
},
}
const client = createMockClient({
"ses-1": [
{
id: "m1",
info: { role: "assistant", time: "2026-01-01T00:00:00Z" },
parts: [{ type: "text", text: "completed result" }],
},
],
})
const tool = createBackgroundOutput(manager, client)
// #when block=true, full_session not specified (defaults to true)
const output = await tool.execute({
task_id: "task-1",
block: true,
timeout: 10000,
}, mockContext)
// #then should have waited and returned full session output
expect(task.status).toBe("completed")
expect(pollCount).toBeGreaterThanOrEqual(3)
expect(output).toContain("# Full Session Output")
expect(output).toContain("completed result")
})
})
describe("background_cancel", () => {
test("cancels a running task via manager", async () => {
// #given

View File

@@ -2,6 +2,11 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
interface SDKMessage {
info?: { role?: string; time?: { created?: number } }
parts?: Array<{ type: string; text?: string; content?: string | Array<{ type: string; text?: string }> }>
}
export async function processMessages(
sessionID: string,
ctx: PluginInput
@@ -20,9 +25,8 @@ export async function processMessages(
// Include both assistant messages AND tool messages
// Tool results (grep, glob, bash output) come from role "tool"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const relevantMessages = messages.filter(
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
(m: SDKMessage) => m.info?.role === "assistant" || m.info?.role === "tool"
)
if (relevantMessages.length === 0) {
@@ -34,8 +38,7 @@ export async function processMessages(
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
// Sort by time ascending (oldest first) to process messages in order
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
const sortedMessages = [...relevantMessages].sort((a: SDKMessage, b: SDKMessage) => {
const timeA = a.info?.time?.created ?? 0
const timeB = b.info?.time?.created ?? 0
return timeA - timeB
@@ -52,12 +55,11 @@ export async function processMessages(
const extractedContent: string[] = []
for (const message of newMessages) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const part of (message as any).parts ?? []) {
for (const part of message.parts ?? []) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extractedContent.push(part.text)
} else if (part.type === "tool_result") {
} else if ((part.type as string) === "tool_result") {
// Tool results contain the actual output from tool calls
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
if (typeof toolResult.content === "string" && toolResult.content) {

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