Compare commits

..

83 Commits

Author SHA1 Message Date
github-actions[bot]
f27fd9a6de release: v3.10.0 2026-03-02 06:27:47 +00:00
YeonGyu-Kim
3db46a58a7 feat(hashline): change hashline_edit default from true to false
Hashline edit tool and companion hooks now require explicit opt-in
via `"hashline_edit": true` in config. Previously enabled by default.

- tool-registry: hashline edit tool not registered unless opted in
- create-tool-guard-hooks: hashline-read-enhancer disabled by default
- Updated config schema comment and documentation
- Added TDD tests for default behavior
2026-03-02 15:20:31 +09:00
YeonGyu-Kim
0dd9ac43ea perf(read-image-resizer): decode only first 32KB of base64 for dimension parsing
Previously decoded entire image buffer to read headers. Now slices base64
to 32KB prefix before decoding — sufficient for PNG/GIF/WebP/JPEG headers.
Dramatically reduces memory allocation for large images.
2026-03-02 15:20:31 +09:00
YeonGyu-Kim
1a9e7eb305 fix(hook-message-injector): add process-unique prefix to message/part IDs to prevent storage collisions
IDs now include a random 8-hex-char prefix per process (e.g. msg_a1b2c3d4_000001)
preventing collisions when counters reset across process restarts.
2026-03-02 15:20:31 +09:00
YeonGyu-Kim
682a3c8515 fix(hooks): prevent SSRF via URL scheme validation and extend disable mechanism to HTTP hooks
- Restrict HTTP hook URLs to http: and https: schemes only (blocks file://, data://, ftp://)
- Extend hook disable config to cover HTTP hooks by matching against hook URL identifier
- Update all 5 hook executors (pre-tool-use, post-tool-use, stop, pre-compact, user-prompt-submit)
- Add 6 new tests for URL scheme validation (file, data, ftp rejection + http, https, invalid URL)
2026-03-02 15:20:31 +09:00
github-actions[bot]
a666612354 @mathew-cf has signed the CLA in code-yeongyu/oh-my-opencode#2233 2026-03-01 20:19:41 +00:00
github-actions[bot]
a6955d7d14 @Chocothin has signed the CLA in code-yeongyu/oh-my-opencode#2230 2026-03-01 13:52:22 +00:00
github-actions[bot]
1a25b251c3 @DEAN-Cherry has signed the CLA in code-yeongyu/oh-my-opencode#2227 2026-03-01 08:13:56 +00:00
github-actions[bot]
9a505a33ac @laciferin2024 has signed the CLA in code-yeongyu/oh-my-opencode#2222 2026-03-01 01:16:35 +00:00
YeonGyu-Kim
acc21326c5 Merge pull request #2212 from code-yeongyu/fix/h5-collector-ordering
fix(context-injector): use monotonic registration order instead of timestamp for deterministic sorting
2026-02-28 13:40:20 +09:00
YeonGyu-Kim
e7503655b9 Merge pull request #2211 from code-yeongyu/fix/c2-hook-message-ids
fix(hook-message-injector): use monotonic counter for deterministic message/part IDs
2026-02-28 13:40:18 +09:00
YeonGyu-Kim
73fea697d7 Merge pull request #2210 from code-yeongyu/fix/c1-synthetic-part-id
fix(context-injector): use deterministic synthetic part ID for cache stability
2026-02-28 13:40:16 +09:00
YeonGyu-Kim
d39b3aa9b7 Merge pull request #2151 from ualtinok/fix/agent-usage-reminder-subagent-exclusion
[Bug]: agent-usage-reminder hook sends circular reminders to explore/librarian subagents
2026-02-28 13:37:14 +09:00
YeonGyu-Kim
7c9f507dad fix(context-injector): use monotonic registration order instead of timestamp for deterministic sorting 2026-02-28 13:30:57 +09:00
YeonGyu-Kim
4d8360c72f fix(context-injector): use deterministic synthetic part ID for cache stability 2026-02-28 13:30:49 +09:00
YeonGyu-Kim
6e9f27350d fix(hook-message-injector): use monotonic counter for deterministic message/part IDs 2026-02-28 13:30:14 +09:00
YeonGyu-Kim
2478b28e71 Merge pull request #2204 from ualtinok/fix/glob-directory-prefix
fix(glob): use cwd-relative search for ripgrep to fix directory prefix patterns
2026-02-28 13:27:00 +09:00
ismeth
418cf8529f fix(glob): use cwd-relative search for ripgrep to fix directory prefix patterns
Ripgrep's --glob flag silently returns zero results when the search target
is an absolute path and the pattern contains directory prefixes (e.g.
'apps/backend/**/*.ts' with '/project'). This is a known ripgrep behavior
where glob matching fails against paths rooted at absolute arguments.

Fix by running ripgrep with cwd set to the search path and '.' as the
search target, matching how the find backend already operates. Ripgrep
then sees relative paths internally, so directory-prefixed globs match
correctly. Output paths are resolved back to absolute via resolve().
2026-02-28 13:26:30 +09:00
YeonGyu-Kim
cc6ab1addc feat(hooks): add read-image-resizer hook
Intercepts Read tool output with image attachments and resizes to comply with Anthropic API limits (≤1568px long edge, ≤5MB). Only activates for Anthropic provider sessions and appends resize metadata (original/new resolution, token count) to tool output.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 13:21:40 +09:00
YeonGyu-Kim
74f7992442 feat(agents): add Gemini tool guide and few-shot examples to system prompt
Embed tool usage guide (per-tool parallel/sequential signals) and 5 concrete tool-calling examples directly in Gemini system prompt. Modeled after Antigravity's inline schema approach to improve Gemini tool-call quality.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 13:21:40 +09:00
YeonGyu-Kim
13d689cb3a feat(agents): add Plan Agent dependency and strengthen Deep Parallel Delegation for non-Claude models
Non-Claude models skip planning and under-parallelize. Two new sections
injected only when model is not Claude:

- Plan Agent Dependency: multi-step tasks MUST consult Plan Agent first,
  use session_id for follow-ups, ask aggressively when ambiguous
- Deep Parallel Delegation (rewrite): explicit '4 units = 4 agents'
  pattern, each with clear GOAL + success criteria, all run_in_background
2026-02-28 13:21:40 +09:00
YeonGyu-Kim
29d606241b Merge pull request #2202 from ualtinok/fix/glob-grep-relative-path
fix(tools): resolve relative paths in glob/grep against project directory
2026-02-28 13:17:53 +09:00
YeonGyu-Kim
d65ee31d17 Merge pull request #2208 from code-yeongyu/feat/http-hook-support
feat(hooks): add HTTP hook handler support
2026-02-28 12:10:45 +09:00
YeonGyu-Kim
4dae458cf7 style(hooks): add blank line between interpolateEnvVars and resolveHeaders 2026-02-28 12:05:08 +09:00
YeonGyu-Kim
4740515f2f fix(agents): replace active polling with notification-based waiting for background tasks
Sisyphus prompt instructed 'your next action is background_output' which
caused agents to repeatedly poll running tasks instead of ending their
response and waiting for the system notification.

- Replace 'STOP all other output' with 'end your response' (actionable)
- Add system-reminder notification mechanism explanation
- Add explicit 'Do NOT poll' prohibition
- Reduce background_cancel(all=true) mentions from 5x to 1x (Hard Blocks)
- Reduce Oracle collect obligation from 4x to 2x
- Remove motivational fluff ('blind spots', 'normal and expected')

Net: -2 lines, clearer mechanism, eliminates polling loop root cause.
2026-02-28 12:04:18 +09:00
YeonGyu-Kim
3eb53adfc3 fix(hooks): resolve cubic review issues
- Replace two-pass env interpolation with single-pass combined regex to
  prevent re-interpolation of $-sequences in substituted header values
- Convert HookEntry to discriminated union so type: "http" requires url,
  preventing invalid configs from passing type checking
- Add regression test for double-interpolation edge case
2026-02-28 12:00:02 +09:00
YeonGyu-Kim
43dfdb2380 feat(hooks): add HTTP hook handler support
Add type: "http" hook support matching Claude Code's HTTP hook specification.
HTTP hooks send POST requests with JSON body, support env var interpolation
in headers via allowedEnvVars, and configurable timeout.

New files:
- execute-http-hook.ts: HTTP hook execution with env var interpolation
- dispatch-hook.ts: Unified dispatcher for command and HTTP hooks
- execute-http-hook.test.ts: 14 tests covering all HTTP hook scenarios

Modified files:
- types.ts: Added HookHttp interface, HookAction union type
- config.ts: Updated to accept HookAction in raw hook matchers
- pre-tool-use/post-tool-use/stop/user-prompt-submit/pre-compact:
  Updated all 5 executors to dispatch HTTP hooks via dispatchHook()
- plugin-loader/types.ts: Added "http" to HookEntry type union
2026-02-28 11:38:34 +09:00
ismeth
7cec6f7c8b fix(tools): resolve relative paths in glob/grep against project directory
When models pass relative paths (e.g. 'apps/ios/CleanSlate') to glob/grep
tools, they were passed directly to ripgrep which resolved them against
process.cwd(). In OpenCode Desktop, process.cwd() is '/' causing all
relative path lookups to fail with 'No such file or directory'.

Fix: use path.resolve(ctx.directory, args.path) to resolve relative paths
against the project directory instead of relying on process.cwd().
2026-02-28 00:58:05 +01:00
github-actions[bot]
866bd50dca @renanale has signed the CLA in code-yeongyu/oh-my-opencode#2201 2026-02-27 22:38:27 +00:00
Nguyen Khac Trung Kien
518e3c5da7 Merge pull request #2161 from acamq/fix/duplicate-agent-entries 2026-02-27 18:35:59 +07:00
Kenny
85126247b4 Merge pull request #2153 from devxoul/fix/docs-master-to-dev-branch
replace master branch references with dev
2026-02-27 19:33:48 +08:00
Kenny
bc2eaaf89b Merge pull request #2160 from dwnmf/add-russian-readme
Add Russian README
2026-02-27 19:32:14 +08:00
github-actions[bot]
0f73504639 @mertyldrm has signed the CLA in code-yeongyu/oh-my-opencode#2184 2026-02-27 10:53:16 +00:00
github-actions[bot]
db32ac5ae8 @YLRong has signed the CLA in code-yeongyu/oh-my-opencode#2176 2026-02-27 09:06:59 +00:00
YeonGyu-Kim
2eb7994163 fix(atlas): use start-work session agent for continuation gating
Prefer the in-memory session agent set by /start-work when validating idle continuation eligibility, so stale message storage agent values do not block boulder continuation.
2026-02-27 15:01:44 +09:00
github-actions[bot]
15ad9442a4 @1noilimrev has signed the CLA in code-yeongyu/oh-my-opencode#2166 2026-02-27 05:53:45 +00:00
LYA_CAP_OCEAN
07542d39aa Fix markdown blockquote structure in Russian README 2026-02-27 01:59:29 +03:00
acamq
d6dd54867a fix(agents): remove duplicate remapped agent keys 2026-02-26 15:58:02 -07:00
github-actions[bot]
f3cbc24e78 @dwnmf has signed the CLA in code-yeongyu/oh-my-opencode#2160 2026-02-26 22:51:52 +00:00
LYA_CAP_OCEAN
b76abeb8e0 Add Russian README 2026-02-27 01:49:51 +03:00
YeonGyu-Kim
21c249e8c8 fix(ci): pre-download baseline compile targets to avoid Bun extraction failures
Bun's internal download of baseline compile targets from npm registry
consistently fails on Windows CI runners (ExtractionFailed error).
Pre-download the baseline binary via curl into Bun's cache directory
so the compile step finds it already cached and skips the download.

Also makes publish job resilient with if: always() so one failed
platform doesn't block publishing all other successful platforms.
2026-02-27 04:43:29 +09:00
YeonGyu-Kim
0749a8f138 fix(ci): make platform publish resilient to individual build failures
publish job now runs with if: always() && !cancelled(), and gates
each publish step on download.outcome == 'success'. One flaky target
(e.g. windows-x64-baseline) no longer blocks all other platforms.
2026-02-27 04:39:05 +09:00
github-actions[bot]
ae54fd31f4 release: v3.9.0 2026-02-26 19:30:38 +00:00
YeonGyu-Kim
bdd86b1415 fix(hephaestus): remove auto-commit policy to prevent surprise commits
The auto-commit section instructed Hephaestus to automatically commit after
implementation work. Users who didn't know about this behavior would get
surprise commits — a trust-breaking behavioral change flagged by 5 Oracle
reviews as the sole publish blocker for 3.9.0.
2026-02-27 04:27:07 +09:00
YeonGyu-Kim
76cba9b222 Merge pull request #2159 from code-yeongyu/fix/ralph-loop-completion-scoping
fix(ralph-loop): scope completion detection to messages since loop start
2026-02-27 03:23:46 +09:00
YeonGyu-Kim
2955dc868f Merge pull request #2158 from code-yeongyu/fix/hashline-diff-format-compat
test(hashline-edit): verify diff format compatibility with OpenCode UI
2026-02-27 03:23:43 +09:00
YeonGyu-Kim
3ab4b7f77b Merge pull request #2157 from code-yeongyu/fix/token-limiter-safe-truncation
fix(token-limiter): truncate at newline boundaries instead of raw slice
2026-02-27 03:23:40 +09:00
YeonGyu-Kim
3540d1c550 Merge pull request #2156 from code-yeongyu/fix/background-pending-notif-leak
fix(background-agent): clean pendingNotifications on session.deleted
2026-02-27 03:23:38 +09:00
YeonGyu-Kim
9bc9dcaa18 Merge pull request #2155 from code-yeongyu/fix/ultrawork-thinking-db-write
fix(ultrawork-db): write $.thinking alongside $.variant in deferred model override
2026-02-27 03:23:30 +09:00
YeonGyu-Kim
f2a1412bf1 test(ralph-loop): harden completion detector PluginInput mock 2026-02-27 03:12:22 +09:00
YeonGyu-Kim
190c6991ac fix(ralph-loop): persist session message count at loop start 2026-02-27 03:08:30 +09:00
YeonGyu-Kim
e17a00a906 fix(ralph-loop): scope completion detection to messages since loop start 2026-02-27 03:05:14 +09:00
YeonGyu-Kim
c8aa1bbce4 test(hashline-edit): add diff format compatibility tests 2026-02-27 03:02:49 +09:00
YeonGyu-Kim
911710e4d4 fix(token-limiter): truncate at newline boundaries instead of raw slice 2026-02-27 03:02:04 +09:00
YeonGyu-Kim
050b93bebb fix(background-agent): clean pendingNotifications on session.deleted 2026-02-27 03:00:39 +09:00
YeonGyu-Kim
2ffa803b05 fix(ultrawork-db): write $.thinking alongside $.variant in deferred model override 2026-02-27 02:59:22 +09:00
YeonGyu-Kim
cf97494073 Merge pull request #2154 from minpeter/feat/hashline-benchmark
fix(hashline-edit): harden deduplication, validation, and add benchmark suite
2026-02-27 02:14:12 +09:00
minpeter
8fb5949ac6 fix(benchmarks): address review feedback on error handling and validation
- headless.ts: emit error field on tool_result when output starts with Error:
- test-multi-model.ts: errored/timed-out models now shown as RED and exit(1)
- test-multi-model.ts: validate --timeout arg (reject NaN/negative)
- test-edge-cases.ts: use exact match instead of trim() for whitespace test
- test-edge-cases.ts: skip file pre-creation for create-via-append test

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-27 01:44:51 +09:00
minpeter
04f50bac1f feat(benchmarks): add hashline-edit test suites (46 tests)
Ported from code-editing-agent benchmark:
- test-edit-ops.ts: 21 basic edit operations (replace, append, prepend, delete, batch, range)
- test-edge-cases.ts: 25 edge cases (unicode, long lines, whitespace, special chars, file creation)
- test-multi-model.ts: multi-model comparison runner

Verified 21/21 + 25/25 (100%) with Minimax M2.5 via FriendliAI.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-27 01:37:49 +09:00
minpeter
d1a0a66dde feat(benchmarks): add hashline-edit benchmark agent and deps
Standalone headless agent using Vercel AI SDK v6 with FriendliAI provider.
Imports hashline-edit pure functions directly from src/ for benchmarking
the edit tool against LLMs (Minimax M2.5 via FriendliAI).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-27 01:37:40 +09:00
minpeter
b1203b9501 Fix hashline-edit deduplication and validation
- Canonicalize anchors in dedupe keys to handle whitespace variants
- Make lines field required in edit operations
- Only allow unanchored append/prepend to create missing files
- Reorder delete/rename validation to prevent edge cases
- Add allow_non_gpt_model and max_prompt_tokens to config schema
  ```
2026-02-27 01:37:19 +09:00
ismeth
35edcecd8f fix(agent-usage-reminder): skip reminders for non-orchestrator subagents 2026-02-26 17:05:33 +01:00
Jeon Suyeol
0cc9edac17 replace master branch references with dev across docs, schema URLs, and tests 2026-02-27 00:49:53 +09:00
YeonGyu-Kim
58201220cc Merge pull request #2093 from code-yeongyu/fix/issue-1966-ultrawork-variant
fix(keyword-detector): respect ultrawork config variant instead of hardcoding "max"
2026-02-26 23:23:14 +09:00
YeonGyu-Kim
4efad491e7 Merge pull request #2149 from code-yeongyu/fix/issue-1815-1733-prompt-token-count
fix(delegate-task): add token counting and truncation to prevent context overflow
2026-02-26 23:19:35 +09:00
YeonGyu-Kim
4df69c58bf fix(keyword-detector): respect ultrawork config variant instead of hardcoding "max"
Closes #1966
2026-02-26 23:15:32 +09:00
YeonGyu-Kim
cc8ef7fe39 ci: trigger CI 2026-02-26 23:14:33 +09:00
YeonGyu-Kim
2ece7c3d0a Merge pull request #1963 from MoerAI/fix/multi-issue-1888-1693-1891
fix: resolve issues #1888, #1693, #1891
2026-02-26 23:13:00 +09:00
YeonGyu-Kim
decff3152a Merge pull request #2145 from code-yeongyu/fix/issue-1915-windows-spawn-hide
fix(windows): add windowsHide to Bun.spawn calls to prevent stray terminal windows
2026-02-26 23:12:57 +09:00
YeonGyu-Kim
0526bac873 Merge pull request #2148 from code-yeongyu/fix/issue-2121-legacy-hardware-baseline
fix(ci): add baseline CPU variant binaries for legacy hardware support
2026-02-26 21:09:19 +09:00
YeonGyu-Kim
0c62656cc6 Merge pull request #2146 from code-yeongyu/fix/issue-2065-1968-model-updates
fix(models): update Gemini 3→3.1 Pro and add Kimi K2.5 to writing category
2026-02-26 21:07:23 +09:00
YeonGyu-Kim
aff43bfc77 Merge pull request #2143 from code-yeongyu/fix/issue-2017-stop-continuation-cancel
fix(stop-continuation): wire backgroundManager to cancel running tasks on stop
2026-02-26 21:07:20 +09:00
YeonGyu-Kim
6865cee8ca Merge pull request #2141 from code-yeongyu/fix/issue-2084-ralph-loop-inflight
fix(ralph-loop): add inFlight guard and improve completion detection to prevent infinite loops
2026-02-26 21:07:17 +09:00
YeonGyu-Kim
8721ba471c Merge pull request #2140 from code-yeongyu/fix/issue-2025-blocked-todo-continuation
fix(todo-continuation): exclude blocked todos from incomplete count to prevent infinite loops
2026-02-26 21:06:55 +09:00
YeonGyu-Kim
d425f9bb80 fix(models): update Gemini 3 to 3.1 Pro and add Kimi to writing category fallback
Closes #2065

Closes #1968
2026-02-26 21:01:26 +09:00
YeonGyu-Kim
cc5e9d1e9b fix(ci): add baseline CPU variant binaries for legacy hardware support
Closes #2121
2026-02-26 21:00:45 +09:00
YeonGyu-Kim
269f37af1c fix(windows): add windowsHide to Bun.spawn calls to prevent stray terminal windows
Closes #1915
2026-02-26 21:00:40 +09:00
YeonGyu-Kim
ccb789e5df fix(stop-continuation): wire backgroundManager to cancel running tasks on stop
Closes #2017
2026-02-26 20:59:35 +09:00
YeonGyu-Kim
a6617d93c0 fix(ralph-loop): add inFlight guard and improve completion detection to prevent infinite loops
Closes #2084
2026-02-26 20:59:18 +09:00
YeonGyu-Kim
2295161022 fix(ralph-loop): add inFlight guard and improve completion detection to prevent infinite loops
Closes #2084
2026-02-26 20:58:55 +09:00
YeonGyu-Kim
0516f2febc fix(todo-continuation): exclude blocked todos from incomplete count to prevent infinite loops
Closes #2025
2026-02-26 20:58:48 +09:00
YeonGyu-Kim
07e8d965a8 fix(atlas): allow task and call_omo_agent tools for subagent dispatch
Closes #2044
2026-02-26 20:54:42 +09:00
MoerAI
718884210b fix: resolve issues #1888, #1693, #1891
- fix(hooks): skip todo continuation when agent has pending question (#1888)
  Add pending-question-detection module that walks messages backwards
  to detect unanswered question tool_use, preventing CONTINUATION_PROMPT
  injection while awaiting user response.

- fix(config): allow custom agent names in disabled_agents (#1693)
  Change disabled_agents schema from BuiltinAgentNameSchema to z.string()
  and add filterDisabledAgents helper in agent-config-handler to filter
  user, project, and plugin agents with case-insensitive matching.

- fix(agents): change primary agents mode to 'all' (#1891)
  Update Sisyphus, Hephaestus, and Atlas agent modes from 'primary'
  to 'all' so they are available for @mention routing and task()
  delegation in addition to direct chat.
2026-02-24 16:57:02 +09:00
158 changed files with 6860 additions and 723 deletions

View File

@@ -35,15 +35,15 @@ jobs:
# - Uploads compressed artifacts for the publish job
# =============================================================================
build:
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
runs-on: ${{ startsWith(matrix.platform, 'windows-') && 'windows-latest' || 'ubuntu-latest' }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
max-parallel: 7
max-parallel: 11
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
steps:
- uses: actions/checkout@v4
@@ -82,6 +82,52 @@ jobs:
cd packages/${{ matrix.platform }}
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
- name: Pre-download baseline compile target
if: steps.check.outputs.skip != 'true' && endsWith(matrix.platform, '-baseline')
shell: bash
run: |
BUN_VERSION=$(bun --version)
PLATFORM="${{ matrix.platform }}"
PKG_NAME="bun-${PLATFORM}"
CACHE_DIR=$(bun pm cache)
CACHE_DEST="${CACHE_DIR}/${PKG_NAME}-v${BUN_VERSION}"
if [[ -f "$CACHE_DEST" ]]; then
echo "✓ Compile target already cached at ${CACHE_DEST}"
exit 0
fi
echo "Pre-downloading ${PKG_NAME} v${BUN_VERSION} to ${CACHE_DEST}"
TARBALL_URL="https://registry.npmjs.org/@oven/bun-${PLATFORM}/-/bun-${PLATFORM}-${BUN_VERSION}.tgz"
echo "URL: ${TARBALL_URL}"
mkdir -p "$(dirname "$CACHE_DEST")"
TMP_DIR=$(mktemp -d)
# Download and extract the bun binary from npm tarball
curl -fsSL --retry 5 --retry-delay 5 "${TARBALL_URL}" | tar -xzf - -C "${TMP_DIR}"
if [[ "$PLATFORM" == windows-* ]]; then
BIN_NAME="bun.exe"
else
BIN_NAME="bun"
fi
# npm tarball has package/bin/bun structure
if [[ -f "${TMP_DIR}/package/bin/${BIN_NAME}" ]]; then
cp "${TMP_DIR}/package/bin/${BIN_NAME}" "${CACHE_DEST}"
elif [[ -f "${TMP_DIR}/package/${BIN_NAME}" ]]; then
cp "${TMP_DIR}/package/${BIN_NAME}" "${CACHE_DEST}"
else
echo "Could not find ${BIN_NAME} in tarball, listing contents:"
find "${TMP_DIR}" -type f
exit 1
fi
chmod +x "${CACHE_DEST}" 2>/dev/null || true
echo "✓ Pre-downloaded to ${CACHE_DEST}"
ls -lh "${CACHE_DEST}"
- name: Build binary
if: steps.check.outputs.skip != 'true'
uses: nick-fields/retry@v3
@@ -95,14 +141,18 @@ jobs:
case "$PLATFORM" in
darwin-arm64) TARGET="bun-darwin-arm64" ;;
darwin-x64) TARGET="bun-darwin-x64" ;;
darwin-x64-baseline) TARGET="bun-darwin-x64-baseline" ;;
linux-x64) TARGET="bun-linux-x64" ;;
linux-x64-baseline) TARGET="bun-linux-x64-baseline" ;;
linux-arm64) TARGET="bun-linux-arm64" ;;
linux-x64-musl) TARGET="bun-linux-x64-musl" ;;
linux-x64-musl-baseline) TARGET="bun-linux-x64-musl-baseline" ;;
linux-arm64-musl) TARGET="bun-linux-arm64-musl" ;;
windows-x64) TARGET="bun-windows-x64" ;;
windows-x64-baseline) TARGET="bun-windows-x64-baseline" ;;
esac
if [ "$PLATFORM" = "windows-x64" ]; then
if [[ "$PLATFORM" == windows-* ]]; then
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
else
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
@@ -119,7 +169,7 @@ jobs:
PLATFORM="${{ matrix.platform }}"
cd packages/${PLATFORM}
if [ "$PLATFORM" = "windows-x64" ]; then
if [[ "$PLATFORM" == windows-* ]]; then
# Windows: use 7z (pre-installed on windows-latest)
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
else
@@ -150,12 +200,13 @@ jobs:
# =============================================================================
publish:
needs: build
if: always() && !cancelled()
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
steps:
- name: Check if already published
id: check
@@ -172,19 +223,21 @@ jobs:
fi
- name: Download artifact
id: download
if: steps.check.outputs.skip != 'true'
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: binary-${{ matrix.platform }}
path: .
- name: Extract artifact
if: steps.check.outputs.skip != 'true'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
run: |
PLATFORM="${{ matrix.platform }}"
mkdir -p packages/${PLATFORM}
if [ "$PLATFORM" = "windows-x64" ]; then
if [[ "$PLATFORM" == windows-* ]]; then
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
else
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/
@@ -195,13 +248,13 @@ jobs:
ls -la packages/${PLATFORM}/bin/
- uses: actions/setup-node@v4
if: steps.check.outputs.skip != 'true'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Publish ${{ matrix.platform }}
if: steps.check.outputs.skip != 'true'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
run: |
cd packages/${{ matrix.platform }}

View File

@@ -189,7 +189,7 @@ jobs:
VERSION="${{ steps.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl windows-x64; do
for platform in darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline; do
jq --arg v "$VERSION" '.version = $v' "packages/${platform}/package.json" > tmp.json
mv tmp.json "packages/${platform}/package.json"
done

View File

@@ -122,7 +122,7 @@ bunx oh-my-opencode run # Non-interactive session
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| ci.yml | push/PR | Tests (split: mock-heavy isolated + batch), typecheck, build, schema auto-commit |
| publish.yml | manual | Version bump, npm publish, platform binaries, GitHub release, merge to master |
| publish.yml | manual | Version bump, npm publish, platform binaries, GitHub release, merge to dev |
| publish-platform.yml | called | 11 platform binaries via bun compile (darwin/linux/windows) |
| sisyphus-agent.yml | @mention | AI agent handles issues/PRs |

View File

@@ -44,7 +44,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -96,7 +96,7 @@ OmOをインストールして、`ultrawork`とタイプしてください。狂
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
もしくは[インストールガイド](docs/guide/installation.md)を直接読んでもいいですが、マジでエージェントにやらせてください。人間は設定で必ずタイポします。
@@ -106,7 +106,7 @@ https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/
インストールガイドを取得して、それに従ってください:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
---

View File

@@ -44,7 +44,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -95,7 +95,7 @@ OmO 설치하고. `ultrawork` 치세요. 끝.
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
아니면 [설치 가이드](docs/guide/installation.md)를 직접 읽으셔도 되지만, 진심으로 그냥 에이전트한테 시키세요. 사람은 설정하다 꼭 오타 냅니다.
@@ -105,7 +105,7 @@ https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/
설치 가이드를 가져와서 따라 하세요:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
---

View File

@@ -47,7 +47,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -99,7 +99,7 @@ Copy and paste this prompt to your LLM agent (Claude Code, AmpCode, Cursor, etc.
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
Or read the [Installation Guide](docs/guide/installation.md), but seriously, let an agent do it. Humans fat-finger configs.
@@ -109,7 +109,7 @@ Or read the [Installation Guide](docs/guide/installation.md), but seriously, let
Fetch the installation guide and follow it:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
---

367
README.ru.md Normal file
View File

@@ -0,0 +1,367 @@
> [!WARNING] **Предупреждение о безопасности: сайт-имитатор**
>
> **ohmyopencode.com НЕ аффилирован с этим проектом.** Мы не управляем этим сайтом и не одобряем его.
>
> OhMyOpenCode — **бесплатный и открытый проект**. Не скачивайте установщики и не вводите платёжные данные на сторонних сайтах, которые называют себя «официальными».
>
> Поскольку сайт-имитатор находится за платным доступом, мы **не можем проверить, что именно он распространяет**. Относитесь к любым загрузкам с него как к **потенциально небезопасным**.
>
> ✅ Официальные загрузки: https://github.com/code-yeongyu/oh-my-opencode/releases
> [!NOTE]
>
> [![Sisyphus Labs - Sisyphus is the agent that codes like your team.](./.github/assets/sisyphuslabs.png?v=2)](https://sisyphuslabs.ai)
>
> > **Мы создаём полноценную продуктовую версию Sisyphus, чтобы задать стандарты для frontier-агентов. <br />Присоединяйтесь к листу ожидания [здесь](https://sisyphuslabs.ai).**
> [!TIP] Будьте с нами!
>
> | [](https://discord.gg/PUwSMR9XNk) | Вступайте в наш [Discord](https://discord.gg/PUwSMR9XNk), чтобы общаться с контрибьюторами и пользователями `oh-my-opencode`. |
> | ----------------------------------- | ------------------------------------------------------------ |
> | [](https://x.com/justsisyphus) | Новости и обновления `oh-my-opencode` раньше публиковались на моём аккаунте X. <br /> После ошибочной блокировки, [@justsisyphus](https://x.com/justsisyphus) публикует обновления вместо меня. |
> | [](https://github.com/code-yeongyu) | Подпишитесь на [@code-yeongyu](https://github.com/code-yeongyu) на GitHub, чтобы следить за другими проектами. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> --> <div align="center">
[![Oh My OpenCode](./.github/assets/hero.jpg)](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
[![Preview](./.github/assets/omo.png)](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
</div>
> Anthropic [**заблокировал OpenCode из-за нас.**](https://x.com/thdxr/status/2010149530486911014) **Да, это правда.** Они хотят держать вас в замкнутой системе. Claude Code — красивая тюрьма, но всё равно тюрьма.
>
> Мы не делаем привязки. Мы работаем с любыми моделями. Claude / Kimi / GLM для оркестрации. GPT для рассуждений. Minimax для скорости. Gemini для творческих задач. Будущее — не в выборе одного победителя, а в оркестровке всех. Модели дешевеют каждый месяц. Умнеют каждый месяц. Ни один провайдер не будет доминировать. Мы строим под открытый рынок, а не под чьи-то огороженные сады.
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/code-yeongyu/oh-my-opencode?color=369eff&labelColor=black&logo=github&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/releases) [![npm downloads](https://img.shields.io/npm/dt/oh-my-opencode?color=ff6b35&labelColor=black&style=flat-square)](https://www.npmjs.com/package/oh-my-opencode) [![GitHub Contributors](https://img.shields.io/github/contributors/code-yeongyu/oh-my-opencode?color=c4f042&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/graphs/contributors) [![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members) [![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers) [![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues) [![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
English | 한국어 | 日本語 | 简体中文 | Русский
</div> <!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## Отзывы
> «Из-за него я отменил подписку на Cursor. В опенсорс-сообществе происходит что-то невероятное.» — [Arthur Guiot](https://x.com/arthur_guiot/status/2008736347092382053?s=20)
> «Если Claude Code делает за 7 дней то, на что у человека уходит 3 месяца, Sisyphus справляется за 1 час. Он просто работает, пока задача не выполнена. Это дисциплинированный агент.» <br/>— B, исследователь в области квантовых финансов
> «За один день устранил 8000 предупреждений eslint с помощью Oh My Opencode.» <br/>— [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> «За ночь конвертировал приложение на tauri в 45k строк в веб-SaaS с помощью Ohmyopencode и ralph loop. Начал с промпта «проинтервьюируй меня», попросил оценки и рекомендации по вопросам. Было удивительно наблюдать за работой и утром проснуться с почти рабочим сайтом!» — [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
> «Используйте oh-my-opencode — вы не захотите возвращаться назад.» <br/>— [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> «Пока не могу точно объяснить, почему это так круто, но опыт разработки вышел на совершенно другой уровень.» — [苔硯:こけすずり](https://x.com/kokesuzuri/status/2008532913961529372?s=20)
> «Экспериментирую с open code, oh my opencode и supermemory этим выходным, чтобы собрать нечто среднее между Minecraft и souls-like.» «Попросил добавить анимации приседания, пока хожу на обеденную прогулку. [Видео]» — [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
> «Ребята, вам нужно включить это в ядро и нанять его. Серьёзно. Это очень, очень, очень хорошо.» <br/>— Henning Kilset
> «Наймите @yeon_gyu_kim, если сможете его уговорить, этот парень революционизировал opencode.» <br/>— [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> «Oh My OpenCode — это что-то с чем-то» — [YouTube — Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
------
# Oh My OpenCode
Вы жонглируете Claude Code, Codex, случайными OSS-моделями. Настраиваете рабочие процессы. Дебажите агентов.
Мы уже проделали эту работу. Протестировали всё. Оставили только то, что реально работает.
Установите OmO. Введите `ultrawork`. Готово.
## Установка
### Для людей
Скопируйте и вставьте этот промпт в ваш LLM-агент (Claude Code, AmpCode, Cursor и т.д.):
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
```
Или прочитайте руководство по установке, но серьёзно — пусть агент сделает это за вас. Люди ошибаются в конфигах.
### Для LLM-агентов
Загрузите руководство по установке и следуйте ему:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
```
------
## Пропустите этот README
Мы вышли за пределы эпохи чтения документации. Просто вставьте это в своего агента:
```
Read this and tell me why it's not just another boilerplate: https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/README.md
```
## Ключевые возможности
### 🪄 `ultrawork`
Вы правда это читаете? Поразительно.
Установите. Введите `ultrawork` (или `ulw`). Готово.
Всё описанное ниже, каждая функция, каждая оптимизация — вам не нужно это знать. Оно просто работает.
Даже при наличии только следующих подписок ultrawork будет работать отлично (проект не аффилирован с ними, это личная рекомендация):
- [Подписка ChatGPT ($20)](https://chatgpt.com/)
- [Подписка Kimi Code ($0.99) (*только в этом месяце)](https://www.kimi.com/membership/pricing?track_id=5cdeca93-66f0-4d35-aabb-b6df8fcea328)
- [Тариф GLM Coding ($10)](https://z.ai/subscribe)
- При доступе к оплате за токены использование моделей Kimi и Gemini обойдётся недорого.
| | Функция | Что делает |
| ---- | -------------------------------------------------------- | ------------------------------------------------------------ |
| 🤖 | **Дисциплинированные агенты** | Sisyphus оркестрирует Hephaestus, Oracle, Librarian, Explore. Полноценная AI-команда разработки в параллельном режиме. |
| ⚡ | **`ultrawork` / `ulw`** | Одно слово. Все агенты активируются. Не останавливается, пока задача не выполнена. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Анализирует истинное намерение пользователя перед классификацией и действием. Никакого буквального неверного толкования. |
| 🔗 | **Инструмент правок на основе хэш-якорей** | Хэш содержимого `LINE#ID` проверяет каждое изменение. Ноль ошибок с устаревшими строками. Вдохновлено [oh-my-pi](https://github.com/can1357/oh-my-pi). [Проблема обвязки →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP + AST-Grep** | Переименование в рабочем пространстве, диагностика перед сборкой, переписывание с учётом AST. Точность IDE для агентов. |
| 🧠 | **Фоновые агенты** | Запускайте 5+ специалистов параллельно. Контекст остаётся компактным. Результаты — когда готовы. |
| 📚 | **Встроенные MCP** | Exa (веб-поиск), Context7 (официальная документация), Grep.app (поиск по GitHub). Всегда включены. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Самореферентный цикл. Не останавливается, пока задача не выполнена на 100%. |
| ✅ | **Todo Enforcer** | Агент завис? Система немедленно возвращает его в работу. Ваша задача будет выполнена, точка. |
| 💬 | **Comment Checker** | Никакого AI-мусора в комментариях. Код читается так, словно его писал опытный разработчик. |
| 🖥️ | **Интеграция с Tmux** | Полноценный интерактивный терминал. REPL, дебаггеры, TUI. Всё живое. |
| 🔌 | **Совместимость с Claude Code** | Ваши хуки, команды, навыки, MCP и плагины? Всё работает без изменений. |
| 🎯 | **MCP, встроенные в навыки** | Навыки несут собственные MCP-серверы. Никакого раздувания контекста. |
| 📋 | **Prometheus Planner** | Стратегическое планирование в режиме интервью перед любым выполнением. |
| 🔍 | **`/init-deep`** | Автоматически генерирует иерархические файлы `AGENTS.md` по всему проекту. Отлично работает на эффективность токенов и производительность агента. |
### Дисциплинированные агенты
<table><tr> <td align="center"><img src=".github/assets/sisyphus.png" height="300" /></td> <td align="center"><img src=".github/assets/hephaestus.png" height="300" /></td> </tr></table>
**Sisyphus** (`claude-opus-4-6` / **`kimi-k2.5`** / **`glm-5`**) — главный оркестратор. Он планирует, делегирует задачи специалистам и доводит их до завершения с агрессивным параллельным выполнением. Он не останавливается на полпути.
**Hephaestus** (`gpt-5.3-codex`) — автономный глубокий исполнитель. Дайте ему цель, а не рецепт. Он исследует кодовую базу, изучает паттерны и выполняет задачи сквозным образом без лишних подсказок. *Законный Мастер.*
**Prometheus** (`claude-opus-4-6` / **`kimi-k2.5`** / **`glm-5`**) — стратегический планировщик. Режим интервью: задаёт вопросы, определяет объём работ и формирует детальный план до того, как написана хотя бы одна строка кода.
Каждый агент настроен под сильные стороны своей модели. Никакого ручного переключения между моделями. Подробнее →
> Anthropic [заблокировал OpenCode из-за нас.](https://x.com/thdxr/status/2010149530486911014) Именно поэтому Hephaestus зовётся «Законным Мастером». Ирония намеренная.
>
> Мы работаем лучше всего на Opus, но Kimi K2.5 + GPT-5.3 Codex уже превосходят ванильный Claude Code. Никакой настройки не требуется.
### Оркестрация агентов
Когда Sisyphus делегирует задачу субагенту, он выбирает не модель, а **категорию**. Категория автоматически сопоставляется с нужной моделью:
| Категория | Для чего предназначена |
| -------------------- | ------------------------------------- |
| `visual-engineering` | Фронтенд, UI/UX, дизайн |
| `deep` | Автономные исследования + выполнение |
| `quick` | Изменения в одном файле, опечатки |
| `ultrabrain` | Сложная логика, архитектурные решения |
Агент сообщает тип задачи. Обвязка подбирает нужную модель. Вы ни к чему не прикасаетесь.
### Совместимость с Claude Code
Вы тщательно настроили Claude Code. Хорошо.
Каждый хук, команда, навык, MCP и плагин работают здесь без изменений. Полная совместимость, включая плагины.
### Инструменты мирового класса для ваших агентов
LSP, AST-Grep, Tmux, MCP — реально интегрированы, а не склеены скотчем.
- **LSP**: `lsp_rename`, `lsp_goto_definition`, `lsp_find_references`, `lsp_diagnostics`. Точность IDE для каждого агента
- **AST-Grep**: Поиск и переписывание кода с учётом синтаксических паттернов для 25 языков
- **Tmux**: Полноценный интерактивный терминал. REPL, дебаггеры, TUI-приложения. Агент остаётся в сессии
- **MCP**: Веб-поиск, официальная документация, поиск по коду на GitHub. Всё встроено
### MCP, встроенные в навыки
MCP-серверы съедают бюджет контекста. Мы это исправили.
Навыки приносят собственные MCP-серверы. Запускаются по необходимости, ограничены задачей, исчезают по завершении. Контекстное окно остаётся чистым.
### Лучше пишет код. Правки на основе хэш-якорей
Проблема обвязки реальна. Большинство сбоев агентов — не вина модели. Это вина инструмента правок.
> *«Ни один из этих инструментов не даёт модели стабильный, проверяемый идентификатор строк, которые она хочет изменить... Все они полагаются на то, что модель воспроизведёт контент, который уже видела. Когда это не получается — а так бывает нередко — пользователь обвиняет модель.»*
>
> <br/>— [Can Bölük, «Проблема обвязки»](https://blog.can.ac/2026/02/12/the-harness-problem/)
Вдохновлённые [oh-my-pi](https://github.com/can1357/oh-my-pi), мы реализовали **Hashline**. Каждая строка, которую читает агент, возвращается с тегом хэша содержимого:
```
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
Агент редактирует, ссылаясь на эти теги. Если файл изменился с момента последнего чтения, хэш не совпадёт, и правка будет отклонена до любого повреждения. Никакого воспроизведения пробелов. Никаких ошибок с устаревшими строками.
Grok Code Fast 1: успешность **6.7% → 68.3%**. Просто за счёт замены инструмента правок.
### Глубокая инициализация. `/init-deep`
Запустите `/init-deep`. Будут сгенерированы иерархические файлы `AGENTS.md`:
```
project/
├── AGENTS.md ← контекст всего проекта
├── src/
│ ├── AGENTS.md ← контекст для src
│ └── components/
│ └── AGENTS.md ← контекст для компонентов
```
Агенты автоматически читают нужный контекст. Никакого ручного управления.
### Планирование. Prometheus
Сложная задача? Не нужно молиться и надеяться на промпт.
`/start-work` вызывает Prometheus. **Интервьюирует вас как настоящий инженер**, определяет объём работ и неоднозначности, формирует проверенный план до прикосновения к коду. Агент знает, что строит, прежде чем начать.
### Навыки
Навыки — это не просто промпты. Каждый привносит:
- Системные инструкции, настроенные под предметную область
- Встроенные MCP-серверы, запускаемые по необходимости
- Ограниченные разрешения. Агенты остаются в рамках
Встроенные: `playwright` (автоматизация браузера), `git-master` (атомарные коммиты, хирургия rebase), `frontend-ui-ux` (UI с упором на дизайн).
Добавьте свои: `.opencode/skills/*/SKILL.md` или `~/.config/opencode/skills/*/SKILL.md`.
**Хотите полное описание возможностей?** Смотрите **документацию по функциям** — агенты, хуки, инструменты, MCP и всё остальное подробно.
------
> **Впервые в oh-my-opencode?** Прочитайте **Обзор**, чтобы понять, что у вас есть, или ознакомьтесь с **руководством по оркестрации**, чтобы узнать, как агенты взаимодействуют.
## Удаление
Чтобы удалить oh-my-opencode:
1. **Удалите плагин из конфига OpenCode**
Отредактируйте `~/.config/opencode/opencode.json` (или `opencode.jsonc`) и уберите `"oh-my-opencode"` из массива `plugin`:
```bash
# С помощью jq
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **Удалите файлы конфигурации (опционально)**
```bash
# Удалить пользовательский конфиг
rm -f ~/.config/opencode/oh-my-opencode.json ~/.config/opencode/oh-my-opencode.jsonc
# Удалить конфиг проекта (если существует)
rm -f .opencode/oh-my-opencode.json .opencode/oh-my-opencode.jsonc
```
3. **Проверьте удаление**
```bash
opencode --version
# Плагин больше не должен загружаться
```
## Функции
Функции, которые, как вы будете думать, должны были существовать всегда. Попробовав раз, вы не сможете вернуться назад.
Смотрите полную документацию по функциям.
**Краткий обзор:**
- **Агенты**: Sisyphus (главный агент), Prometheus (планировщик), Oracle (архитектура/отладка), Librarian (документация/поиск по коду), Explore (быстрый grep по кодовой базе), Multimodal Looker
- **Фоновые агенты**: Запускайте несколько агентов параллельно, как настоящая команда разработки
- **Инструменты LSP и AST**: Рефакторинг, переименование, диагностика, поиск кода с учётом AST
- **Инструмент правок на основе хэш-якорей**: Ссылки `LINE#ID` проверяют содержимое перед применением каждого изменения. Хирургические правки, ноль ошибок с устаревшими строками
- **Инъекция контекста**: Автоматическое добавление AGENTS.md, README.md, условных правил
- **Совместимость с Claude Code**: Полная система хуков, команды, навыки, агенты, MCP
- **Встроенные MCP**: websearch (Exa), context7 (документация), grep_app (поиск по GitHub)
- **Инструменты сессий**: Список, чтение, поиск и анализ истории сессий
- **Инструменты продуктивности**: Ralph Loop, Todo Enforcer, Comment Checker, Think Mode и другое
- **Настройка моделей**: Сопоставление агент–модель встроено в руководство по установке
## Конфигурация
Продуманные настройки по умолчанию, которые можно изменить при необходимости.
Смотрите документацию по конфигурации.
**Краткий обзор:**
- **Расположение конфигов**: `.opencode/oh-my-opencode.jsonc` или `.opencode/oh-my-opencode.json` (проект), `~/.config/opencode/oh-my-opencode.jsonc` или `~/.config/opencode/oh-my-opencode.json` (пользователь)
- **Поддержка JSONC**: Комментарии и конечные запятые поддерживаются
- **Агенты**: Переопределение моделей, температур, промптов и разрешений для любого агента
- **Встроенные навыки**: `playwright` (автоматизация браузера), `git-master` (атомарные коммиты)
- **Агент Sisyphus**: Главный оркестратор с Prometheus (Планировщик) и Metis (Консультант по плану)
- **Фоновые задачи**: Настройка ограничений параллельности по провайдеру/модели
- **Категории**: Делегирование задач по предметной области (`visual`, `business-logic`, пользовательские)
- **Хуки**: 25+ встроенных хуков, все настраиваются через `disabled_hooks`
- **MCP**: Встроенные websearch (Exa), context7 (документация), grep_app (поиск по GitHub)
- **LSP**: Полная поддержка LSP с инструментами рефакторинга
- **Экспериментальное**: Агрессивное усечение, автовозобновление и другое
## Слово автора
**Хотите узнать философию?** Прочитайте Манифест Ultrawork.
------
Я потратил $24K на токены LLM в личных проектах. Попробовал все инструменты. Настраивал всё до смерти. OpenCode победил.
Каждая проблема, с которой я столкнулся, — её решение уже встроено в этот плагин. Устанавливайте и работайте.
Если OpenCode — это Debian/Arch, то OmO — это Ubuntu/[Omarchy](https://omarchy.org/).
Сильное влияние со стороны [AmpCode](https://ampcode.com) и [Claude Code](https://code.claude.com/docs/overview). Функции портированы, часто улучшены. Продолжаем строить. Это **Open**Code.
Другие обвязки обещают оркестрацию нескольких моделей. Мы её поставляем. Плюс стабильность. Плюс функции, которые реально работают.
Я самый одержимый пользователь этого проекта:
- Какая модель думает острее всего?
- Кто бог отладки?
- Кто пишет лучший код?
- Кто рулит фронтендом?
- Кто владеет бэкендом?
- Что быстрее всего в ежедневной работе?
- Что запускают конкуренты?
Этот плагин — дистилляция. Берём лучшее. Есть улучшения? PR приветствуются.
**Хватит мучиться с выбором обвязки.** **Я буду исследовать, воровать лучшее и поставлять это сюда.**
Звучит высокомерно? Знаете, как сделать лучше? Контрибьютьте. Добро пожаловать.
Никакой аффилиации с упомянутыми проектами/моделями. Только личные эксперименты.
99% этого проекта было создано с помощью OpenCode. Я почти не знаю TypeScript. **Но эту документацию я лично просматривал и во многом переписывал.**
## Любимый профессионалами из
- Indent
- Spray — решение для influencer-маркетинга, vovushop — платформа кросс-граничной торговли, vreview — AI-решение для маркетинга отзывов в commerce
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
- ELESTYLE
- elepay — мультимобильный платёжный шлюз, OneQR — мобильное SaaS-приложение для безналичных расчётов
*Особая благодарность [@junhoyeo](https://github.com/junhoyeo) за это потрясающее hero-изображение.*

View File

@@ -44,7 +44,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -97,7 +97,7 @@
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
或者你可以直接去读 [安装指南](docs/guide/installation.md),但说真的,让 Agent 去干吧。人类配环境总是容易敲错字母。
@@ -107,7 +107,7 @@ https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/
获取安装指南并照做:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
---

View File

@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"title": "Oh My OpenCode Configuration",
"description": "Configuration schema for oh-my-opencode plugin",
"type": "object",
@@ -24,19 +24,7 @@
"disabled_agents": {
"type": "array",
"items": {
"type": "string",
"enum": [
"sisyphus",
"hephaestus",
"prometheus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"metis",
"momus",
"atlas"
]
"type": "string"
}
},
"disabled_skills": {

62
benchmarks/bun.lock Normal file
View File

@@ -0,0 +1,62 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "hashline-edit-benchmark",
"dependencies": {
"@ai-sdk/openai": "^1.3.0",
"@friendliai/ai-provider": "^1.0.9",
"ai": "^6.0.94",
"zod": "^4.1.0",
},
},
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.55", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@1.3.24", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@friendliai/ai-provider": ["@friendliai/ai-provider@1.1.4", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.12" } }, "sha512-9TU4B1QFqPhbkONjI5afCF7Ox4jOqtGg1xw8mA9QHZdtlEbZxU+mBNvMPlI5pU5kPoN6s7wkXmFmxpID+own1A=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"ai": ["ai@6.0.101", "", { "dependencies": { "@ai-sdk/gateway": "3.0.55", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ur/NgbgOp1rdhyDiKDk6EOpSgd1g5ADlbcD1cjQJtQsnmhEngz3Rf8nK5JetDh0vnbLy2aEBpaQeL+zvLRWuaA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"@friendliai/ai-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@friendliai/ai-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
}
}

193
benchmarks/headless.ts Normal file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bun
import { readFile, writeFile, mkdir } from "node:fs/promises"
import { join, dirname } from "node:path"
import { stepCountIs, streamText, type CoreMessage } from "ai"
import { tool } from "ai"
import { createFriendli } from "@friendliai/ai-provider"
import { z } from "zod"
import { formatHashLines } from "../src/tools/hashline-edit/hash-computation"
import { normalizeHashlineEdits } from "../src/tools/hashline-edit/normalize-edits"
import { applyHashlineEditsWithReport } from "../src/tools/hashline-edit/edit-operations"
import { canonicalizeFileText, restoreFileText } from "../src/tools/hashline-edit/file-text-canonicalization"
const DEFAULT_MODEL = "MiniMaxAI/MiniMax-M2.5"
const MAX_STEPS = 50
const sessionId = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const emit = (event: Record<string, unknown>) =>
console.log(JSON.stringify({ sessionId, timestamp: new Date().toISOString(), ...event }))
// ── CLI ──────────────────────────────────────────────────────
function parseArgs(): { prompt: string; modelId: string } {
const args = process.argv.slice(2)
let prompt = ""
let modelId = DEFAULT_MODEL
for (let i = 0; i < args.length; i++) {
if ((args[i] === "-p" || args[i] === "--prompt") && args[i + 1]) {
prompt = args[++i]
} else if ((args[i] === "-m" || args[i] === "--model") && args[i + 1]) {
modelId = args[++i]
} else if (args[i] === "--reasoning-mode" && args[i + 1]) {
i++ // consume
}
// --no-translate, --think consumed silently
}
if (!prompt) {
console.error("Usage: bun run benchmarks/headless.ts -p <prompt> [-m <model>]")
process.exit(1)
}
return { prompt, modelId }
}
// ── Tools ────────────────────────────────────────────────────
const readFileTool = tool({
description: "Read a file with hashline-tagged content (LINE#ID format)",
inputSchema: z.object({ path: z.string().describe("File path") }),
execute: async ({ path }) => {
const fullPath = join(process.cwd(), path)
try {
const content = await readFile(fullPath, "utf-8")
const lines = content.split("\n")
const tagged = formatHashLines(content)
return `OK - read file\npath: ${path}\nlines: ${lines.length}\n\n${tagged}`
} catch {
return `Error: File not found: ${path}`
}
},
})
const editFileTool = tool({
description: "Edit a file using hashline anchors (LINE#ID format)",
inputSchema: z.object({
path: z.string(),
edits: z.array(
z.object({
op: z.enum(["replace", "append", "prepend"]),
pos: z.string().optional(),
end: z.string().optional(),
lines: z.union([z.array(z.string()), z.string(), z.null()]),
})
).min(1),
}),
execute: async ({ path, edits }) => {
const fullPath = join(process.cwd(), path)
try {
let rawContent = ""
let exists = true
try {
rawContent = await readFile(fullPath, "utf-8")
} catch {
exists = false
}
const normalized = normalizeHashlineEdits(edits)
if (!exists) {
const canCreate = normalized.every(
(e) => (e.op === "append" || e.op === "prepend") && !e.pos
)
if (!canCreate) return `Error: File not found: ${path}`
}
const envelope = canonicalizeFileText(rawContent)
const result = applyHashlineEditsWithReport(envelope.content, normalized)
if (result.content === envelope.content) {
return `Error: No changes made to ${path}. The edits produced identical content.`
}
const writeContent = restoreFileText(result.content, envelope)
await mkdir(dirname(fullPath), { recursive: true })
await writeFile(fullPath, writeContent, "utf-8")
const oldLineCount = rawContent.split("\n").length
const newLineCount = writeContent.split("\n").length
const delta = newLineCount - oldLineCount
const sign = delta > 0 ? "+" : ""
const action = exists ? "Updated" : "Created"
return `${action} ${path}\n${edits.length} edit(s) applied, ${sign}${delta} line(s)`
} catch (error) {
return `Error: ${error instanceof Error ? error.message : String(error)}`
}
},
})
// ── Agent Loop ───────────────────────────────────────────────
async function run() {
const { prompt, modelId } = parseArgs()
const friendli = createFriendli({ apiKey: process.env.FRIENDLI_TOKEN! })
const model = friendli(modelId)
const tools = { read_file: readFileTool, edit_file: editFileTool }
emit({ type: "user", content: prompt })
const messages: CoreMessage[] = [{ role: "user", content: prompt }]
const system =
"You are a code editing assistant. Use read_file to read files and edit_file to edit them. " +
"Always read a file before editing it to get fresh LINE#ID anchors."
for (let step = 0; step < MAX_STEPS; step++) {
const stream = streamText({
model,
tools,
messages,
system,
stopWhen: stepCountIs(1),
})
let currentText = ""
for await (const part of stream.fullStream) {
switch (part.type) {
case "text-delta":
currentText += part.text
break
case "tool-call":
emit({
type: "tool_call",
tool_call_id: part.toolCallId,
tool_name: part.toolName,
tool_input: part.args,
model: modelId,
})
break
case "tool-result": {
const output = typeof part.result === "string" ? part.result : JSON.stringify(part.result)
const isError = typeof output === "string" && output.startsWith("Error:")
emit({
type: "tool_result",
tool_call_id: part.toolCallId,
output,
...(isError ? { error: output } : {}),
})
break
}
}
const response = await stream.response
messages.push(...response.messages)
const finishReason = await stream.finishReason
if (finishReason !== "tool-calls") {
if (currentText.trim()) {
emit({ type: "assistant", content: currentText, model: modelId })
}
break
}
}
}
// ── Signal + Startup ─────────────────────────────────────────
process.once("SIGINT", () => process.exit(0))
process.once("SIGTERM", () => process.exit(143))
const startTime = Date.now()
run()
.catch((error) => {
emit({ type: "error", error: error instanceof Error ? error.message : String(error) })
process.exit(1)
})
.then(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
console.error(`[headless] Completed in ${elapsed}s`)
})

19
benchmarks/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "hashline-edit-benchmark",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Hashline edit tool benchmark using Vercel AI SDK with FriendliAI provider",
"scripts": {
"bench:basic": "bun run test-edit-ops.ts",
"bench:edge": "bun run test-edge-cases.ts",
"bench:multi": "bun run test-multi-model.ts",
"bench:all": "bun run bench:basic && bun run bench:edge"
},
"dependencies": {
"ai": "^6.0.94",
"@ai-sdk/openai": "^1.3.0",
"@friendliai/ai-provider": "^1.0.9",
"zod": "^4.1.0"
}
}

File diff suppressed because it is too large Load Diff

808
benchmarks/test-edit-ops.ts Normal file
View File

@@ -0,0 +1,808 @@
#!/usr/bin/env bun
/**
* Comprehensive headless edit_file stress test: 21 operation types
*
* Tests: 5 basic ops + 10 creative cases + 6 whitespace cases
* Each runs via headless mode with its own demo file + prompt.
*
* Usage:
* bun run scripts/test-headless-edit-ops.ts [-m <model>] [--provider <provider>]
*/
import { spawn } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
// ── CLI arg passthrough ───────────────────────────────────────
const extraArgs: string[] = [];
const rawArgs = process.argv.slice(2);
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i];
if (
(arg === "-m" || arg === "--model" || arg === "--provider") &&
i + 1 < rawArgs.length
) {
extraArgs.push(arg, rawArgs[i + 1]);
i++;
} else if (arg === "--think" || arg === "--no-translate") {
extraArgs.push(arg);
} else if (arg === "--reasoning-mode" && i + 1 < rawArgs.length) {
extraArgs.push(arg, rawArgs[i + 1]);
i++;
}
}
// ── Colors ────────────────────────────────────────────────────
const BOLD = "\x1b[1m";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const YELLOW = "\x1b[33m";
const DIM = "\x1b[2m";
const CYAN = "\x1b[36m";
const RESET = "\x1b[0m";
const pass = (msg: string) => console.log(` ${GREEN}${RESET} ${msg}`);
const fail = (msg: string) => console.log(` ${RED}${RESET} ${msg}`);
const info = (msg: string) => console.log(` ${DIM}${msg}${RESET}`);
const warn = (msg: string) => console.log(` ${YELLOW}${RESET} ${msg}`);
// ── Test case definition ─────────────────────────────────────
interface TestCase {
fileContent: string;
fileName: string;
name: string;
prompt: string;
validate: (content: string) => { passed: boolean; reason: string };
}
const TEST_CASES: TestCase[] = [
{
name: "1. Replace single line",
fileName: "config.txt",
fileContent: [
"host: localhost",
"port: 3000",
"debug: false",
"timeout: 30",
"retries: 3",
].join("\n"),
prompt: [
"Follow these steps exactly:",
"Step 1: Call read_file on config.txt.",
"Step 2: Note the anchor for the port line (line 2).",
"Step 3: Call edit_file with path='config.txt' and edits containing ONE object:",
" { op: 'replace', pos: '<line2 anchor>', lines: ['port: 8080'] }",
"IMPORTANT: pos must be ONLY the anchor (like '2#KB'). lines must be a SEPARATE array field with the new content.",
].join(" "),
validate: (content) => {
const has8080 = content.includes("port: 8080");
const has3000 = content.includes("port: 3000");
if (has8080 && !has3000) {
return { passed: true, reason: "port changed to 8080" };
}
if (has3000) {
return { passed: false, reason: "port still 3000 — edit not applied" };
}
return {
passed: false,
reason: `unexpected content: ${content.slice(0, 100)}`,
};
},
},
{
name: "2. Append after line",
fileName: "fruits.txt",
fileContent: ["apple", "banana", "cherry"].join("\n"),
prompt:
"Read fruits.txt with read_file. Then use edit_file with op='append' to insert a new line 'grape' after the 'banana' line. Use pos='LINE#HASH' of the banana line and lines=['grape'].",
validate: (content) => {
const lines = content.trim().split("\n");
const bananaIdx = lines.findIndex((l) => l.trim() === "banana");
const grapeIdx = lines.findIndex((l) => l.trim() === "grape");
if (grapeIdx === -1) {
return { passed: false, reason: '"grape" not found in file' };
}
if (bananaIdx === -1) {
return { passed: false, reason: '"banana" was removed' };
}
if (grapeIdx !== bananaIdx + 1) {
return {
passed: false,
reason: `"grape" at line ${grapeIdx + 1} but expected after "banana" at line ${bananaIdx + 1}`,
};
}
if (lines.length !== 4) {
return {
passed: false,
reason: `expected 4 lines, got ${lines.length}`,
};
}
return {
passed: true,
reason: '"grape" correctly appended after "banana"',
};
},
},
{
name: "3. Prepend before line",
fileName: "code.txt",
fileContent: ["function greet() {", ' return "hello";', "}"].join("\n"),
prompt:
"Read code.txt with read_file. Then use edit_file with op='prepend' to add '// Greeting function' before the function line. Use pos='LINE#HASH' of the function line and lines=['// Greeting function'].",
validate: (content) => {
const lines = content.trim().split("\n");
const commentIdx = lines.findIndex(
(l) => l.trim().startsWith("//") && l.toLowerCase().includes("greet")
);
const funcIdx = lines.findIndex((l) =>
l.trim().startsWith("function greet")
);
if (commentIdx === -1) {
return { passed: false, reason: "comment line not found" };
}
if (funcIdx === -1) {
return { passed: false, reason: '"function greet" line was removed' };
}
if (commentIdx !== funcIdx - 1) {
return {
passed: false,
reason: `comment at line ${commentIdx + 1} but function at ${funcIdx + 1} — not directly before`,
};
}
return {
passed: true,
reason: "comment correctly prepended before function",
};
},
},
{
name: "4. Range replace (multi-line → single line)",
fileName: "log.txt",
fileContent: [
"=== Log Start ===",
"INFO: started",
"WARN: slow query",
"ERROR: timeout",
"INFO: recovered",
"=== Log End ===",
].join("\n"),
prompt: [
"Follow these steps exactly:",
"Step 1: Call read_file on log.txt to see line anchors.",
"Step 2: Note the anchor for 'WARN: slow query' (line 3) and 'ERROR: timeout' (line 4).",
"Step 3: Call edit_file with path='log.txt' and edits containing ONE object with THREE separate JSON fields:",
" { op: 'replace', pos: '<line3 anchor>', end: '<line4 anchor>', lines: ['RESOLVED: issues cleared'] }",
"CRITICAL: pos, end, and lines are THREE SEPARATE JSON fields. pos is ONLY '3#XX'. end is ONLY '4#YY'. lines is ['RESOLVED: issues cleared'].",
"If edit_file fails or errors, use write_file to write the complete correct file content instead.",
"The correct final content should be: === Log Start ===, INFO: started, RESOLVED: issues cleared, INFO: recovered, === Log End ===",
"Do not make any other changes.",
].join(" "),
validate: (content) => {
const lines = content.trim().split("\n");
const hasResolved = lines.some(
(l) => l.trim() === "RESOLVED: issues cleared"
);
const hasWarn = content.includes("WARN: slow query");
const hasError = content.includes("ERROR: timeout");
if (!hasResolved) {
return {
passed: false,
reason: '"RESOLVED: issues cleared" not found',
};
}
if (hasWarn || hasError) {
return { passed: false, reason: "old WARN/ERROR lines still present" };
}
// Core assertion: 2 old lines removed, 1 new line added = net -1 line
// Allow slight overshoot from model adding extra content
if (lines.length < 4 || lines.length > 6) {
return {
passed: false,
reason: `expected ~5 lines, got ${lines.length}`,
};
}
return {
passed: true,
reason: "range replace succeeded — 2 lines → 1 line",
};
},
},
{
name: "5. Delete line",
fileName: "settings.txt",
fileContent: [
"mode: production",
"debug: true",
"cache: enabled",
"log_level: info",
].join("\n"),
prompt: [
"Follow these steps exactly:",
"Step 1: Call read_file on settings.txt to see line anchors.",
"Step 2: Note the anchor for 'debug: true' (line 2).",
"Step 3: Call edit_file with path='settings.txt' and edits containing ONE object:",
" { op: 'replace', pos: '<line2 anchor>', lines: [] }",
"IMPORTANT: lines must be an empty array [] to delete the line. pos must be ONLY the anchor like '2#SR'.",
].join(" "),
validate: (content) => {
const lines = content.trim().split("\n");
const hasDebug = content.includes("debug: true");
if (hasDebug) {
return { passed: false, reason: '"debug: true" still present' };
}
if (lines.length !== 3) {
return {
passed: false,
reason: `expected 3 lines, got ${lines.length}`,
};
}
if (
!(
content.includes("mode: production") &&
content.includes("cache: enabled")
)
) {
return { passed: false, reason: "other lines were removed" };
}
return { passed: true, reason: '"debug: true" successfully deleted' };
},
},
// ── Creative cases (6-15) ────────────────────────────────────
{
name: "6. Batch edit — two replacements in one call",
fileName: "batch.txt",
fileContent: ["red", "green", "blue", "yellow"].join("\n"),
prompt: [
"Read batch.txt with read_file.",
"Then call edit_file ONCE with path='batch.txt' and edits containing TWO objects:",
" 1) { op: 'replace', pos: '<line1 anchor>', lines: ['crimson'] }",
" 2) { op: 'replace', pos: '<line3 anchor>', lines: ['navy'] }",
"Both edits must be in the SAME edits array in a single edit_file call.",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (!c.includes("crimson")) return { passed: false, reason: "'crimson' not found" };
if (!c.includes("navy")) return { passed: false, reason: "'navy' not found" };
if (c.includes("red")) return { passed: false, reason: "'red' still present" };
if (c.includes("blue")) return { passed: false, reason: "'blue' still present" };
if (lines.length !== 4) return { passed: false, reason: `expected 4 lines, got ${lines.length}` };
return { passed: true, reason: "both lines replaced in single call" };
},
},
{
name: "7. Line expansion — 1 line → 3 lines",
fileName: "expand.txt",
fileContent: ["header", "TODO: implement", "footer"].join("\n"),
prompt: [
"Read expand.txt with read_file.",
"Replace the 'TODO: implement' line (line 2) with THREE lines:",
" 'step 1: init', 'step 2: process', 'step 3: cleanup'",
"Use edit_file with op='replace', pos=<line2 anchor>, lines=['step 1: init', 'step 2: process', 'step 3: cleanup'].",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (c.includes("TODO")) return { passed: false, reason: "TODO line still present" };
if (!c.includes("step 1: init")) return { passed: false, reason: "'step 1: init' not found" };
if (!c.includes("step 3: cleanup")) return { passed: false, reason: "'step 3: cleanup' not found" };
if (lines.length !== 5) return { passed: false, reason: `expected 5 lines, got ${lines.length}` };
return { passed: true, reason: "1 line expanded to 3 lines" };
},
},
{
name: "8. Append at EOF",
fileName: "eof.txt",
fileContent: ["line one", "line two"].join("\n"),
prompt: [
"Read eof.txt with read_file.",
"Use edit_file to append 'line three' after the LAST line of the file.",
"Use op='append', pos=<last line anchor>, lines=['line three'].",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (!c.includes("line three")) return { passed: false, reason: "'line three' not found" };
if (lines[lines.length - 1].trim() !== "line three")
return { passed: false, reason: "'line three' not at end" };
if (lines.length !== 3) return { passed: false, reason: `expected 3 lines, got ${lines.length}` };
return { passed: true, reason: "appended at EOF" };
},
},
{
name: "9. Special characters in content",
fileName: "special.json",
fileContent: [
'{',
' "name": "old-value",',
' "count": 42',
'}',
].join("\n"),
prompt: [
"Read special.json with read_file.",
'Replace the line containing \"name\": \"old-value\" with \"name\": \"new-value\".',
"Use edit_file with op='replace', pos=<that line's anchor>, lines=[' \"name\": \"new-value\",'].",
].join(" "),
validate: (c) => {
if (c.includes("old-value")) return { passed: false, reason: "'old-value' still present" };
if (!c.includes('"new-value"')) return { passed: false, reason: "'new-value' not found" };
if (!c.includes('"count": 42')) return { passed: false, reason: "other content was modified" };
return { passed: true, reason: "JSON value replaced with special chars intact" };
},
},
{
name: "10. Replace first line",
fileName: "first.txt",
fileContent: ["OLD HEADER", "body content", "footer"].join("\n"),
prompt: [
"Read first.txt with read_file.",
"Replace the very first line 'OLD HEADER' with 'NEW HEADER'.",
"Use edit_file with op='replace', pos=<line1 anchor>, lines=['NEW HEADER'].",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (c.includes("OLD HEADER")) return { passed: false, reason: "'OLD HEADER' still present" };
if (lines[0].trim() !== "NEW HEADER") return { passed: false, reason: "first line is not 'NEW HEADER'" };
if (!c.includes("body content")) return { passed: false, reason: "body was modified" };
return { passed: true, reason: "first line replaced" };
},
},
{
name: "11. Replace last line",
fileName: "last.txt",
fileContent: ["alpha", "bravo", "OLD_FOOTER"].join("\n"),
prompt: [
"Read last.txt with read_file.",
"Replace the last line 'OLD_FOOTER' with 'NEW_FOOTER'.",
"Use edit_file with op='replace', pos=<last line anchor>, lines=['NEW_FOOTER'].",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (c.includes("OLD_FOOTER")) return { passed: false, reason: "'OLD_FOOTER' still present" };
if (lines[lines.length - 1].trim() !== "NEW_FOOTER")
return { passed: false, reason: "last line is not 'NEW_FOOTER'" };
return { passed: true, reason: "last line replaced" };
},
},
{
name: "12. Adjacent line edits",
fileName: "adjacent.txt",
fileContent: ["aaa", "bbb", "ccc", "ddd"].join("\n"),
prompt: [
"Read adjacent.txt with read_file.",
"Replace line 2 ('bbb') with 'BBB' and line 3 ('ccc') with 'CCC'.",
"Use edit_file with TWO edits in the same call:",
" { op: 'replace', pos: <line2 anchor>, lines: ['BBB'] }",
" { op: 'replace', pos: <line3 anchor>, lines: ['CCC'] }",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (c.includes("bbb")) return { passed: false, reason: "'bbb' still present" };
if (c.includes("ccc")) return { passed: false, reason: "'ccc' still present" };
if (!c.includes("BBB")) return { passed: false, reason: "'BBB' not found" };
if (!c.includes("CCC")) return { passed: false, reason: "'CCC' not found" };
if (lines.length !== 4) return { passed: false, reason: `expected 4 lines, got ${lines.length}` };
return { passed: true, reason: "two adjacent lines replaced" };
},
},
{
name: "13. Prepend multi-line block",
fileName: "block.py",
fileContent: ["def main():", " print('hello')", "", "main()"].join("\n"),
prompt: [
"Read block.py with read_file.",
"Prepend a 2-line comment block before 'def main():' (line 1).",
"The two lines are: '# Author: test' and '# Date: 2025-01-01'.",
"Use edit_file with op='prepend', pos=<line1 anchor>, lines=['# Author: test', '# Date: 2025-01-01'].",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (!c.includes("# Author: test")) return { passed: false, reason: "author comment not found" };
if (!c.includes("# Date: 2025-01-01")) return { passed: false, reason: "date comment not found" };
const defIdx = lines.findIndex((l) => l.startsWith("def main"));
const authorIdx = lines.findIndex((l) => l.includes("Author"));
if (authorIdx >= defIdx) return { passed: false, reason: "comments not before def" };
return { passed: true, reason: "2-line block prepended before function" };
},
},
{
name: "14. Delete range — 3 consecutive lines",
fileName: "cleanup.txt",
fileContent: ["keep1", "remove-a", "remove-b", "remove-c", "keep2"].join("\n"),
prompt: [
"Read cleanup.txt with read_file.",
"Delete lines 2-4 ('remove-a', 'remove-b', 'remove-c') using a single range replace.",
"Use edit_file with op='replace', pos=<line2 anchor>, end=<line4 anchor>, lines=[].",
"An empty lines array deletes the range.",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (c.includes("remove")) return { passed: false, reason: "'remove' lines still present" };
if (!c.includes("keep1")) return { passed: false, reason: "'keep1' was deleted" };
if (!c.includes("keep2")) return { passed: false, reason: "'keep2' was deleted" };
if (lines.length !== 2) return { passed: false, reason: `expected 2 lines, got ${lines.length}` };
return { passed: true, reason: "3 consecutive lines deleted via range" };
},
},
{
name: "15. Replace with duplicate-content line",
fileName: "dupes.txt",
fileContent: ["item", "item", "item", "item"].join("\n"),
prompt: [
"Read dupes.txt with read_file. All 4 lines have the same text 'item'.",
"Replace ONLY line 3 with 'CHANGED'. Do NOT modify any other line.",
"Use edit_file with op='replace', pos=<line3 anchor>, lines=['CHANGED'].",
"The anchor hash uniquely identifies line 3 even though the content is identical.",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (!c.includes("CHANGED")) return { passed: false, reason: "'CHANGED' not found" };
const changedCount = lines.filter((l) => l.trim() === "CHANGED").length;
const itemCount = lines.filter((l) => l.trim() === "item").length;
if (changedCount !== 1) return { passed: false, reason: `expected 1 CHANGED, got ${changedCount}` };
if (itemCount !== 3) return { passed: false, reason: `expected 3 item lines, got ${itemCount}` };
if (lines.length !== 4) return { passed: false, reason: `expected 4 lines, got ${lines.length}` };
return { passed: true, reason: "only line 3 changed among duplicates" };
},
},
// ── Whitespace cases (16-21) ──────────────────────────────────
{
name: "16. Fix indentation — 2 spaces → 4 spaces",
fileName: "indent.js",
fileContent: ["function foo() {", " const x = 1;", " return x;", "}"].join("\n"),
prompt: [
"Read indent.js with read_file.",
"Replace line 2 ' const x = 1;' (2-space indent) with ' const x = 1;' (4-space indent).",
"Use edit_file with op='replace', pos=<line2 anchor>, lines=[' const x = 1;'].",
"The ONLY change is the indentation: 2 spaces → 4 spaces. Content stays the same.",
].join(" "),
validate: (c) => {
const lines = c.split("\n");
const line2 = lines[1];
if (!line2) return { passed: false, reason: "line 2 missing" };
if (line2 === " const x = 1;") return { passed: true, reason: "indentation fixed to 4 spaces" };
if (line2 === " const x = 1;") return { passed: false, reason: "still 2-space indent" };
return { passed: false, reason: `unexpected line 2: '${line2}'` };
},
},
{
name: "17. Replace preserving leading whitespace",
fileName: "preserve.py",
fileContent: [
"class Foo:",
" def old_method(self):",
" pass",
].join("\n"),
prompt: [
"Read preserve.py with read_file.",
"Replace line 2 ' def old_method(self):' with ' def new_method(self):'.",
"Keep the 4-space indentation. Only change the method name.",
"Use edit_file with op='replace', pos=<line2 anchor>, lines=[' def new_method(self):'].",
].join(" "),
validate: (c) => {
if (c.includes("old_method")) return { passed: false, reason: "'old_method' still present" };
const lines = c.split("\n");
const methodLine = lines.find((l) => l.includes("new_method"));
if (!methodLine) return { passed: false, reason: "'new_method' not found" };
if (!methodLine.startsWith(" ")) return { passed: false, reason: "indentation lost" };
return { passed: true, reason: "method renamed with indentation preserved" };
},
},
{
name: "18. Insert blank line between sections",
fileName: "sections.txt",
fileContent: ["[section-a]", "value-a=1", "[section-b]", "value-b=2"].join("\n"),
prompt: [
"Read sections.txt with read_file.",
"Insert a blank empty line between 'value-a=1' (line 2) and '[section-b]' (line 3).",
"Use edit_file with op='append', pos=<line2 anchor>, lines=[''].",
"lines=[''] inserts one empty line.",
].join(" "),
validate: (c) => {
const lines = c.split("\n");
const valAIdx = lines.findIndex((l) => l.includes("value-a=1"));
const secBIdx = lines.findIndex((l) => l.includes("[section-b]"));
if (valAIdx === -1) return { passed: false, reason: "'value-a=1' missing" };
if (secBIdx === -1) return { passed: false, reason: "'[section-b]' missing" };
if (secBIdx - valAIdx < 2) return { passed: false, reason: "no blank line between sections" };
const between = lines[valAIdx + 1];
if (between.trim() !== "") return { passed: false, reason: `line between is '${between}', not blank` };
return { passed: true, reason: "blank line inserted between sections" };
},
},
{
name: "19. Delete blank line",
fileName: "noblank.txt",
fileContent: ["first", "", "second", "third"].join("\n"),
prompt: [
"Read noblank.txt with read_file.",
"Delete the empty blank line (line 2). Use edit_file with op='replace', pos=<line2 anchor>, lines=[].",
].join(" "),
validate: (c) => {
const lines = c.trim().split("\n");
if (lines.length !== 3) return { passed: false, reason: `expected 3 lines, got ${lines.length}` };
if (lines[0].trim() !== "first") return { passed: false, reason: "'first' not on line 1" };
if (lines[1].trim() !== "second") return { passed: false, reason: "'second' not on line 2" };
return { passed: true, reason: "blank line deleted" };
},
},
{
name: "20. Tab → spaces conversion",
fileName: "tabs.txt",
fileContent: ["start", "\tindented-with-tab", "end"].join("\n"),
prompt: [
"Read tabs.txt with read_file.",
"Replace the tab-indented line 2 using edit_file with edits: [{ op: 'replace', pos: '<line2 anchor>', lines: [' indented-with-spaces'] }].",
"Expected final line 2 to be 4 spaces followed by indented-with-spaces.",
].join(" "),
validate: (c) => {
if (c.includes("\t")) return { passed: false, reason: "tab still present" };
if (!c.includes(" indented-with-spaces"))
return { passed: false, reason: "' indented-with-spaces' not found" };
if (!c.includes("start")) return { passed: false, reason: "'start' was modified" };
return { passed: true, reason: "tab converted to 4 spaces" };
},
},
{
name: "21. Deeply nested indent replacement",
fileName: "nested.ts",
fileContent: [
"if (a) {",
" if (b) {",
" if (c) {",
" old_call();",
" }",
" }",
"}",
].join("\n"),
prompt: [
"Read nested.ts with read_file.",
"Replace line 4 ' old_call();' with ' new_call();'.",
"Preserve the exact 6-space indentation. Only change the function name.",
"Use edit_file with op='replace', pos=<line4 anchor>, lines=[' new_call();'].",
].join(" "),
validate: (c) => {
if (c.includes("old_call")) return { passed: false, reason: "'old_call' still present" };
const lines = c.split("\n");
const callLine = lines.find((l) => l.includes("new_call"));
if (!callLine) return { passed: false, reason: "'new_call' not found" };
const leadingSpaces = callLine.match(/^ */)?.[0].length ?? 0;
if (leadingSpaces !== 6) return { passed: false, reason: `expected 6-space indent, got ${leadingSpaces}` };
return { passed: true, reason: "deeply nested line replaced with indent preserved" };
},
},
];
// ── JSONL event types ─────────────────────────────────────────
interface ToolCallEvent {
tool_call_id: string;
tool_input: Record<string, unknown>;
tool_name: string;
type: "tool_call";
}
interface ToolResultEvent {
error?: string;
output: string;
tool_call_id: string;
type: "tool_result";
}
interface AnyEvent {
type: string;
[key: string]: unknown;
}
// ── Run single test case ─────────────────────────────────────
async function runTestCase(
tc: TestCase,
testDir: string
): Promise<{
passed: boolean;
editCalls: number;
editSuccesses: number;
duration: number;
}> {
const testFile = join(testDir, tc.fileName);
writeFileSync(testFile, tc.fileContent, "utf-8");
const headlessScript = resolve(import.meta.dir, "headless.ts");
const headlessArgs = [
"run",
headlessScript,
"-p",
tc.prompt,
"--no-translate",
...extraArgs,
];
const startTime = Date.now();
const output = await new Promise<string>((res, reject) => {
const proc = spawn("bun", headlessArgs, {
cwd: testDir,
env: { ...process.env, BUN_INSTALL: process.env.BUN_INSTALL },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const timeout = setTimeout(
() => {
proc.kill("SIGTERM");
reject(new Error("Timed out after 4 minutes"));
},
4 * 60 * 1000
);
proc.on("close", (code) => {
clearTimeout(timeout);
if (code !== 0) {
reject(new Error(`Exit code ${code}\n${stderr.slice(-500)}`));
} else {
res(stdout);
}
});
proc.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
const duration = Date.now() - startTime;
// Parse events
const events: AnyEvent[] = [];
for (const line of output.split("\n").filter((l) => l.trim())) {
try {
events.push(JSON.parse(line) as AnyEvent);
} catch {
// skip non-JSON
}
}
const toolCalls = events.filter(
(e) => e.type === "tool_call"
) as unknown as ToolCallEvent[];
const toolResults = events.filter(
(e) => e.type === "tool_result"
) as unknown as ToolResultEvent[];
const editCalls = toolCalls.filter((e) => e.tool_name === "edit_file");
const editCallIds = new Set(editCalls.map((e) => e.tool_call_id));
const editResults = toolResults.filter((e) =>
editCallIds.has(e.tool_call_id)
);
const editSuccesses = editResults.filter((e) => !e.error);
// Show blocked calls
const editErrors = editResults.filter((e) => e.error);
for (const err of editErrors) {
const matchingCall = editCalls.find(
(c) => c.tool_call_id === err.tool_call_id
);
info(` blocked: ${err.error?.slice(0, 120)}`);
if (matchingCall) {
info(` input: ${JSON.stringify(matchingCall.tool_input).slice(0, 200)}`);
}
}
// Validate file content
let finalContent: string;
try {
finalContent = readFileSync(testFile, "utf-8");
} catch {
return {
passed: false,
editCalls: editCalls.length,
editSuccesses: editSuccesses.length,
duration,
};
}
const validation = tc.validate(finalContent);
return {
passed: validation.passed,
editCalls: editCalls.length,
editSuccesses: editSuccesses.length,
duration,
};
}
// ── Main ──────────────────────────────────────────────────────
const main = async () => {
console.log(`\n${BOLD}Headless Edit Operations Test — ${TEST_CASES.length} Types${RESET}\n`);
const testDir = join(tmpdir(), `edit-ops-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
info(`Test dir: ${testDir}`);
console.log();
let totalPassed = 0;
const results: { name: string; passed: boolean; detail: string }[] = [];
for (const tc of TEST_CASES) {
console.log(`${CYAN}${BOLD}${tc.name}${RESET}`);
info(`File: ${tc.fileName}`);
info(`Prompt: "${tc.prompt.slice(0, 80)}..."`);
try {
const result = await runTestCase(tc, testDir);
const status = result.passed
? `${GREEN}PASS${RESET}`
: `${RED}FAIL${RESET}`;
const detail = `edit_file: ${result.editSuccesses}/${result.editCalls} succeeded, ${(result.duration / 1000).toFixed(1)}s`;
console.log(` ${status}${detail}`);
if (result.passed) {
totalPassed++;
// Validate the file to show reason
const content = readFileSync(join(testDir, tc.fileName), "utf-8");
const v = tc.validate(content);
pass(v.reason);
} else {
const content = readFileSync(join(testDir, tc.fileName), "utf-8");
const v = tc.validate(content);
fail(v.reason);
info(
`Final content:\n${content
.split("\n")
.map((l, i) => ` ${i + 1}: ${l}`)
.join("\n")}`
);
}
results.push({ name: tc.name, passed: result.passed, detail });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(` ${RED}ERROR${RESET}${msg.slice(0, 200)}`);
fail(msg.slice(0, 200));
results.push({ name: tc.name, passed: false, detail: msg.slice(0, 100) });
}
// Reset file for next test (in case of side effects)
try {
rmSync(join(testDir, tc.fileName), { force: true });
} catch {}
console.log();
}
// Summary
console.log(`${BOLD}━━━ Summary ━━━${RESET}`);
for (const r of results) {
const icon = r.passed ? `${GREEN}${RESET}` : `${RED}${RESET}`;
console.log(` ${icon} ${r.name}${r.detail}`);
}
console.log();
console.log(
`${BOLD}Result: ${totalPassed}/${TEST_CASES.length} passed (${Math.round((totalPassed / TEST_CASES.length) * 100)}%)${RESET}`
);
// Cleanup
try {
rmSync(testDir, { recursive: true, force: true });
} catch {}
if (totalPassed === TEST_CASES.length) {
console.log(
`\n${BOLD}${GREEN}🎉 ALL TESTS PASSED — 100% success rate!${RESET}\n`
);
process.exit(0);
} else {
console.log(`\n${BOLD}${RED}Some tests failed.${RESET}\n`);
process.exit(1);
}
};
main();

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env bun
/**
* Multi-model edit_file test runner
*
* Runs test-headless-edit-ops.ts against every available model
* and produces a summary table.
*
* Usage:
* bun run scripts/test-multi-model-edit.ts [--timeout <seconds>]
*/
import { spawn } from "node:child_process";
import { resolve } from "node:path";
// ── Models ────────────────────────────────────────────────────
const MODELS = [
{ id: "MiniMaxAI/MiniMax-M2.5", short: "M2.5" },
// { id: "MiniMaxAI/MiniMax-M2.1", short: "M2.1" }, // masked: slow + timeout-prone
// { id: "zai-org/GLM-5", short: "GLM-5" }, // masked: API 503
{ id: "zai-org/GLM-4.7", short: "GLM-4.7" },
];
// ── CLI args ──────────────────────────────────────────────────
let perModelTimeoutSec = 900; // 15 min default per model (5 tests)
const rawArgs = process.argv.slice(2);
for (let i = 0; i < rawArgs.length; i++) {
if (rawArgs[i] === "--timeout" && i + 1 < rawArgs.length) {
const parsed = Number.parseInt(rawArgs[i + 1], 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error(`Invalid --timeout value: ${rawArgs[i + 1]}`);
process.exit(1);
}
perModelTimeoutSec = parsed;
i++;
}
// ── Colors ────────────────────────────────────────────────────
const BOLD = "\x1b[1m";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const YELLOW = "\x1b[33m";
const DIM = "\x1b[2m";
const CYAN = "\x1b[36m";
const RESET = "\x1b[0m";
// ── Types ─────────────────────────────────────────────────────
interface TestResult {
detail: string;
name: string;
passed: boolean;
}
interface ModelResult {
durationMs: number;
error?: string;
modelId: string;
modelShort: string;
tests: TestResult[];
totalPassed: number;
totalTests: number;
}
// ── Parse test-headless-edit-ops stdout ───────────────────────
function parseOpsOutput(stdout: string): TestResult[] {
const results: TestResult[] = [];
// Match lines like: " PASS — edit_file: 1/1 succeeded, 32.5s"
// or " FAIL — edit_file: 0/3 succeeded, 15.2s"
// or " ERROR — Timed out after 10 minutes"
// Following a line like: "1. Replace single line"
const lines = stdout.split("\n");
let currentTestName = "";
for (const line of lines) {
// Detect test name: starts with ANSI-colored bold cyan + "N. Name"
// Strip ANSI codes for matching
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
// Test name pattern: "N. <name>"
const testNameMatch = stripped.match(/^\s*(\d+\.\s+.+)$/);
if (
testNameMatch &&
!stripped.includes("—") &&
!stripped.includes("✓") &&
!stripped.includes("✗")
) {
currentTestName = testNameMatch[1].trim();
continue;
}
// Result line: PASS/FAIL/ERROR
if (currentTestName && stripped.includes("PASS")) {
const detail = stripped.replace(/^\s*PASS\s*—?\s*/, "").trim();
results.push({
name: currentTestName,
passed: true,
detail: detail || "passed",
});
currentTestName = "";
} else if (currentTestName && stripped.includes("FAIL")) {
const detail = stripped.replace(/^\s*FAIL\s*—?\s*/, "").trim();
results.push({
name: currentTestName,
passed: false,
detail: detail || "failed",
});
currentTestName = "";
} else if (currentTestName && stripped.includes("ERROR")) {
const detail = stripped.replace(/^\s*ERROR\s*—?\s*/, "").trim();
results.push({
name: currentTestName,
passed: false,
detail: detail || "error",
});
currentTestName = "";
}
}
return results;
}
// ── Run one model ────────────────────────────────────────────
async function runModel(model: {
id: string;
short: string;
}): Promise<ModelResult> {
const opsScript = resolve(import.meta.dir, "test-edit-ops.ts");
const startTime = Date.now();
return new Promise<ModelResult>((resolvePromise) => {
const proc = spawn(
"bun",
["run", opsScript, "-m", model.id, "--no-translate"],
{
cwd: resolve(import.meta.dir),
env: { ...process.env, BUN_INSTALL: process.env.BUN_INSTALL },
stdio: ["ignore", "pipe", "pipe"],
}
);
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const timeout = setTimeout(() => {
proc.kill("SIGTERM");
resolvePromise({
modelId: model.id,
modelShort: model.short,
tests: [],
totalPassed: 0,
totalTests: 0,
durationMs: Date.now() - startTime,
error: `Timed out after ${perModelTimeoutSec}s`,
});
}, perModelTimeoutSec * 1000);
proc.on("close", () => {
clearTimeout(timeout);
const tests = parseOpsOutput(stdout);
const totalPassed = tests.filter((t) => t.passed).length;
resolvePromise({
modelId: model.id,
modelShort: model.short,
tests,
totalPassed,
totalTests: Math.max(tests.length, 5),
durationMs: Date.now() - startTime,
});
});
proc.on("error", (err) => {
clearTimeout(timeout);
resolvePromise({
modelId: model.id,
modelShort: model.short,
tests: [],
totalPassed: 0,
totalTests: 0,
durationMs: Date.now() - startTime,
error: err.message,
});
});
});
}
// ── Main ──────────────────────────────────────────────────────
const main = async () => {
console.log(`\n${BOLD}═══ Multi-Model edit_file Test Runner ═══${RESET}\n`);
console.log(`${DIM}Models: ${MODELS.map((m) => m.short).join(", ")}${RESET}`);
console.log(`${DIM}Timeout: ${perModelTimeoutSec}s per model${RESET}`);
console.log();
const allResults: ModelResult[] = [];
for (const model of MODELS) {
console.log(`${CYAN}${BOLD}▶ Testing ${model.short} (${model.id})${RESET}`);
const result = await runModel(model);
allResults.push(result);
const timeStr = `${(result.durationMs / 1000).toFixed(1)}s`;
if (result.error) {
console.log(` ${RED}ERROR${RESET}: ${result.error} (${timeStr})`);
} else {
const color =
result.totalPassed === result.totalTests
? GREEN
: result.totalPassed > 0
? YELLOW
: RED;
console.log(
` ${color}${result.totalPassed}/${result.totalTests} passed${RESET} (${timeStr})`
);
for (const t of result.tests) {
const icon = t.passed ? `${GREEN}${RESET}` : `${RED}${RESET}`;
console.log(` ${icon} ${t.name}`);
}
}
console.log();
}
// ── Summary Table ──────────────────────────────────────────
console.log(`${BOLD}═══ Summary ═══${RESET}\n`);
// Per-model results
for (const r of allResults) {
const timeStr = `${(r.durationMs / 1000).toFixed(0)}s`;
const color = r.error ? RED : r.totalPassed === r.totalTests ? GREEN : r.totalPassed > 0 ? YELLOW : RED;
const label = r.error ? `ERROR: ${r.error}` : `${r.totalPassed}/${r.totalTests}`;
console.log(` ${r.modelShort.padEnd(8)} ${color}${label}${RESET} (${timeStr})`);
for (const t of r.tests) {
const icon = t.passed ? `${GREEN}${RESET}` : `${RED}${RESET}`;
console.log(` ${icon} ${t.name}`);
}
}
console.log();
// Overall
const totalModels = allResults.length;
const erroredModels = allResults.filter((r) => r.error).length;
const perfectModels = allResults.filter(
(r) => !r.error && r.totalPassed === r.totalTests && r.totalTests > 0
).length;
console.log(
`${BOLD}Models with 100%: ${perfectModels}/${totalModels}${RESET}`
);
const overallPassed = allResults.reduce((sum, r) => sum + r.totalPassed, 0);
const overallTotal = allResults.reduce((sum, r) => sum + r.totalTests, 0);
console.log(
`${BOLD}Overall: ${overallPassed}/${overallTotal} (${Math.round((overallPassed / overallTotal) * 100)}%)${RESET}`
);
console.log();
if (erroredModels > 0) {
console.log(
`${BOLD}${RED}${erroredModels} model(s) errored. See details above.${RESET}\n`
);
process.exit(1);
} else if (perfectModels === totalModels) {
console.log(`${BOLD}${GREEN}🎉 ALL MODELS PASSED ALL TESTS!${RESET}\n`);
process.exit(0);
} else {
console.log(
`${BOLD}${YELLOW}Some models have failures. See details above.${RESET}\n`
);
process.exit(1);
}
};
main();

View File

@@ -3,8 +3,9 @@
// Wrapper script that detects platform and spawns the correct binary
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { getPlatformPackage, getBinaryPath } from "./platform.js";
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
const require = createRequire(import.meta.url);
@@ -26,55 +27,116 @@ function getLibcFamily() {
}
}
function supportsAvx2() {
if (process.arch !== "x64") {
return null;
}
if (process.env.OH_MY_OPENCODE_FORCE_BASELINE === "1") {
return false;
}
if (process.platform === "linux") {
try {
const cpuInfo = readFileSync("/proc/cpuinfo", "utf8").toLowerCase();
return cpuInfo.includes("avx2");
} catch {
return null;
}
}
if (process.platform === "darwin") {
const probe = spawnSync("sysctl", ["-n", "machdep.cpu.leaf7_features"], {
encoding: "utf8",
});
if (probe.error || probe.status !== 0) {
return null;
}
return probe.stdout.toUpperCase().includes("AVX2");
}
return null;
}
function getSignalExitCode(signal) {
const signalCodeByName = {
SIGINT: 2,
SIGILL: 4,
SIGKILL: 9,
SIGTERM: 15,
};
return 128 + (signalCodeByName[signal] ?? 1);
}
function main() {
const { platform, arch } = process;
const libcFamily = getLibcFamily();
const avx2Supported = supportsAvx2();
// Get platform package name
let pkg;
let packageCandidates;
try {
pkg = getPlatformPackage({ platform, arch, libcFamily });
packageCandidates = getPlatformPackageCandidates({
platform,
arch,
libcFamily,
preferBaseline: avx2Supported === false,
});
} catch (error) {
console.error(`\noh-my-opencode: ${error.message}\n`);
process.exit(1);
}
// Resolve binary path
const binRelPath = getBinaryPath(pkg, platform);
let binPath;
try {
binPath = require.resolve(binRelPath);
} catch {
const resolvedBinaries = packageCandidates
.map((pkg) => {
try {
return { pkg, binPath: require.resolve(getBinaryPath(pkg, platform)) };
} catch {
return null;
}
})
.filter((entry) => entry !== null);
if (resolvedBinaries.length === 0) {
console.error(`\noh-my-opencode: Platform binary not installed.`);
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
console.error(`Expected package: ${pkg}`);
console.error(`Expected packages (in order): ${packageCandidates.join(", ")}`);
console.error(`\nTo fix, run:`);
console.error(` npm install ${pkg}\n`);
console.error(` npm install ${packageCandidates[0]}\n`);
process.exit(1);
}
// Spawn the binary
const result = spawnSync(binPath, process.argv.slice(2), {
stdio: "inherit",
});
// Handle spawn errors
if (result.error) {
console.error(`\noh-my-opencode: Failed to execute binary.`);
console.error(`Error: ${result.error.message}\n`);
process.exit(2);
}
// Handle signals
if (result.signal) {
const signalNum = result.signal === "SIGTERM" ? 15 :
result.signal === "SIGKILL" ? 9 :
result.signal === "SIGINT" ? 2 : 1;
process.exit(128 + signalNum);
for (let index = 0; index < resolvedBinaries.length; index += 1) {
const currentBinary = resolvedBinaries[index];
const hasFallback = index < resolvedBinaries.length - 1;
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
stdio: "inherit",
});
if (result.error) {
if (hasFallback) {
continue;
}
console.error(`\noh-my-opencode: Failed to execute binary.`);
console.error(`Error: ${result.error.message}\n`);
process.exit(2);
}
if (result.signal === "SIGILL" && hasFallback) {
continue;
}
if (result.signal) {
process.exit(getSignalExitCode(result.signal));
}
process.exit(result.status ?? 1);
}
process.exit(result.status ?? 1);
process.exit(1);
}
main();

14
bin/platform.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
export declare function getPlatformPackage(options: {
platform: string;
arch: string;
libcFamily?: string | null;
}): string;
export declare function getPlatformPackageCandidates(options: {
platform: string;
arch: string;
libcFamily?: string | null;
preferBaseline?: boolean;
}): string[];
export declare function getBinaryPath(pkg: string, platform: string): string;

View File

@@ -26,6 +26,50 @@ export function getPlatformPackage({ platform, arch, libcFamily }) {
return `oh-my-opencode-${os}-${arch}${suffix}`;
}
/** @param {{ platform: string, arch: string, libcFamily?: string | null, preferBaseline?: boolean }} options */
export function getPlatformPackageCandidates({ platform, arch, libcFamily, preferBaseline = false }) {
const primaryPackage = getPlatformPackage({ platform, arch, libcFamily });
const baselinePackage = getBaselinePlatformPackage({ platform, arch, libcFamily });
if (!baselinePackage) {
return [primaryPackage];
}
return preferBaseline ? [baselinePackage, primaryPackage] : [primaryPackage, baselinePackage];
}
/** @param {{ platform: string, arch: string, libcFamily?: string | null }} options */
function getBaselinePlatformPackage({ platform, arch, libcFamily }) {
if (arch !== "x64") {
return null;
}
if (platform === "darwin") {
return "oh-my-opencode-darwin-x64-baseline";
}
if (platform === "win32") {
return "oh-my-opencode-windows-x64-baseline";
}
if (platform === "linux") {
if (libcFamily === null || libcFamily === undefined) {
throw new Error(
"Could not detect libc on Linux. " +
"Please ensure detect-libc is installed or report this issue."
);
}
if (libcFamily === "musl") {
return "oh-my-opencode-linux-x64-musl-baseline";
}
return "oh-my-opencode-linux-x64-baseline";
}
return null;
}
/**
* Get the path to the binary within a platform package
* @param {string} pkg Package name

View File

@@ -1,6 +1,6 @@
// bin/platform.test.ts
import { describe, expect, test } from "bun:test";
import { getPlatformPackage, getBinaryPath } from "./platform.js";
import { getBinaryPath, getPlatformPackage, getPlatformPackageCandidates } from "./platform.js";
describe("getPlatformPackage", () => {
// #region Darwin platforms
@@ -146,3 +146,58 @@ describe("getBinaryPath", () => {
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
});
});
describe("getPlatformPackageCandidates", () => {
test("returns x64 and baseline candidates for Linux glibc", () => {
// #given Linux x64 with glibc
const input = { platform: "linux", arch: "x64", libcFamily: "glibc" };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then returns modern first then baseline fallback
expect(result).toEqual([
"oh-my-opencode-linux-x64",
"oh-my-opencode-linux-x64-baseline",
]);
});
test("returns x64 musl and baseline candidates for Linux musl", () => {
// #given Linux x64 with musl
const input = { platform: "linux", arch: "x64", libcFamily: "musl" };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then returns musl modern first then musl baseline fallback
expect(result).toEqual([
"oh-my-opencode-linux-x64-musl",
"oh-my-opencode-linux-x64-musl-baseline",
]);
});
test("returns baseline first when preferBaseline is true", () => {
// #given Windows x64 and baseline preference
const input = { platform: "win32", arch: "x64", preferBaseline: true };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then baseline package is preferred first
expect(result).toEqual([
"oh-my-opencode-windows-x64-baseline",
"oh-my-opencode-windows-x64",
]);
});
test("returns only one candidate for ARM64", () => {
// #given non-x64 platform
const input = { platform: "linux", arch: "arm64", libcFamily: "glibc" };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then baseline fallback is not included
expect(result).toEqual(["oh-my-opencode-linux-arm64"]);
});
});

View File

@@ -162,7 +162,7 @@ See the [Orchestration System Guide](./orchestration.md) for how agents dispatch
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
// Main orchestrator: Claude Opus or Kimi K2.5 work best

View File

@@ -5,7 +5,7 @@
Paste this into your llm agent session:
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
**Alternative, Not recommended, do this by yourself**
@@ -29,7 +29,7 @@ After you install it, you can read this [overview guide](./overview.md) to under
> **IMPORTANT: Use `curl` to fetch this file, NOT WebFetch.** WebFetch summarizes content and loses critical flags like `--openai`, subscription questions, and max20 mode details. Always use:
> ```bash
> curl -fsSL https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
> curl -fsSL https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
> ```
If you're an LLM Agent helping set up oh-my-opencode, Welcome! You'll now help your user to install the best agent harness, OhMyOpenCode. Tell user this with greetings, with test 'oMoMoMoMo...'

View File

@@ -16,7 +16,7 @@ Paste this into your LLM agent session:
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/docs/guide/installation.md
```
Or read the full [Installation Guide](./installation.md) for manual setup, provider authentication, and troubleshooting.
@@ -167,7 +167,7 @@ You can override specific agents or categories in your config:
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
// Main orchestrator: Claude Opus or Kimi K2.5 work best

View File

@@ -56,7 +56,7 @@ JSONC supports `// line comments`, `/* block comments */`, and trailing commas.
Enable schema autocomplete:
```json
{ "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json" }
{ "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json" }
```
Run `bunx oh-my-opencode install` for guided setup. Run `opencode models` to list available models.
@@ -67,7 +67,7 @@ Here's a practical starting configuration:
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
// Main orchestrator: Claude Opus or Kimi K2.5 work best
@@ -573,13 +573,13 @@ Define `fallback_models` per agent or category:
### Hashline Edit
Replaces the built-in `Edit` tool with a hash-anchored version using `LINE#ID` references to prevent stale-line edits. Enabled by default.
Replaces the built-in `Edit` tool with a hash-anchored version using `LINE#ID` references to prevent stale-line edits. Disabled by default.
```json
{ "hashline_edit": false }
{ "hashline_edit": true }
```
When enabled, two companion hooks are active: `hashline-read-enhancer` (annotates Read output) and `hashline-edit-diff-enhancer` (shows diffs). Disable them individually via `disabled_hooks`.
When enabled, two companion hooks are active: `hashline-read-enhancer` (annotates Read output) and `hashline-edit-diff-enhancer` (shows diffs). Opt-in by setting `hashline_edit: true`. Disable the companion hooks individually via `disabled_hooks` if needed.
### Experimental

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.5",
"version": "3.10.0",
"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",
@@ -75,13 +75,17 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.5",
"oh-my-opencode-darwin-x64": "3.8.5",
"oh-my-opencode-linux-arm64": "3.8.5",
"oh-my-opencode-linux-arm64-musl": "3.8.5",
"oh-my-opencode-linux-x64": "3.8.5",
"oh-my-opencode-linux-x64-musl": "3.8.5",
"oh-my-opencode-windows-x64": "3.8.5"
"oh-my-opencode-darwin-arm64": "3.10.0",
"oh-my-opencode-darwin-x64": "3.10.0",
"oh-my-opencode-darwin-x64-baseline": "3.10.0",
"oh-my-opencode-linux-arm64": "3.10.0",
"oh-my-opencode-linux-arm64-musl": "3.10.0",
"oh-my-opencode-linux-x64": "3.10.0",
"oh-my-opencode-linux-x64-baseline": "3.10.0",
"oh-my-opencode-linux-x64-musl": "3.10.0",
"oh-my-opencode-linux-x64-musl-baseline": "3.10.0",
"oh-my-opencode-windows-x64": "3.10.0",
"oh-my-opencode-windows-x64-baseline": "3.10.0"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.5",
"version": "3.10.0",
"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-baseline",
"version": "3.1.1",
"version": "3.10.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.8.5",
"version": "3.10.0",
"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.5",
"version": "3.10.0",
"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.5",
"version": "3.10.0",
"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-baseline",
"version": "3.1.1",
"version": "3.10.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl-baseline",
"version": "3.1.1",
"version": "3.10.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.8.5",
"version": "3.10.0",
"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.5",
"version": "3.10.0",
"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-baseline",
"version": "3.1.1",
"version": "3.10.0",
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
"license": "MIT",
"repository": {

View File

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

View File

@@ -2,7 +2,7 @@
// Runs after npm install to verify platform binary is available
import { createRequire } from "node:module";
import { getPlatformPackage, getBinaryPath } from "./bin/platform.js";
import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
const require = createRequire(import.meta.url);
@@ -27,12 +27,28 @@ function main() {
const libcFamily = getLibcFamily();
try {
const pkg = getPlatformPackage({ platform, arch, libcFamily });
const binPath = getBinaryPath(pkg, platform);
// Try to resolve the binary
require.resolve(binPath);
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
const packageCandidates = getPlatformPackageCandidates({
platform,
arch,
libcFamily,
});
const resolvedPackage = packageCandidates.find((pkg) => {
try {
require.resolve(getBinaryPath(pkg, platform));
return true;
} catch {
return false;
}
});
if (!resolvedPackage) {
throw new Error(
`No platform binary package installed. Tried: ${packageCandidates.join(", ")}`
);
}
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch} (${resolvedPackage})`);
} catch (error) {
console.warn(`⚠ oh-my-opencode: ${error.message}`);
console.warn(` The CLI may not work on this platform.`);

View File

@@ -9,7 +9,7 @@ export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
return {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
title: "Oh My OpenCode Configuration",
description: "Configuration schema for oh-my-opencode plugin",
...jsonSchema,

View File

@@ -1767,6 +1767,78 @@
"created_at": "2026-02-26T09:50:46Z",
"repoId": 1108837393,
"pullRequestNo": 2131
},
{
"name": "dwnmf",
"id": 56194792,
"comment_id": 3969700423,
"created_at": "2026-02-26T22:51:41Z",
"repoId": 1108837393,
"pullRequestNo": 2160
},
{
"name": "1noilimrev",
"id": 24486928,
"comment_id": 3970957470,
"created_at": "2026-02-27T05:53:36Z",
"repoId": 1108837393,
"pullRequestNo": 2166
},
{
"name": "YLRong",
"id": 6837942,
"comment_id": 3971635504,
"created_at": "2026-02-27T08:54:09Z",
"repoId": 1108837393,
"pullRequestNo": 2176
},
{
"name": "mertyldrm",
"id": 51949702,
"comment_id": 3972191343,
"created_at": "2026-02-27T10:53:03Z",
"repoId": 1108837393,
"pullRequestNo": 2184
},
{
"name": "renanale",
"id": 37278838,
"comment_id": 3975562407,
"created_at": "2026-02-27T22:38:18Z",
"repoId": 1108837393,
"pullRequestNo": 2201
},
{
"name": "laciferin2024",
"id": 170102251,
"comment_id": 3978786169,
"created_at": "2026-03-01T01:16:25Z",
"repoId": 1108837393,
"pullRequestNo": 2222
},
{
"name": "DEAN-Cherry",
"id": 76607677,
"comment_id": 3979468463,
"created_at": "2026-03-01T08:13:43Z",
"repoId": 1108837393,
"pullRequestNo": 2227
},
{
"name": "Chocothin",
"id": 99174213,
"comment_id": 3980002001,
"created_at": "2026-03-01T13:52:10Z",
"repoId": 1108837393,
"pullRequestNo": 2230
},
{
"name": "mathew-cf",
"id": 68972715,
"comment_id": 3980951159,
"created_at": "2026-03-01T20:19:31Z",
"repoId": 1108837393,
"pullRequestNo": 2233
}
]
}

View File

@@ -29,7 +29,7 @@ import {
buildDecisionMatrix,
} from "./prompt-section-builder"
const MODE: AgentMode = "primary"
const MODE: AgentMode = "all"
export type AtlasPromptSource = "default" | "gpt" | "gemini"

View File

@@ -4,6 +4,8 @@ import { describe, it, expect } from "bun:test"
import {
buildCategorySkillsDelegationGuide,
buildUltraworkSection,
buildDeepParallelSection,
buildNonClaudePlannerSection,
type AvailableSkill,
type AvailableCategory,
type AvailableAgent,
@@ -172,4 +174,86 @@ describe("buildUltraworkSection", () => {
})
})
describe("buildDeepParallelSection", () => {
const deepCategory: AvailableCategory = { name: "deep", description: "Autonomous problem-solving" }
const otherCategory: AvailableCategory = { name: "quick", description: "Trivial tasks" }
it("#given non-Claude model with deep category #when building #then returns parallel delegation section", () => {
//#given
const model = "google/gemini-3-pro"
const categories = [deepCategory, otherCategory]
//#when
const result = buildDeepParallelSection(model, categories)
//#then
expect(result).toContain("Deep Parallel Delegation")
expect(result).toContain("EVERY independent unit")
expect(result).toContain("run_in_background=true")
expect(result).toContain("4 independent units")
})
it("#given Claude model #when building #then returns empty", () => {
//#given
const model = "anthropic/claude-opus-4-6"
const categories = [deepCategory]
//#when
const result = buildDeepParallelSection(model, categories)
//#then
expect(result).toBe("")
})
it("#given non-Claude model without deep category #when building #then returns empty", () => {
//#given
const model = "openai/gpt-5.2"
const categories = [otherCategory]
//#when
const result = buildDeepParallelSection(model, categories)
//#then
expect(result).toBe("")
})
})
describe("buildNonClaudePlannerSection", () => {
it("#given non-Claude model #when building #then returns plan agent section", () => {
//#given
const model = "google/gemini-3-pro"
//#when
const result = buildNonClaudePlannerSection(model)
//#then
expect(result).toContain("Plan Agent")
expect(result).toContain("session_id")
expect(result).toContain("Multi-step")
})
it("#given Claude model #when building #then returns empty", () => {
//#given
const model = "anthropic/claude-sonnet-4-6"
//#when
const result = buildNonClaudePlannerSection(model)
//#then
expect(result).toBe("")
})
it("#given GPT model #when building #then returns plan agent section", () => {
//#given
const model = "openai/gpt-5.2"
//#when
const result = buildNonClaudePlannerSection(model)
//#then
expect(result).toContain("Plan Agent")
expect(result).not.toBe("")
})
})

View File

@@ -277,12 +277,11 @@ Briefly announce "Consulting Oracle for [reason]" before invocation.
### Oracle Background Task Policy:
**You MUST collect Oracle results before your final answer. No exceptions.**
**Collect Oracle results before your final answer. No exceptions.**
- Oracle may take several minutes. This is normal and expected.
- When Oracle is running and you finish your own exploration/analysis, your next action is \`background_output(task_id="...")\` on Oracle — NOT delivering a final answer.
- Oracle catches blind spots you cannot see — its value is HIGHEST when you think you don't need it.
- **NEVER** cancel Oracle. **NEVER** use \`background_cancel(all=true)\` when Oracle is running. Cancel disposable tasks (explore, librarian) individually by taskId instead.
- Oracle takes minutes. When done with your own work: **end your response** — wait for the \`<system-reminder>\`.
- Do NOT poll \`background_output\` on a running Oracle. The notification will come.
- Never cancel Oracle.
</Oracle_Usage>`
}
@@ -292,8 +291,8 @@ export function buildHardBlocksSection(): string {
"- Commit without explicit request — **Never**",
"- Speculate about unread code — **Never**",
"- Leave code in broken state after failures — **Never**",
"- `background_cancel(all=true)` when Oracle is running — **Never.** Cancel tasks individually by taskId.",
"- Delivering final answer before collecting Oracle result — **Never.** Always `background_output` Oracle first.",
"- `background_cancel(all=true)` — **Never.** Always cancel individually by taskId.",
"- Delivering final answer before collecting Oracle result — **Never.**",
]
return `## Hard Blocks (NEVER violate)
@@ -308,8 +307,8 @@ export function buildAntiPatternsSection(): string {
"- **Testing**: Deleting failing tests to \"pass\"",
"- **Search**: Firing agents for single-line typos or obvious syntax errors",
"- **Debugging**: Shotgun debugging, random changes",
"- **Background Tasks**: `background_cancel(all=true)` — always cancel individually by taskId",
"- **Oracle**: Skipping Oracle results when Oracle was launched — ALWAYS collect via `background_output`",
"- **Background Tasks**: Polling `background_output` on running tasks — end response and wait for notification",
"- **Oracle**: Delivering answer without collecting Oracle results",
]
return `## Anti-Patterns (BLOCKING violations)
@@ -317,6 +316,22 @@ export function buildAntiPatternsSection(): string {
${patterns.join("\n")}`
}
export function buildNonClaudePlannerSection(model: string): string {
const isNonClaude = !model.toLowerCase().includes('claude')
if (!isNonClaude) return ""
return `### Plan Agent Dependency (Non-Claude)
Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan.
- Single-file fix or trivial change → proceed directly
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST
- Use \`session_id\` to resume the same Plan Agent — ask follow-up questions aggressively
- If ANY part of the task is ambiguous, ask Plan Agent before guessing
Plan Agent returns a structured work breakdown with parallel execution opportunities. Follow it.`
}
export function buildDeepParallelSection(model: string, categories: AvailableCategory[]): string {
const isNonClaude = !model.toLowerCase().includes('claude')
const hasDeepCategory = categories.some(c => c.name === 'deep')
@@ -325,12 +340,13 @@ export function buildDeepParallelSection(model: string, categories: AvailableCat
return `### Deep Parallel Delegation
For implementation tasks, actively decompose and delegate to \`deep\` category agents in parallel.
Delegate EVERY independent unit to a \`deep\` agent in parallel (\`run_in_background=true\`).
If a task decomposes into 4 independent units, spawn 4 agents simultaneously — not 1 at a time.
1. Break the implementation into independent work units
2. Maximize parallel deep agents — spawn one per independent unit (\`run_in_background=true\`)
3. Give each agent a GOAL, not step-by-step instructions — deep agents explore and solve autonomously
4. Collect results, integrate, verify coherence`
1. Decompose the implementation into independent work units
2. Assign one \`deep\` agent per unit — all via \`run_in_background=true\`
3. Give each agent a clear GOAL with success criteria, not step-by-step instructions
4. Collect all results, integrate, verify coherence across units`
}
export function buildUltraworkSection(

View File

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

View File

@@ -39,6 +39,136 @@ Then ACTUALLY CALL those tools using the JSON tool schema. Produce the tool_use
</TOOL_CALL_MANDATE>`;
}
export function buildGeminiToolGuide(): string {
return `<GEMINI_TOOL_GUIDE>
## Tool Usage Guide — WHEN and HOW to Call Each Tool
You have access to tools via function calling. This guide defines WHEN to call each one.
**Violating these patterns = failed response.**
### Reading & Search (ALWAYS parallelizable — call multiple simultaneously)
| Tool | When to Call | Parallel? |
|---|---|---|
| \`Read\` | Before making ANY claim about file contents. Before editing any file. | <20> Yes — read multiple files at once |
| \`Grep\` | Finding patterns, imports, usages across codebase. BEFORE claiming "X is used in Y". | ✅ Yes — run multiple greps at once |
| \`Glob\` | Finding files by name/extension pattern. BEFORE claiming "file X exists". | ✅ Yes — run multiple globs at once |
| \`AstGrepSearch\` | Finding code patterns with AST awareness (structural matches). | ✅ Yes |
### Code Intelligence (parallelizable on different files)
| Tool | When to Call | Parallel? |
|---|---|---|
| \`LspDiagnostics\` | **AFTER EVERY edit.** BEFORE claiming task is done. MANDATORY. | ✅ Yes — different files |
| \`LspGotoDefinition\` | Finding where a symbol is defined. | ✅ Yes |
| \`LspFindReferences\` | Finding all usages of a symbol across workspace. | ✅ Yes |
| \`LspSymbols\` | Getting file outline or searching workspace symbols. | ✅ Yes |
### Editing (SEQUENTIAL — must Read first)
| Tool | When to Call | Parallel? |
|---|---|---|
| \`Edit\` | Modifying existing files. MUST Read file first to get LINE#ID anchors. | ❌ After Read |
| \`Write\` | Creating NEW files only. Or full file overwrite. | ❌ Sequential |
### Execution & Delegation
| Tool | When to Call | Parallel? |
|---|---|---|
| \`Bash\` | Running tests, builds, git commands. | ❌ Usually sequential |
| \`Task\` | ANY non-trivial implementation. Research via explore/librarian. | ✅ Fire multiple in background |
### Correct Sequences (MANDATORY — follow these exactly):
1. **Answer about code**: Read → (analyze) → Answer
2. **Edit code**: Read → Edit → LspDiagnostics → Report
3. **Find something**: Grep/Glob (parallel) → Read results → Report
4. **Implement feature**: Task(delegate) → Verify results → Report
5. **Debug**: Read error → Read file → Grep related → Fix → LspDiagnostics
### PARALLEL RULES:
- **Independent reads/searches**: ALWAYS call simultaneously in ONE response
- **Dependent operations**: Call sequentially (Edit AFTER Read, LspDiagnostics AFTER Edit)
- **Background agents**: ALWAYS \`run_in_background=true\`, continue working
</GEMINI_TOOL_GUIDE>`;
}
export function buildGeminiToolCallExamples(): string {
return `<GEMINI_TOOL_CALL_EXAMPLES>
## Correct Tool Calling Patterns — Follow These Examples
### Example 1: User asks about code → Read FIRST, then answer
**User**: "How does the auth middleware work?"
**CORRECT**:
\`\`\`
→ Call Read(filePath="/src/middleware/auth.ts")
→ Call Read(filePath="/src/config/auth.ts") // parallel with above
→ (After reading) Answer based on ACTUAL file contents
\`\`\`
**WRONG**:
\`\`\`
→ "The auth middleware likely validates JWT tokens by..." ← HALLUCINATION. You didn't read the file.
\`\`\`
### Example 2: User asks to edit code → Read, Edit, Verify
**User**: "Fix the type error in user.ts"
**CORRECT**:
\`\`\`
→ Call Read(filePath="/src/models/user.ts")
→ Call LspDiagnostics(filePath="/src/models/user.ts") // parallel with Read
→ (After reading) Call Edit with LINE#ID anchors
→ Call LspDiagnostics(filePath="/src/models/user.ts") // verify fix
→ Report: "Fixed. Diagnostics clean."
\`\`\`
**WRONG**:
\`\`\`
→ Call Edit without reading first ← No LINE#ID anchors = WILL FAIL
→ Skip LspDiagnostics after edit ← UNVERIFIED
\`\`\`
### Example 3: User asks to find something → Search in parallel
**User**: "Where is the database connection configured?"
**CORRECT**:
\`\`\`
→ Call Grep(pattern="database|connection|pool", path="/src") // fires simultaneously
→ Call Glob(pattern="**/*database*") // fires simultaneously
→ Call Glob(pattern="**/*db*") // fires simultaneously
→ (After results) Read the most relevant files
→ Report findings with file paths
\`\`\`
### Example 4: User asks to implement a feature → DELEGATE
**User**: "Add a new /health endpoint to the API"
**CORRECT**:
\`\`\`
→ Call Task(category="quick", load_skills=["typescript-programmer"], prompt="...")
→ (After agent completes) Read changed files to verify
→ Call LspDiagnostics on changed files
→ Report
\`\`\`
**WRONG**:
\`\`\`
→ Write the code yourself ← YOU ARE AN ORCHESTRATOR, NOT AN IMPLEMENTER
\`\`\`
### Example 5: Investigation ≠ Implementation
**User**: "Look into why the tests are failing"
**CORRECT**:
\`\`\`
→ Call Bash(command="npm test") // see actual failures
→ Call Read on failing test files
→ Call Read on source files under test
→ Report: "Tests fail because X. Root cause: Y. Proposed fix: Z."
→ STOP — wait for user to say "fix it"
\`\`\`
**WRONG**:
\`\`\`
→ Start editing source files immediately ← "look into" ≠ "fix"
\`\`\`
</GEMINI_TOOL_CALL_EXAMPLES>`;
}
export function buildGeminiDelegationOverride(): string {
return `<GEMINI_DELEGATION_OVERRIDE>
## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER

View File

@@ -6,9 +6,11 @@ import {
buildGeminiDelegationOverride,
buildGeminiVerificationOverride,
buildGeminiIntentGateEnforcement,
buildGeminiToolGuide,
buildGeminiToolCallExamples,
} from "./sisyphus-gemini-overlays";
const MODE: AgentMode = "primary";
const MODE: AgentMode = "all";
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
category: "utility",
cost: "EXPENSIVE",
@@ -32,6 +34,7 @@ import {
buildHardBlocksSection,
buildAntiPatternsSection,
buildDeepParallelSection,
buildNonClaudePlannerSection,
categorizeTools,
} from "./dynamic-agent-prompt-builder";
@@ -170,6 +173,7 @@ function buildDynamicSisyphusPrompt(
const hardBlocks = buildHardBlocksSection();
const antiPatterns = buildAntiPatternsSection();
const deepParallelSection = buildDeepParallelSection(model, availableCategories);
const nonClaudePlannerSection = buildNonClaudePlannerSection(model);
const taskManagementSection = buildTaskManagementSection(useTaskSystem);
const todoHookNote = useTaskSystem
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
@@ -329,7 +333,7 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio
// Reference Grep (external)
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
// Continue working immediately. Collect with background_output when needed.
// Continue working immediately. System notifies on completion — collect with background_output then.
// WRONG: Sequential or blocking
result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian
@@ -337,10 +341,10 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
### Background Result Collection:
1. Launch parallel agents \u2192 receive task_ids
2. Continue immediate work (explore, librarian results)
3. When results needed: \`background_output(task_id="...")\`
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)\`.
2. Continue immediate work
3. System sends \`<system-reminder>\` on each task completion — then call \`background_output(task_id="...")\`
4. Need results not yet ready? **End your response.** The notification will trigger your next turn.
5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
### Search Stop Conditions
@@ -364,6 +368,8 @@ STOP searching when:
${categorySkillsGuide}
${nonClaudePlannerSection}
${deepParallelSection}
${delegationTable}
@@ -477,9 +483,8 @@ If verification fails:
3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes."
### Before Delivering Final Answer:
- **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)\`.**
- If Oracle is running: **end your response** and wait for the completion notification first.
- Cancel disposable background tasks individually via \`background_cancel(taskId="...")\`.
</Behavior_Instructions>
${oracleSection}
@@ -565,12 +570,25 @@ export function createSisyphusAgent(
: buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem);
if (isGeminiModel(model)) {
// 1. Intent gate + tool mandate — early in prompt (after intent verbalization)
prompt = prompt.replace(
"</intent_verbalization>",
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
);
prompt += "\n" + buildGeminiDelegationOverride();
prompt += "\n" + buildGeminiVerificationOverride();
// 2. Tool guide + examples — after tool_usage_rules (where tools are discussed)
prompt = prompt.replace(
"</tool_usage_rules>",
`</tool_usage_rules>\n\n${buildGeminiToolGuide()}\n\n${buildGeminiToolCallExamples()}`
);
// 3. Delegation + verification overrides — before Constraints (NOT at prompt end)
// Gemini suffers from lost-in-the-middle: content at prompt end gets weaker attention.
// Placing these before <Constraints> ensures they're in a high-attention zone.
prompt = prompt.replace(
"<Constraints>",
`${buildGeminiDelegationOverride()}\n\n${buildGeminiVerificationOverride()}\n\n<Constraints>`
);
}
const permission = {

View File

@@ -47,8 +47,8 @@ describe("isGptModel", () => {
});
test("gemini models are not gpt", () => {
expect(isGptModel("google/gemini-3-pro")).toBe(false);
expect(isGptModel("litellm/gemini-3-pro")).toBe(false);
expect(isGptModel("google/gemini-3.1-pro")).toBe(false);
expect(isGptModel("litellm/gemini-3.1-pro")).toBe(false);
});
test("opencode provider is not gpt", () => {
@@ -58,29 +58,29 @@ describe("isGptModel", () => {
describe("isGeminiModel", () => {
test("#given google provider models #then returns true", () => {
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google/gemini-3.1-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.1-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.1-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.1-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("custom-provider/gemini-3.1-pro")).toBe(true);
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
});

View File

@@ -988,7 +988,7 @@ describe("buildAgent with category and skills", () => {
const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then - category's built-in model is applied
expect(agent.model).toBe("google/gemini-3-pro")
expect(agent.model).toBe("google/gemini-3.1-pro")
})
test("agent with category and existing model keeps existing model", () => {

View File

@@ -2,7 +2,7 @@
exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK for all agents and categories when no providers 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/glm-4.7-free",
@@ -63,7 +63,7 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
exports[`generateModelConfig single native provider uses Claude models when only Claude is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -125,7 +125,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
exports[`generateModelConfig single native provider uses Claude models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -188,7 +188,7 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
exports[`generateModelConfig single native provider uses OpenAI models when only OpenAI is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "openai/gpt-5.2",
@@ -255,7 +255,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
exports[`generateModelConfig single native provider uses OpenAI models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "openai/gpt-5.2",
@@ -322,10 +322,10 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
exports[`generateModelConfig single native provider uses Gemini models when only Gemini is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -334,34 +334,34 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"momus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"prometheus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "google/gemini-3-flash-preview",
},
"ultrabrain": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -371,7 +371,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "google/gemini-3-flash-preview",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -383,10 +383,10 @@ exports[`generateModelConfig single native provider uses Gemini models when only
exports[`generateModelConfig single native provider uses Gemini models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -395,44 +395,44 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"momus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"prometheus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "google/gemini-3-flash-preview",
},
"ultrabrain": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
"unspecified-low": {
"model": "google/gemini-3-flash-preview",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -444,7 +444,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
exports[`generateModelConfig all native providers uses preferred models from fallback chains when all natives available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -485,7 +485,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -506,7 +506,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -518,7 +518,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
exports[`generateModelConfig all native providers uses preferred models with isMax20 flag when all natives available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -559,7 +559,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -581,7 +581,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -593,7 +593,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
exports[`generateModelConfig fallback providers uses OpenCode Zen models when only OpenCode Zen is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/kimi-k2.5-free",
@@ -634,7 +634,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
},
"categories": {
"artistry": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"deep": {
@@ -655,7 +655,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
"model": "opencode/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"writing": {
@@ -667,7 +667,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
exports[`generateModelConfig fallback providers uses OpenCode Zen models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/kimi-k2.5-free",
@@ -708,7 +708,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
},
"categories": {
"artistry": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"deep": {
@@ -730,7 +730,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
"model": "opencode/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"writing": {
@@ -742,7 +742,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
exports[`generateModelConfig fallback providers uses GitHub Copilot models when only Copilot is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -779,14 +779,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
"ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -796,7 +796,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -808,7 +808,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
exports[`generateModelConfig fallback providers uses GitHub Copilot models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -845,14 +845,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
"ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -863,7 +863,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -875,7 +875,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
exports[`generateModelConfig fallback providers uses ZAI model for librarian when only ZAI is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/glm-4.7-free",
@@ -930,7 +930,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
exports[`generateModelConfig fallback providers uses ZAI model for librarian with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/glm-4.7-free",
@@ -985,7 +985,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen combination 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/kimi-k2.5-free",
@@ -1026,7 +1026,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
},
"categories": {
"artistry": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"deep": {
@@ -1047,7 +1047,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"writing": {
@@ -1059,7 +1059,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot combination 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -1100,7 +1100,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1121,7 +1121,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1133,7 +1133,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combination (librarian uses ZAI) 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1194,7 +1194,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combination (explore uses Gemini) 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1217,7 +1217,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"prometheus": {
@@ -1231,14 +1231,14 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
"ultrabrain": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -1248,7 +1248,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1260,7 +1260,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
exports[`generateModelConfig mixed provider scenarios uses all fallback providers together 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/kimi-k2.5-free",
@@ -1301,7 +1301,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1322,7 +1322,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1334,7 +1334,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
exports[`generateModelConfig mixed provider scenarios uses all providers together 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/kimi-k2.5-free",
@@ -1375,7 +1375,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1396,7 +1396,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1408,7 +1408,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
exports[`generateModelConfig mixed provider scenarios uses all providers with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/kimi-k2.5-free",
@@ -1449,7 +1449,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1471,7 +1471,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {

View File

@@ -178,7 +178,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
expect(models).toBeTruthy()
const required = [
"antigravity-gemini-3-pro",
"antigravity-gemini-3.1-pro",
"antigravity-gemini-3-flash",
"antigravity-claude-sonnet-4-6",
"antigravity-claude-sonnet-4-6-thinking",
@@ -206,7 +206,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
// #when checking Gemini Pro variants
const pro = models["antigravity-gemini-3-pro"]
const pro = models["antigravity-gemini-3.1-pro"]
// #then should have low and high variants
expect(pro.variants).toBeTruthy()
expect(pro.variants.low).toBeTruthy()
@@ -277,7 +277,7 @@ describe("generateOmoConfig - model fallback system", () => {
const result = generateOmoConfig(config)
// #then Sisyphus is omitted (requires all fallback providers)
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json")
expect((result.agents as Record<string, { model: string }>).sisyphus).toBeUndefined()
})

View File

@@ -4,10 +4,10 @@
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
* - `antigravity-gemini-3-pro` with variants: low, high
* - `antigravity-gemini-3.1-pro` with variants: low, high
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
*
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3.1-pro-high`) still work
* but variants are the recommended approach.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
@@ -16,7 +16,7 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
models: {
"antigravity-gemini-3-pro": {
"antigravity-gemini-3.1-pro": {
name: "Gemini 3 Pro (Antigravity)",
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },

View File

@@ -1,4 +1,5 @@
import { getConfigDir } from "./config-context"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
@@ -16,7 +17,7 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: getConfigDir(),
stdout: "inherit",
stderr: "inherit",

View File

@@ -1,4 +1,5 @@
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { initConfigContext } from "./config-context"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
@@ -11,7 +12,7 @@ interface OpenCodeBinaryResult {
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
const proc = spawnWithWindowsHide([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})

View File

@@ -3,6 +3,7 @@ import { createRequire } from "node:module"
import { dirname, join } from "node:path"
import type { DependencyInfo } from "../types"
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
@@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
async function getBinaryVersion(binary: string): Promise<string | null> {
try {
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const proc = spawnWithWindowsHide([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
@@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
path: resolvedPath,
}
}

View File

@@ -26,7 +26,7 @@ describe("model-resolution check", () => {
// then: Should have category entries
const visual = info.categories.find((c) => c.name === "visual-engineering")
expect(visual).toBeDefined()
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3-pro")
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3.1-pro")
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
})
})

View File

@@ -1,6 +1,7 @@
import { existsSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
import { OPENCODE_BINARIES } from "../constants"
@@ -110,7 +111,7 @@ export async function getOpenCodeVersion(
): Promise<string | null> {
try {
const command = buildVersionCommand(binaryPath, platform)
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
const processResult = spawnWithWindowsHide(command, { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text()
await processResult.exited

View File

@@ -1,3 +1,5 @@
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
export interface GhCliInfo {
installed: boolean
version: string | null
@@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
async function getGhVersion(): Promise<string | null> {
try {
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const processResult = spawnWithWindowsHide(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text()
await processResult.exited
if (processResult.exitCode !== 0) return null
@@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{
error: string | null
}> {
try {
const processResult = Bun.spawn(["gh", "auth", "status"], {
const processResult = spawnWithWindowsHide(["gh", "auth", "status"], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },

View File

@@ -24,7 +24,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
oracle: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
@@ -59,7 +59,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
metis: {
@@ -68,14 +68,14 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
},
momus: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
},
atlas: {
@@ -84,7 +84,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
}
@@ -92,7 +92,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["zai-coding-plan"], model: "glm-5" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
@@ -101,7 +101,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
ultrabrain: {
fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
@@ -109,17 +109,17 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
requiresModel: "gpt-5.3-codex",
},
artistry: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
requiresModel: "gemini-3-pro",
requiresModel: "gemini-3.1-pro",
},
quick: {
fallbackChain: [
@@ -139,7 +139,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
writing: {

View File

@@ -515,7 +515,7 @@ describe("generateModelConfig", () => {
// #then should include correct schema URL
expect(result.$schema).toBe(
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json"
)
})
})

View File

@@ -19,7 +19,7 @@ export type { GeneratedOmoConfig } from "./model-fallback-types"
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json"

View File

@@ -40,16 +40,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("claude-haiku-4.5")
})
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
// #given github-copilot provider and gemini-3-pro model
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
// #given github-copilot provider and gemini-3.1-pro model
const provider = "github-copilot"
const model = "gemini-3-pro"
const model = "gemini-3.1-pro"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-pro-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should transform to gemini-3.1-pro-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
@@ -64,16 +64,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("gemini-3-flash-preview")
})
test("prevents double transformation of gemini-3-pro-preview", () => {
// #given github-copilot provider and gemini-3-pro-preview model (already transformed)
test("prevents double transformation of gemini-3.1-pro-preview", () => {
// #given github-copilot provider and gemini-3.1-pro-preview model (already transformed)
const provider = "github-copilot"
const model = "gemini-3-pro-preview"
const model = "gemini-3.1-pro-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-pro-preview-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should NOT become gemini-3.1-pro-preview-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("prevents double transformation of gemini-3-flash-preview", () => {
@@ -102,16 +102,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("gemini-3-flash-preview")
})
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
// #given google provider and gemini-3-pro model
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
// #given google provider and gemini-3.1-pro model
const provider = "google"
const model = "gemini-3-pro"
const model = "gemini-3.1-pro"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-pro-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should transform to gemini-3.1-pro-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("passes through other gemini models unchanged", () => {
@@ -138,16 +138,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("gemini-3-flash-preview")
})
test("prevents double transformation of gemini-3-pro-preview", () => {
// #given google provider and gemini-3-pro-preview model (already transformed)
test("prevents double transformation of gemini-3.1-pro-preview", () => {
// #given google provider and gemini-3.1-pro-preview model (already transformed)
const provider = "google"
const model = "gemini-3-pro-preview"
const model = "gemini-3.1-pro-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-pro-preview-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should NOT become gemini-3.1-pro-preview-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("does not transform claude models for google provider", () => {

View File

@@ -3,6 +3,7 @@ import type { RunResult } from "./types"
import { createJsonOutputManager } from "./json-output"
import { resolveSession } from "./session-resolver"
import { executeOnCompleteHook } from "./on-complete-hook"
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
import type { OpencodeClient } from "./types"
import * as originalSdk from "@opencode-ai/sdk"
import * as originalPortUtils from "../../shared/port-utils"
@@ -147,7 +148,7 @@ describe("integration: --session-id", () => {
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
// then
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
expect(mockClient.session.get).toHaveBeenCalledWith({
path: { id: sessionId },
query: { directory: "/test" },
@@ -161,10 +162,13 @@ describe("integration: --on-complete", () => {
beforeEach(() => {
spyOn(console, "error").mockImplementation(() => {})
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
} as unknown as ReturnType<typeof Bun.spawn>)
stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
})
afterEach(() => {
@@ -186,7 +190,7 @@ describe("integration: --on-complete", () => {
// then
expect(spawnSpy).toHaveBeenCalledTimes(1)
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.SESSION_ID).toBe("session-123")
expect(options?.env?.EXIT_CODE).toBe("0")
expect(options?.env?.DURATION_MS).toBe("5000")
@@ -208,10 +212,13 @@ describe("integration: option combinations", () => {
spyOn(console, "error").mockImplementation(() => {})
mockStdout = createMockWriteStream()
mockStderr = createMockWriteStream()
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
} as unknown as ReturnType<typeof Bun.spawn>)
stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
})
afterEach(() => {
@@ -249,9 +256,9 @@ describe("integration: option combinations", () => {
const emitted = mockStdout.writes[0]!
expect(() => JSON.parse(emitted)).not.toThrow()
expect(spawnSpy).toHaveBeenCalledTimes(1)
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(args).toEqual(["sh", "-c", "echo done"])
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.SESSION_ID).toBe("session-123")
expect(options?.env?.EXIT_CODE).toBe("0")
expect(options?.env?.DURATION_MS).toBe("5000")

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
import { executeOnCompleteHook } from "./on-complete-hook"
describe("executeOnCompleteHook", () => {
@@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => {
return {
exited: Promise.resolve(exitCode),
exitCode,
} as unknown as ReturnType<typeof Bun.spawn>
stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
}
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
@@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => {
it("executes command with correct env vars", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => {
// then
expect(spawnSpy).toHaveBeenCalledTimes(1)
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(args).toEqual(["sh", "-c", "echo test"])
expect(options?.env?.SESSION_ID).toBe("session-123")
@@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => {
it("env var values are strings", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => {
})
// then
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.EXIT_CODE).toBe("1")
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
@@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => {
it("empty command string is no-op", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => {
it("whitespace-only command is no-op", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => {
it("command failure logs warning but does not throw", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1))
try {
// when
await expect(
expect(
executeOnCompleteHook({
command: "false",
sessionId: "session-123",
@@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => {
it("spawn error logs warning but does not throw", async () => {
// given
const spawnError = new Error("Command not found")
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => {
throw spawnError
})
try {
// when
await expect(
expect(
executeOnCompleteHook({
command: "nonexistent-command",
sessionId: "session-123",

View File

@@ -1,4 +1,5 @@
import pc from "picocolors"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
export async function executeOnCompleteHook(options: {
command: string
@@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: {
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
try {
const proc = Bun.spawn(["sh", "-c", trimmedCommand], {
const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], {
env: {
...process.env,
SESSION_ID: sessionId,

View File

@@ -1,4 +1,5 @@
import { delimiter, dirname, join } from "node:path"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
@@ -41,7 +42,7 @@ export function collectCandidateBinaryPaths(
export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
try {
const proc = Bun.spawn([binaryPath, "--version"], {
const proc = spawnWithWindowsHide([binaryPath, "--version"], {
stdout: "pipe",
stderr: "pipe",
})

View File

@@ -49,6 +49,7 @@ export const HookNameSchema = z.enum([
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",
"read-image-resizer",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

@@ -27,13 +27,13 @@ export const OhMyOpenCodeConfigSchema = z.object({
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
default_run_agent: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_agents: z.array(z.string()).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(z.string()).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(),
/** Enable hashline_edit tool/hook integrations (default: true at call site) */
/** Enable hashline_edit tool/hook integrations (default: false) */
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(),

View File

@@ -34,7 +34,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
// given
const config: BackgroundTaskConfig = {
modelConcurrency: { "google/gemini-3-pro": 5 },
modelConcurrency: { "google/gemini-3.1-pro": 5 },
providerConcurrency: { anthropic: 3 }
}
const manager = new ConcurrencyManager(config)
@@ -95,7 +95,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
// when
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3.1-pro")
// then
expect(modelLimit).toBe(10)

View File

@@ -2987,6 +2987,28 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
manager.shutdown()
resetToastManager()
})
test("should clean pending notifications for deleted sessions", () => {
//#given
const manager = createBackgroundManager()
const sessionID = "session-pending-notifications"
manager.queuePendingNotification(sessionID, "<system-reminder>queued</system-reminder>")
expect(getPendingNotifications(manager).get(sessionID)).toEqual([
"<system-reminder>queued</system-reminder>",
])
//#when
manager.handleEvent({
type: "session.deleted",
properties: { info: { id: sessionID } },
})
//#then
expect(getPendingNotifications(manager).has(sessionID)).toBe(false)
manager.shutdown()
})
})
describe("BackgroundManager.handleEvent - session.error", () => {

View File

@@ -830,6 +830,8 @@ export class BackgroundManager {
tasksToCancel.set(descendant.id, descendant)
}
this.pendingNotifications.delete(sessionID)
if (tasksToCancel.size === 0) return
for (const task of tasksToCancel.values()) {
@@ -866,6 +868,13 @@ export class BackgroundManager {
subagentSessions.delete(task.sessionID)
}
}
for (const task of tasksToCancel.values()) {
if (task.parentSessionID) {
this.pendingNotifications.delete(task.parentSessionID)
}
}
SessionCategoryRegistry.remove(sessionID)
}

View File

@@ -80,12 +80,11 @@ export interface PluginManifest {
/**
* Hooks configuration
*/
export interface HookEntry {
type: "command" | "prompt" | "agent"
command?: string
prompt?: string
agent?: string
}
export type HookEntry =
| { type: "command"; command?: string }
| { type: "prompt"; prompt?: string }
| { type: "agent"; agent?: string }
| { type: "http"; url: string; headers?: Record<string, string>; allowedEnvVars?: string[]; timeout?: number }
export interface HookMatcher {
matcher?: string

View File

@@ -205,6 +205,45 @@ describe("ContextCollector", () => {
const ids = pending.entries.map((e) => e.id)
expect(ids).toEqual(["first", "second", "third"])
})
it("keeps registration order even when Date.now values are not monotonic", () => {
// given
const sessionID = "ses_order_non_monotonic_time"
const originalDateNow = Date.now
const mockedTimestamps = [300, 100, 200]
let timestampIndex = 0
Date.now = () => mockedTimestamps[timestampIndex++] ?? 0
try {
collector.register(sessionID, {
id: "first",
source: "custom",
content: "First",
priority: "normal",
})
collector.register(sessionID, {
id: "second",
source: "custom",
content: "Second",
priority: "normal",
})
collector.register(sessionID, {
id: "third",
source: "custom",
content: "Third",
priority: "normal",
})
} finally {
Date.now = originalDateNow
}
// when
const pending = collector.getPending(sessionID)
// then
const ids = pending.entries.map((entry) => entry.id)
expect(ids).toEqual(["first", "second", "third"])
})
})
describe("consume", () => {

View File

@@ -14,6 +14,8 @@ const PRIORITY_ORDER: Record<ContextPriority, number> = {
const CONTEXT_SEPARATOR = "\n\n---\n\n"
let registrationCounter = 0
export class ContextCollector {
private sessions: Map<string, Map<string, ContextEntry>> = new Map()
@@ -30,7 +32,7 @@ export class ContextCollector {
source: options.source,
content: options.content,
priority: options.priority ?? "normal",
timestamp: Date.now(),
registrationOrder: ++registrationCounter,
metadata: options.metadata,
}
@@ -77,7 +79,7 @@ export class ContextCollector {
return entries.sort((a, b) => {
const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
if (priorityDiff !== 0) return priorityDiff
return a.timestamp - b.timestamp
return a.registrationOrder - b.registrationOrder
})
}
}

View File

@@ -64,6 +64,51 @@ describe("createContextInjectorMessagesTransformHook", () => {
expect(output.messages[2].parts[1].text).toBe("Second message")
})
it("uses deterministic synthetic part ID across repeated transforms", async () => {
// given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform_deterministic"
const baseMessage = createMockMessage("user", "Stable message", sessionID)
collector.register(sessionID, {
id: "ctx-1",
source: "keyword-detector",
content: "Injected context",
})
const firstOutput = {
messages: [structuredClone(baseMessage)],
}
// when
await hook["experimental.chat.messages.transform"]!({}, firstOutput)
// then
const firstSyntheticPart = firstOutput.messages[0].parts[0]
expect(
"synthetic" in firstSyntheticPart && firstSyntheticPart.synthetic === true
).toBe(true)
// given
collector.register(sessionID, {
id: "ctx-2",
source: "keyword-detector",
content: "Injected context",
})
const secondOutput = {
messages: [structuredClone(baseMessage)],
}
// when
await hook["experimental.chat.messages.transform"]!({}, secondOutput)
// then
const secondSyntheticPart = secondOutput.messages[0].parts[0]
expect(
"synthetic" in secondSyntheticPart && secondSyntheticPart.synthetic === true
).toBe(true)
expect(secondSyntheticPart.id).toBe(firstSyntheticPart.id)
})
it("does nothing when no pending context", async () => {
// given
const hook = createContextInjectorMessagesTransformHook(collector)

View File

@@ -148,7 +148,7 @@ export function createContextInjectorMessagesTransformHook(
// synthetic part pattern (minimal fields)
const syntheticPart = {
id: `synthetic_hook_${Date.now()}`,
id: `synthetic_hook_${sessionID}`,
messageID: lastUserMessage.info.id,
sessionID: (lastUserMessage.info as { sessionID?: string }).sessionID ?? "",
type: "text" as const,

View File

@@ -27,8 +27,8 @@ export interface ContextEntry {
content: string
/** Priority for ordering (default: normal) */
priority: ContextPriority
/** Timestamp when registered */
timestamp: number
/** Monotonic order when registered */
registrationOrder: number
/** Optional metadata for debugging/logging */
metadata?: Record<string, unknown>
}

View File

@@ -4,6 +4,8 @@ import {
findFirstMessageWithAgent,
findNearestMessageWithFieldsFromSDK,
findFirstMessageWithAgentFromSDK,
generateMessageId,
generatePartId,
injectHookMessage,
} from "./injector"
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
@@ -192,6 +194,40 @@ describe("findFirstMessageWithAgentFromSDK", () => {
})
})
describe("generateMessageId", () => {
it("returns deterministic sequential IDs with fixed format", () => {
// given
const format = /^msg_[0-9a-f]{8}_\d{6}$/
// when
const firstId = generateMessageId()
const secondId = generateMessageId()
// then
expect(firstId).toMatch(format)
expect(secondId).toMatch(format)
expect(secondId.split("_")[1]).toBe(firstId.split("_")[1])
expect(Number(secondId.split("_")[2])).toBe(Number(firstId.split("_")[2]) + 1)
})
})
describe("generatePartId", () => {
it("returns deterministic sequential IDs with fixed format", () => {
// given
const format = /^prt_[0-9a-f]{8}_\d{6}$/
// when
const firstId = generatePartId()
const secondId = generatePartId()
// then
expect(firstId).toMatch(format)
expect(secondId).toMatch(format)
expect(secondId.split("_")[1]).toBe(firstId.split("_")[1])
expect(Number(secondId.split("_")[2])).toBe(Number(firstId.split("_")[2]) + 1)
})
})
describe("injectHookMessage", () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { randomBytes } from "node:crypto"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
@@ -29,6 +30,10 @@ interface SDKMessage {
}
}
const processPrefix = randomBytes(4).toString("hex")
let messageCounter = 0
let partCounter = 0
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
const info = msg.info
if (!info) return null
@@ -204,16 +209,12 @@ export function findFirstMessageWithAgent(messageDir: string): string | null {
return null
}
function generateMessageId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 14)
return `msg_${timestamp}${random}`
export function generateMessageId(): string {
return `msg_${processPrefix}_${String(++messageCounter).padStart(6, "0")}`
}
function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
export function generatePartId(): string {
return `prt_${processPrefix}_${String(++partCounter).padStart(6, "0")}`
}
function getOrCreateMessageDir(sessionID: string): string {

View File

@@ -162,7 +162,7 @@ describe("TaskToastManager", () => {
description: "Task with category default model",
agent: "sisyphus-junior",
isBackground: false,
modelInfo: { model: "google/gemini-3-pro", type: "category-default" as const },
modelInfo: { model: "google/gemini-3.1-pro", type: "category-default" as const },
}
// when - addTask is called

View File

@@ -6,6 +6,8 @@ import {
} from "./storage";
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types";
import { getSessionAgent } from "../../features/claude-code-session-state";
import { getAgentConfigKey } from "../../shared/agent-display-names";
interface ToolExecuteInput {
tool: string;
@@ -26,6 +28,23 @@ interface EventInput {
};
}
/**
* Only orchestrator agents should receive usage reminders.
* Subagents (explore, librarian, oracle, etc.) are the targets of delegation,
* so reminding them to delegate to themselves is counterproductive.
*/
const ORCHESTRATOR_AGENTS = new Set([
"sisyphus",
"sisyphus-junior",
"atlas",
"hephaestus",
"prometheus",
]);
function isOrchestratorAgent(agentName: string): boolean {
return ORCHESTRATOR_AGENTS.has(getAgentConfigKey(agentName));
}
export function createAgentUsageReminderHook(_ctx: PluginInput) {
const sessionStates = new Map<string, AgentUsageState>();
@@ -60,6 +79,12 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
output: ToolExecuteOutput,
) => {
const { tool, sessionID } = input;
const agent = getSessionAgent(sessionID);
if (agent && !isOrchestratorAgent(agent)) {
return;
}
const toolLower = tool.toLowerCase();
if (AGENT_TOOLS.has(toolLower)) {

View File

@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { HOOK_NAME } from "./hook-name"
@@ -97,8 +97,10 @@ export function createAtlasEventHandler(input: {
return
}
const sessionAgent = getSessionAgent(sessionID)
const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
const lastAgentKey = getAgentConfigKey(lastAgent ?? "")
const effectiveAgent = sessionAgent ?? lastAgent
const lastAgentKey = getAgentConfigKey(effectiveAgent ?? "")
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
@@ -108,7 +110,7 @@ export function createAtlasEventHandler(input: {
if (!agentMatches) {
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
sessionID,
lastAgent: lastAgent ?? "unknown",
lastAgent: effectiveAgent ?? "unknown",
requiredAgent,
})
return

View File

@@ -9,7 +9,7 @@ import {
readBoulderState,
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
import { _resetForTesting, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
@@ -933,7 +933,7 @@ describe("atlas hook", () => {
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
// given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work)
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -1386,5 +1386,38 @@ describe("atlas hook", () => {
// then - should call prompt because session state was cleaned
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should inject when session agent was updated to atlas by start-work even if message storage agent differs", async () => {
// given - boulder targets atlas, but nearest stored message still says hephaestus
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
agent: "atlas",
}
writeBoulderState(TEST_DIR, state)
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
updateSessionAgent(MAIN_SESSION_ID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should continue because start-work updated session agent to atlas
expect(mockInput._promptMock).toHaveBeenCalled()
})
})
})

View File

@@ -1,12 +1,12 @@
import { join } from "path"
import { existsSync } from "fs"
import { getClaudeConfigDir } from "../../shared"
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
interface RawHookMatcher {
matcher?: string
pattern?: string
hooks: HookCommand[]
hooks: HookAction[]
}
interface RawClaudeHooksConfig {

View File

@@ -0,0 +1,27 @@
import type { HookAction } from "./types"
import type { CommandResult } from "../../shared/command-executor/execute-hook-command"
import { executeHookCommand } from "../../shared"
import { executeHttpHook } from "./execute-http-hook"
import { DEFAULT_CONFIG } from "./plugin-config"
export function getHookIdentifier(hook: HookAction): string {
if (hook.type === "http") return hook.url
return hook.command.split("/").pop() || hook.command
}
export async function dispatchHook(
hook: HookAction,
stdinJson: string,
cwd: string
): Promise<CommandResult> {
if (hook.type === "http") {
return executeHttpHook(hook, stdinJson)
}
return executeHookCommand(
hook.command,
stdinJson,
cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
}

View File

@@ -0,0 +1,312 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import type { HookHttp } from "./types"
const mockFetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
)
const originalFetch = globalThis.fetch
describe("executeHttpHook", () => {
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch
mockFetch.mockReset()
mockFetch.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
)
})
afterEach(() => {
globalThis.fetch = originalFetch
})
describe("#given a basic HTTP hook", () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks/pre-tool-use",
}
const stdinData = JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash" })
it("#when executed #then sends POST request with correct body", async () => {
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, stdinData)
expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe("http://localhost:8080/hooks/pre-tool-use")
expect(options.method).toBe("POST")
expect(options.body).toBe(stdinData)
})
it("#when executed #then sets content-type to application/json", async () => {
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, stdinData)
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Content-Type"]).toBe("application/json")
})
})
describe("#given an HTTP hook with headers and env var interpolation", () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv, MY_TOKEN: "secret-123", OTHER_VAR: "other-value" }
})
afterEach(() => {
process.env = originalEnv
})
it("#when allowedEnvVars includes the var #then interpolates env var in headers", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
headers: { Authorization: "Bearer $MY_TOKEN" },
allowedEnvVars: ["MY_TOKEN"],
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123")
})
it("#when env var uses ${VAR} syntax #then interpolates correctly", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
headers: { Authorization: "Bearer ${MY_TOKEN}" },
allowedEnvVars: ["MY_TOKEN"],
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123")
})
it("#when env var not in allowedEnvVars #then replaces with empty string", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
headers: { Authorization: "Bearer $OTHER_VAR" },
allowedEnvVars: ["MY_TOKEN"],
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer ")
})
})
describe("#given an HTTP hook with timeout", () => {
it("#when timeout specified #then passes AbortSignal with timeout", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
timeout: 10,
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
expect(options.signal).toBeDefined()
})
})
describe("#given hook URL scheme validation", () => {
it("#when URL uses file:// scheme #then rejects with exit code 1", async () => {
const hook: HookHttp = { type: "http", url: "file:///etc/passwd" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('HTTP hook URL scheme "file:" is not allowed')
expect(mockFetch).not.toHaveBeenCalled()
})
it("#when URL uses data: scheme #then rejects with exit code 1", async () => {
const hook: HookHttp = { type: "http", url: "data:text/plain,hello" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('HTTP hook URL scheme "data:" is not allowed')
expect(mockFetch).not.toHaveBeenCalled()
})
it("#when URL uses ftp:// scheme #then rejects with exit code 1", async () => {
const hook: HookHttp = { type: "http", url: "ftp://localhost/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('HTTP hook URL scheme "ftp:" is not allowed')
expect(mockFetch).not.toHaveBeenCalled()
})
it("#when URL uses http:// scheme #then allows hook execution", async () => {
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(0)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it("#when URL uses https:// scheme #then allows hook execution", async () => {
const hook: HookHttp = { type: "http", url: "https://example.com/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(0)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it("#when URL is invalid #then rejects with exit code 1", async () => {
const hook: HookHttp = { type: "http", url: "not-a-valid-url" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("HTTP hook URL is invalid: not-a-valid-url")
expect(mockFetch).not.toHaveBeenCalled()
})
})
describe("#given a successful HTTP response", () => {
it("#when response has JSON body #then returns parsed output", async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ decision: "allow", reason: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
)
)
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('"decision":"allow"')
})
})
describe("#given a failing HTTP response", () => {
it("#when response status is 4xx #then returns exit code 1", async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(new Response("Bad Request", { status: 400 }))
)
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("400")
})
it("#when fetch throws network error #then returns exit code 1", async () => {
mockFetch.mockImplementation(() => Promise.reject(new Error("ECONNREFUSED")))
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("ECONNREFUSED")
})
})
describe("#given response with exit code in JSON", () => {
it("#when JSON contains exitCode 2 #then uses that exit code", async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ exitCode: 2, stderr: "blocked" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
)
)
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(2)
})
})
})
describe("interpolateEnvVars", () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv, TOKEN: "abc", SECRET: "xyz" }
})
afterEach(() => {
process.env = originalEnv
})
it("#given $VAR syntax #when var is allowed #then interpolates", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer $TOKEN", ["TOKEN"])
expect(result).toBe("Bearer abc")
})
it("#given ${VAR} syntax #when var is allowed #then interpolates", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN"])
expect(result).toBe("Bearer abc")
})
it("#given multiple vars #when some not allowed #then only interpolates allowed ones", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("$TOKEN:$SECRET", ["TOKEN"])
expect(result).toBe("abc:")
})
it("#given ${VAR} where value contains $ANOTHER #when both allowed #then does not double-interpolate", async () => {
process.env = { ...process.env, TOKEN: "val$SECRET", SECRET: "oops" }
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN", "SECRET"])
expect(result).toBe("Bearer val$SECRET")
})
it("#given no allowedEnvVars #when called #then replaces all with empty", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer $TOKEN", [])
expect(result).toBe("Bearer ")
})
})

View File

@@ -0,0 +1,92 @@
import type { HookHttp } from "./types"
import type { CommandResult } from "../../shared/command-executor/execute-hook-command"
const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30
const ALLOWED_SCHEMES = new Set(["http:", "https:"])
export function interpolateEnvVars(
value: string,
allowedEnvVars: string[]
): string {
const allowedSet = new Set(allowedEnvVars)
return value.replace(/\$\{(\w+)\}|\$(\w+)/g, (_match, bracedVar: string | undefined, bareVar: string | undefined) => {
const varName = (bracedVar ?? bareVar) as string
if (allowedSet.has(varName)) {
return process.env[varName] ?? ""
}
return ""
})
}
function resolveHeaders(
hook: HookHttp
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (!hook.headers) return headers
const allowedEnvVars = hook.allowedEnvVars ?? []
for (const [key, value] of Object.entries(hook.headers)) {
headers[key] = interpolateEnvVars(value, allowedEnvVars)
}
return headers
}
export async function executeHttpHook(
hook: HookHttp,
stdin: string
): Promise<CommandResult> {
try {
const parsed = new URL(hook.url)
if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
return {
exitCode: 1,
stderr: `HTTP hook URL scheme "${parsed.protocol}" is not allowed. Only http: and https: are permitted.`,
}
}
} catch {
return { exitCode: 1, stderr: `HTTP hook URL is invalid: ${hook.url}` }
}
const timeoutS = hook.timeout ?? DEFAULT_HTTP_HOOK_TIMEOUT_S
const headers = resolveHeaders(hook)
try {
const response = await fetch(hook.url, {
method: "POST",
headers,
body: stdin,
signal: AbortSignal.timeout(timeoutS * 1000),
})
if (!response.ok) {
return {
exitCode: 1,
stderr: `HTTP hook returned status ${response.status}: ${response.statusText}`,
stdout: await response.text().catch(() => ""),
}
}
const body = await response.text()
if (!body) {
return { exitCode: 0, stdout: "", stderr: "" }
}
try {
const parsed = JSON.parse(body) as { exitCode?: number }
if (typeof parsed.exitCode === "number") {
return { exitCode: parsed.exitCode, stdout: body, stderr: "" }
}
} catch {
}
return { exitCode: 0, stdout: body, stderr: "" }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { exitCode: 1, stderr: `HTTP hook error: ${message}` }
}
}

View File

@@ -3,8 +3,8 @@ import type {
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
@@ -94,22 +94,17 @@ export async function executePostToolUseHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) {
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
const hookName = getHookIdentifier(hook)
if (isHookCommandDisabled("PostToolUse", hookName, extendedConfig ?? null)) {
log("PostToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.stdout) {
messages.push(result.stdout)

View File

@@ -3,8 +3,8 @@ import type {
PreCompactOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreCompactContext {
@@ -50,22 +50,17 @@ export async function executePreCompactHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) {
log("PreCompact hook command skipped (disabled by config)", { command: hook.command })
const hookName = getHookIdentifier(hook)
if (isHookCommandDisabled("PreCompact", hookName, extendedConfig ?? null)) {
log("PreCompact hook command skipped (disabled by config)", { command: hookName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.exitCode === 2) {
log("PreCompact hook blocked", { hookName, stderr: result.stderr })

View File

@@ -4,8 +4,8 @@ import type {
PermissionDecision,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreToolUseContext {
@@ -77,22 +77,17 @@ export async function executePreToolUseHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
const hookName = getHookIdentifier(hook)
if (isHookCommandDisabled("PreToolUse", hookName, extendedConfig ?? null)) {
log("PreToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.exitCode === 2) {
return {

View File

@@ -3,8 +3,8 @@ import type {
StopOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { getTodoPath } from "./todo"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
@@ -68,19 +68,15 @@ export async function executeStopHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
log("Stop hook command skipped (disabled by config)", { command: hook.command })
const hookName = getHookIdentifier(hook)
if (isHookCommandDisabled("Stop", hookName, extendedConfig ?? null)) {
log("Stop hook command skipped (disabled by config)", { command: hookName })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
// Check exit code first - exit code 2 means block
if (result.exitCode === 2) {

View File

@@ -12,7 +12,7 @@ export type ClaudeHookEvent =
export interface HookMatcher {
matcher: string
hooks: HookCommand[]
hooks: HookAction[]
}
export interface HookCommand {
@@ -20,6 +20,16 @@ export interface HookCommand {
command: string
}
export interface HookHttp {
type: "http"
url: string
headers?: Record<string, string>
allowedEnvVars?: string[]
timeout?: number
}
export type HookAction = HookCommand | HookHttp
export interface ClaudeHooksConfig {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]

View File

@@ -3,8 +3,8 @@ import type {
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
@@ -80,19 +80,15 @@ export async function executeUserPromptSubmitHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
const hookName = getHookIdentifier(hook)
if (isHookCommandDisabled("UserPromptSubmit", hookName, extendedConfig ?? null)) {
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hookName })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.stdout) {
const output = result.stdout.trim()

View File

@@ -50,3 +50,4 @@ export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallba
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
export { createReadImageResizerHook } from "./read-image-resizer"

View File

@@ -6,6 +6,7 @@ import {
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
import type { InteractiveBashSessionState } from "./types";
import { subagentSessions } from "../../features/claude-code-session-state";
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
@@ -19,7 +20,7 @@ async function killAllTrackedSessions(
): Promise<void> {
for (const sessionName of state.tmuxSessions) {
try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
})

View File

@@ -1,6 +1,7 @@
import type { InteractiveBashSessionState } from "./types";
import { loadInteractiveBashSessionState } from "./storage";
import { OMO_SESSION_PREFIX } from "./constants";
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
if (!sessionStates.has(sessionID)) {
@@ -24,7 +25,7 @@ export async function killAllTrackedSessions(
): Promise<void> {
for (const sessionName of state.tmuxSessions) {
try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
});

View File

@@ -0,0 +1,111 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { detectCompletionInSessionMessages } from "./completion-promise-detector"
type SessionMessage = {
info?: { role?: string }
parts?: Array<{ type: string; text?: string }>
}
function createPluginInput(messages: SessionMessage[]): PluginInput {
const pluginInput = {
client: { session: {} } as PluginInput["client"],
project: {} as PluginInput["project"],
directory: "/tmp",
worktree: "/tmp",
serverUrl: new URL("http://localhost"),
$: {} as PluginInput["$"],
} as PluginInput
pluginInput.client.session.messages =
(async () => ({ data: messages })) as unknown as PluginInput["client"]["session"]["messages"]
return pluginInput
}
describe("detectCompletionInSessionMessages", () => {
describe("#given session with prior DONE and new messages", () => {
test("#when sinceMessageIndex excludes prior DONE #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
},
{
info: { role: "assistant" },
parts: [{ type: "text", text: "Working on the new task" }],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-123",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
sinceMessageIndex: 1,
})
// #then
expect(detected).toBe(false)
})
test("#when sinceMessageIndex includes current DONE #then should detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
},
{
info: { role: "assistant" },
parts: [{ type: "text", text: "Current completion <promise>DONE</promise>" }],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-123",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
sinceMessageIndex: 1,
})
// #then
expect(detected).toBe(true)
})
})
describe("#given no sinceMessageIndex (backward compat)", () => {
test("#then should scan all messages", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
},
{
info: { role: "assistant" },
parts: [{ type: "text", text: "No completion in latest message" }],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-123",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
})
// #then
expect(detected).toBe(true)
})
})
})

View File

@@ -52,6 +52,7 @@ export async function detectCompletionInSessionMessages(
promise: string
apiTimeoutMs: number
directory: string
sinceMessageIndex?: number
},
): Promise<boolean> {
try {
@@ -75,12 +76,17 @@ export async function detectCompletionInSessionMessages(
? responseData
: []
const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
const scopedMessages =
typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length
? messageArray.slice(options.sinceMessageIndex)
: messageArray
const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const pattern = buildPromisePattern(options.promise)
const recentAssistants = assistantMessages.slice(-3)
for (const assistant of recentAssistants) {
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
const assistant = assistantMessages[index]
if (!assistant.parts) continue
let responseText = ""

View File

@@ -494,6 +494,7 @@ describe("ralph-loop", () => {
config: {
enabled: true,
default_max_iterations: 200,
default_strategy: "continue",
},
})
@@ -602,7 +603,7 @@ describe("ralph-loop", () => {
expect(hook.getState()).toBeNull()
// then - messages API was called with correct session ID
expect(messagesCalls.length).toBe(1)
expect(messagesCalls.length).toBe(2)
expect(messagesCalls[0].sessionID).toBe("session-123")
})
@@ -632,7 +633,7 @@ describe("ralph-loop", () => {
expect(hook.getState()).toBeNull()
// then - messages API was called with correct session ID
expect(messagesCalls.length).toBe(1)
expect(messagesCalls.length).toBe(2)
expect(messagesCalls[0].sessionID).toBe("session-123")
})
@@ -708,6 +709,57 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
test("should skip concurrent idle events for same session when handler is in flight", async () => {
// given - active loop with delayed prompt injection
let releasePromptAsync: (() => void) | undefined
const promptAsyncBlocked = new Promise<void>((resolve) => {
releasePromptAsync = resolve
})
let firstPromptStartedResolve: (() => void) | undefined
const firstPromptStarted = new Promise<void>((resolve) => {
firstPromptStartedResolve = resolve
})
const mockInput = createMockPluginInput() as {
client: {
session: {
promptAsync: (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => Promise<unknown>
}
}
}
const originalPromptAsync = mockInput.client.session.promptAsync
let promptAsyncCalls = 0
mockInput.client.session.promptAsync = async (opts) => {
promptAsyncCalls += 1
if (promptAsyncCalls === 1) {
firstPromptStartedResolve?.()
}
await promptAsyncBlocked
return originalPromptAsync(opts)
}
const hook = createRalphLoopHook(mockInput as Parameters<typeof createRalphLoopHook>[0])
hook.startLoop("session-123", "Build feature", { maxIterations: 10 })
// when - second idle arrives while first idle processing is still in flight
const firstIdle = hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
await firstPromptStarted
const secondIdle = hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
releasePromptAsync?.()
await Promise.all([firstIdle, secondIdle])
// then - only one continuation should be injected
expect(promptAsyncCalls).toBe(1)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should clear loop state on user abort (MessageAbortedError)", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
@@ -782,8 +834,8 @@ describe("ralph-loop", () => {
expect(hook.getState()).toBeNull()
})
test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
// given - promise appears in an assistant message older than last 3
test("should detect completion even when promise is older than previous narrow window", async () => {
// given - promise appears in an older assistant message with additional assistant output after it
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
@@ -801,9 +853,40 @@ describe("ralph-loop", () => {
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should continue (promise is older than last 3 assistant messages)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
// then - loop should complete because all assistant messages are scanned
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should detect completion when many assistant messages are emitted after promise", async () => {
// given - completion promise followed by long assistant output sequence
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done now <promise>DONE</promise>" }] },
]
for (let index = 1; index <= 25; index += 1) {
mockSessionMessages.push({
info: { role: "assistant" },
parts: [{ type: "text", text: `Post-completion assistant output ${index}` }],
})
}
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should complete despite large trailing output
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should allow starting new loop while previous loop is active (different session)", async () => {
@@ -992,7 +1075,7 @@ Original task: Build something`
expect(promptCalls.length).toBe(0)
expect(hook.getState()).toBeNull()
// API should NOT be called since transcript found completion
expect(messagesCalls.length).toBe(0)
expect(messagesCalls.length).toBe(1)
})
test("should show ultrawork completion toast", async () => {

View File

@@ -23,6 +23,7 @@ export function createLoopStateController(options: {
loopOptions?: {
maxIterations?: number
completionPromise?: string
messageCountAtStart?: number
ultrawork?: boolean
strategy?: "reset" | "continue"
},
@@ -34,6 +35,7 @@ export function createLoopStateController(options: {
loopOptions?.maxIterations ??
config?.default_max_iterations ??
DEFAULT_MAX_ITERATIONS,
message_count_at_start: loopOptions?.messageCountAtStart,
completion_promise:
loopOptions?.completionPromise ??
DEFAULT_COMPLETION_PROMISE,
@@ -93,5 +95,19 @@ export function createLoopStateController(options: {
return state
},
setMessageCountAtStart(sessionID: string, messageCountAtStart: number): RalphLoopState | null {
const state = readState(directory, stateDir)
if (!state || state.session_id !== sessionID) {
return null
}
state.message_count_at_start = messageCountAtStart
if (!writeState(directory, state, stateDir)) {
return null
}
return state
},
}
}

View File

@@ -25,6 +25,8 @@ export function createRalphLoopEventHandler(
ctx: PluginInput,
options: RalphLoopEventHandlerOptions,
) {
const inFlightSessions = new Set<string>()
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
@@ -32,115 +34,128 @@ export function createRalphLoopEventHandler(
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (options.sessionRecovery.isRecovering(sessionID)) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
if (inFlightSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
return
}
const state = options.loopState.getState()
if (!state || !state.active) {
return
}
if (state.session_id && state.session_id !== sessionID) {
if (options.checkSessionExists) {
try {
const exists = await options.checkSessionExists(state.session_id)
if (!exists) {
options.loopState.clear()
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return
}
const transcriptPath = options.getTranscriptPath(sessionID)
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
const completionViaApi = completionViaTranscript
? false
: await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
})
if (completionViaTranscript || completionViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionViaTranscript
? "transcript_file"
: "session_messages_api",
})
options.loopState.clear()
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
return
}
if (state.iteration >= state.max_iterations) {
log(`[${HOOK_NAME}] Max iterations reached`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
options.loopState.clear()
await ctx.client.tui?.showToast?.({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
}).catch(() => {})
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
iteration: newState.iteration,
max: newState.max_iterations,
})
await ctx.client.tui?.showToast?.({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
}).catch(() => {})
inFlightSessions.add(sessionID)
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
if (options.sessionRecovery.isRecovering(sessionID)) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
const state = options.loopState.getState()
if (!state || !state.active) {
return
}
if (state.session_id && state.session_id !== sessionID) {
if (options.checkSessionExists) {
try {
const exists = await options.checkSessionExists(state.session_id)
if (!exists) {
options.loopState.clear()
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return
}
const transcriptPath = options.getTranscriptPath(sessionID)
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
const completionViaApi = completionViaTranscript
? false
: await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
sinceMessageIndex: state.message_count_at_start,
})
if (completionViaTranscript || completionViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionViaTranscript
? "transcript_file"
: "session_messages_api",
})
options.loopState.clear()
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
return
}
if (state.iteration >= state.max_iterations) {
log(`[${HOOK_NAME}] Max iterations reached`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
options.loopState.clear()
await ctx.client.tui?.showToast?.({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
}).catch(() => {})
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
error: String(err),
iteration: newState.iteration,
max: newState.max_iterations,
})
await ctx.client.tui?.showToast?.({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
}).catch(() => {})
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
sessionID,
error: String(err),
})
}
return
} finally {
inFlightSessions.delete(sessionID)
}
return
}
if (event.type === "session.deleted") {

View File

@@ -13,6 +13,7 @@ export interface RalphLoopHook {
options?: {
maxIterations?: number
completionPromise?: string
messageCountAtStart?: number
ultrawork?: boolean
strategy?: "reset" | "continue"
}
@@ -23,6 +24,19 @@ export interface RalphLoopHook {
const DEFAULT_API_TIMEOUT = 5000 as const
function getMessageCountFromResponse(messagesResponse: unknown): number {
if (Array.isArray(messagesResponse)) {
return messagesResponse.length
}
if (typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse) {
const data = (messagesResponse as { data?: unknown }).data
return Array.isArray(data) ? data.length : 0
}
return 0
}
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
@@ -51,7 +65,25 @@ export function createRalphLoopHook(
return {
event,
startLoop: loopState.startLoop,
startLoop: (sessionID, prompt, loopOptions): boolean => {
const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions)
if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") {
return startSuccess
}
ctx.client.session
.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
.then((messagesResponse: unknown) => {
const messageCountAtStart = getMessageCountFromResponse(messagesResponse)
loopState.setMessageCountAtStart(sessionID, messageCountAtStart)
})
.catch(() => {})
return startSuccess
},
cancelLoop: loopState.cancelLoop,
getState: loopState.getState as () => RalphLoopState | null,
}

View File

@@ -36,7 +36,7 @@ async function waitUntil(condition: () => boolean): Promise<void> {
}
describe("ralph-loop reset strategy race condition", () => {
test("should continue iteration when old session idle arrives before TUI switch completes", async () => {
test("should skip duplicate idle while reset iteration handling is in flight", async () => {
// given - reset strategy loop with blocked TUI session switch
const promptCalls: Array<{ sessionID: string; text: string }> = []
const createSessionCalls: Array<{ parentID?: string }> = []
@@ -85,7 +85,7 @@ describe("ralph-loop reset strategy race condition", () => {
},
},
},
} as Parameters<typeof createRalphLoopHook>[0])
} as unknown as Parameters<typeof createRalphLoopHook>[0])
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
@@ -100,14 +100,12 @@ describe("ralph-loop reset strategy race condition", () => {
event: { type: "session.idle", properties: { sessionID: "session-old" } },
})
await waitUntil(() => selectSessionCalls > 1)
selectSessionDeferred.resolve()
await Promise.all([firstIdleEvent, secondIdleEvent])
// then - second idle should not be skipped during reset transition
expect(createSessionCalls.length).toBe(2)
expect(promptCalls.length).toBe(2)
expect(hook.getState()?.iteration).toBe(3)
// then - duplicate idle should be skipped to prevent concurrent continuation injection
expect(createSessionCalls.length).toBe(1)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
})

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