diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a9dad9f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Unified diff evidence has significant single-space context lines. +benchmarks/results/**/*.patch -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af7f2fd..4972627 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,20 @@ jobs: node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" || exit 1 done + regression: + name: deterministic regressions + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --legacy-peer-deps + - run: npm run test:regression + # The gateway is the part that breaks per-platform: it runs on a real Node # binary outside Electron, spawns child processes, and binds a port. Every # macOS bug fixed in 0.9.1 would have been caught by running this on macOS. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ecf172..e394361 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,18 @@ permissions: contents: write jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci --legacy-peer-deps + - run: npm run test:regression build: + needs: regression strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index dea6325..d9b46a6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ build/ *.log .DS_Store data/ +.gstack/ + +# Private session reviews, raw local experiments, and memory drafts. +reviews/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 351c4b5..2a8b93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Optional scoped OpenCode workers using the supported custom-agent API: model-specific instructions, local tools, preserved repository rules, no automatic skills catalog, and a fixed 24-step limit. The full `standard` agent remains the default; `focused` adds an experimental compact prompt. +- Separate engine uncached/cache-read/cache-write/reasoning/request metrics, accumulated across repairs. Paired repository-regression benchmark with withheld evaluator checks and a real-engine local request-size probe. +- Shared persistent coding worker service with eight MCP job tools and an `omniwork-jobs` CLI. Durable batch admission, reconnectable results, provider capacity and pacing, explicit free/paid policy, and paged artifacts. +- Isolated Git snapshots of current edits, bounded repository context, owned-path checks, same-worker repair, and verified patch application against the latest source. +- Multi-client daemon regression coverage and a repeatable opt-in live coding benchmark (`node scripts/benchmark-jobs.js --live`). +- Searchable desktop and MCP model catalogs with free/paid classification, tool support, context sizes, and access requirements. OpenCode engine models include all connected providers; paid models require explicit selection. `npm run providers -- login` uses OpenCode's official authentication flow. +- Structured MCP task outcomes and caller-supplied acceptance checks. Single and parallel delegations share deadlines, cancellation, usage reporting, and recovery; batches queue every task with bounded concurrency. +- Original tool-output retrieval and line-range file reads. Long single-task conversations can compact without orphaning tool results. +- Required deterministic regression jobs for CI and release builds. + +### Fixed +- Failed writes and intermediate commentary no longer masquerade as completed delegations. Summary-only PASS/FAIL grading is replaced by explicit check results or an unverified label. +- Workers inherit parent permissions, memory, and skills. More than eight tasks are queued rather than silently dropped. +- Rate-limit cooldowns survive subsequent steps, honor Retry-After, and avoid duplicate streaming retries. Failed exact edits trigger escalation. +- Default fallback selection excludes arbitrary paid-only catalog entries; model switches refresh utility and tier policy. +- Lossy gateway compression is now opt-in per request. The earlier 68% figure did not establish answer correctness; default output paging preserves retrievable source and evidence. + ## [0.12.0] — 2026-09-17 ### Added diff --git a/README.md b/README.md index 3388b6d..15ff34a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ # OmniWork -**A free, local, Claude Code–style coding agent — with a whole AI gateway baked in.** +**A local coding harness for Codex, Claude Code, and the desktop—with durable parallel workers and verified patches.** -Download it, open a folder, and start building. No API key. No login. No config. -Free models work the second you launch — and you can run a whole *team* of agents at once. +Submit a batch once. OmniWork schedules workers, isolates their edits, runs acceptance checks, +and returns patches. Use available free models, local inference, or explicitly selected account models. [![Release](https://img.shields.io/github/v/release/VedSoni-dev/omniwork?color=8bb072&label=release)](https://github.com/VedSoni-dev/omniwork/releases/latest) [![License: MIT](https://img.shields.io/badge/license-MIT-d97757.svg)](LICENSE) @@ -24,10 +24,9 @@ Free models work the second you launch — and you can run a whole *team* of age ## Why OmniWork -Every other AI coding tool makes you bring an API key, sign in, or pay per token. OmniWork -doesn't. It bundles **[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — a local AI -gateway fronting 278+ providers (90+ free) — as an in-app sidecar. On launch it starts the -gateway, points its agent at it, and you're coding with **zero setup**. +OmniWork combines **[OmniRoute](https://github.com/diegosouzapw/OmniRoute)**, the official +OpenCode engine, and its own coding loop. A shared worker service keeps engines warm across +MCP clients, queues work within provider capacity, and preserves jobs when a client disconnects. Then it goes further than a single chat agent: run **many agents in parallel**, let one agent **fan work out to subagents**, plug in **MCP tools**, and even use OmniWork as a **token-saving @@ -37,7 +36,9 @@ delegate** *inside* Claude Code or Codex. | | | |---|---| -| 🆓 **Free models** | `auto` routes the gateway's free pool out of the box, and one click connects a free provider that *stays* up (OpenRouter, Ollama Cloud, Groq, Gemini…) — see [Free models that stay up](#free-models-that-stay-up). | +| 🆓 **Model access** | Search connected gateway and OpenCode models. Durable jobs select catalogued free models by default; account models require explicit selection and `allow_paid`. Provider quotas still apply. | +| ⚡ **Durable workers** | Submit up to 100 jobs at once, share a warm service across MCP clients, reconnect to results, and page artifacts on demand. | +| ✅ **Verified patches** | Git snapshots preserve current edits, owned paths constrain accepted changes, and acceptance checks run before and during integration. | | 🖥️ **Claude Code UI** | A clean terminal: `⏺`/`⎿` tool calls, `✻` thinking, `>` prompt, `@`-file mentions, per-turn time + token counts. | | 📁 **Projects & memory** | Sessions live under projects. Durable `MEMORY.md` per project plus a global scope — it remembers what you teach it. | | 🎓 **Skills** | Claude Code–compatible `SKILL.md` packs, loaded on demand. Ships with Anthropic's public set; the agent can write and install more. | @@ -50,7 +51,7 @@ delegate** *inside* Claude Code or Codex. | 🔗 **ACP agent** | Speaks Agent Client Protocol, so OpenClaw / acpx / Zed can drive OmniWork as a full coding agent. | | ✂️ **Select to quote** | Highlighting output copies it instantly; a pill (or `⌘L`) quotes it into the prompt as `> ` lines. | | 📋 **Collapsed pastes** | Paste 300 lines and the prompt shows `[Pasted text #1 +322 lines]` — the model still gets all of it. | -| 🔒 **100% local** | Everything runs on your machine. The gateway never phones home. | +| 🔒 **Local orchestration** | Jobs, workspaces, and routing run locally. Cloud model providers receive prompts; local inference is available through connected local providers. | | 🧩 **MIT, hackable** | Plain CommonJS, no build step for the UI. Fork it, ship it, sell it. | ## Download @@ -92,16 +93,43 @@ xattr -dr com.apple.quarantine /Applications/OmniWork.app ## Use OmniWork *inside* Claude Code / Codex — token saver 🪙 -Don't want to fully switch? Keep your premium agent as the orchestrator and let it **delegate the -token-heavy grunt work to OmniWork's free models.** OmniWork ships an MCP server exposing these tools: +Keep Codex or Claude Code as the orchestrator and submit independent coding tasks to the shared +worker service. These changes are available from this checkout; released installers may lag behind. -- `delegate(task, cwd?, model?, fallback_models?)` — OmniWork does the subtask autonomously (free models by default) and returns a summary + changes -- `delegate_parallel(tasks[], cwd?, model?, fallback_models?)` — fan a batch out to parallel subagents -- `list_models()` — what the gateway can route right now, so you can pin a model and name what to try if it fails -- `list_providers()` / `connect_provider(provider)` — see which free providers are connected and connect one (OpenRouter opens the browser; `local` registers Ollama & co.). It takes no API key on purpose: keys are pasted in the app or the terminal, never by a model. +- `jobs_submit` returns durable IDs for up to 100 tasks. Supply `allowed_paths`, `context_files`, and acceptance `checks`. +- `jobs_wait` waits for results; `jobs_get` returns compact evidence; `jobs_read` pages patches and full results. +- `jobs_apply` reruns checks against the current source before applying an isolated patch. +- `jobs_list`, `jobs_cancel`, and `jobs_status` recover jobs and manage the shared queue. +- `list_models` searches connected model catalogs. Omit the job model for managed free selection, or explicitly select an account model with `policy: "allow_paid"`. -Your expensive model spends tokens on the hard reasoning; OmniWork burns **free** tokens on the -mechanical work, in the same project folder. +The older `delegate` and `delegate_parallel` tools remain available. They edit the supplied directory +directly and run outside the durable queue's capacity limits. + +Try the CLI from this checkout with a `tasks.json` file: + +```json +{ + "cwd": "/absolute/path/to/your/git-project", + "request_id": "slug-fix-001", + "tasks": [{ + "task": "Fix slug generation for empty input and repeated separators.", + "allowed_paths": ["src/slug.js"], + "context_files": ["src/slug.js", "test/slug.test.js"], + "checks": ["node --test test/slug.test.js"] + }] +} +``` + +```sh +npm run jobs -- submit tasks.json +npm run jobs -- wait +npm run jobs -- get +npm run jobs -- apply +``` + +Replace the paths and check command with those in your project. The default Git worktree snapshot +includes current nonignored changes; ignored dependencies need an explicit `setup` command. +See the [worker service guide](docs/worker-service.md) for configuration, contracts, and limits. ### Set it up in one command @@ -139,31 +167,60 @@ supports it. Add to `.mcp.json` (or run `claude mcp add`): -Keep the desktop app running and delegated calls reuse its warm gateway; otherwise the first call -boots one, which takes longer. +For Codex, register the same stdio server: + +```sh +codex mcp add omniwork -- node /absolute/path/to/omniwork/electron/mcp-server.js +``` + +Durable jobs start a background service automatically; the desktop app need not stay open. +Later clients reuse that service and its engines. The first model request can still incur provider +or engine startup latency. + +### Get useful work per token -### When delegating is actually worth it +Split work along independent file ownership boundaries. Give each worker a self-contained contract, +a few relevant files, and checks that distinguish a correct solution from a plausible one. Wait for +compact evidence, inspect the patch, then apply it. One repair attempt is enabled by default and +shares the original deadline and observed token budget. Stronger models can be selected explicitly +for tasks that need them. -Delegation is not free — there's ~25–30 s of overhead per call, and the delegated agent starts -**cold**, with no view of your conversation. It pays for bulk mechanical work, independent chunks -you can fan out with `delegate_parallel`, and read-heavy research you want kept out of context. It -does not pay for small edits, work that needs conversation context, or anything where precision -matters — free models drift on instruction details. +For bounded local coding tasks, select `engine_profile: "scoped"` to omit the automatic skills catalog and unrelated tools while retaining model-specific coding instructions and project rules. The full `standard` profile remains the default; the compact-prompt `focused` profile is experimental. See [coding benchmarks](docs/coding-benchmarks.md) for measured savings and quality limits. -Treat the returned summary as a **claim, not evidence**, and verify before calling the work done. -`npm run connect` installs this guidance so your agent applies it automatically. +This reduces repeated setup and transcript copying. It does not create unlimited provider quota +or guarantee that a free model solves every task. Check quality still determines what “verified” means. ## Fewer tokens per task 🗜️ -Free tiers are the same for everyone now, so OmniWork competes on how little it spends finishing a task. The gateway compresses tool output by default (a real grep result: **9,812 → 3,170 tokens**), housekeeping and mechanical steps run on the fast free pool while real reasoning escalates to the coding pool, a rate-limited provider rotates instead of draining, and every session reuses one id so the gateway caches its prefix. All on by default, all opt-outable: +OmniWork limits repeated context without silently rewriting source or evidence. Tool results larger than 12k characters are paged, and `read_output` retrieves the original text by character offset. `read_file` supports targeted line ranges (200 lines by default, up to 400). Long single-task runs compact at complete tool-cycle boundaries, preserving the task constraints. + +Single and parallel MCP delegations return a structured result with `status` (`completed`, `partial`, `failed`, or `cancelled`), `reason`, successful `changes`, actual model attempts, token usage when available, and verification results. An assistant summary alone is **unverified**. Supply `checks: ["npm test"]` to run explicit acceptance commands; failed checks produce an error result. These commands execute in the requested workspace. Parallel requests accept up to 100 tasks, queue four at a time, and return every result. MCP cancellation and deadlines stop active agents and shell commands. In-loop workers inherit the parent's permissions, memory, and skills. + +| Environment variable | Default | Effect | +|---|---|---| +| `OMNIWORK_COMPRESSION` | off | `rtk` explicitly opts into upstream lossy compression; default sends `off` on every request, including reused gateways | +| `OMNIWORK_MODEL_TIERS` | on | `off` disables fast-to-coding escalation for `auto` | +| `OMNIWORK_UTILITY_MODEL` | fast for auto; selected model for pinned choices | Explicit override for titles, memory, and compaction | +| `OMNIWORK_DELEGATE_TIMEOUT_MS` | 600000 | Deadline for each MCP task including acceptance checks | +| `OMNIWORK_DATA_DIR` | platform app directory | Alternate headless data directory for isolated environments | + +Rate-limit cooldowns persist across steps and are shared by workers using the same gateway. `Retry-After` is honored. Session IDs support affinity, but cache savings require provider usage evidence; they are not assumed. Raw output is retained in a bounded session-local store (8 million characters); expired references require rerunning the tool. Commands producing over 2 million characters are stopped with a visible notice. + +### More models through your existing accounts + +Open **Models & providers** in the sidebar to search models, filter free choices or advertised tool support, see context sizes, and select a model. **Test** sends a small readiness prompt at that model's rates. It does not execute tools or establish coding quality. Some providers reject restricted probes even when normal engine tasks work; failures show that distinction. Catalog listings begin untested, and availability can change. + +OmniWork now exposes **all providers connected to its OpenCode engine**, including custom providers, instead of only a fixed list of free Zen models. Existing free IDs such as `opencode/nemotron-3.5-lightning-free` remain valid. Connected account models use `opencode//`, preventing provider collisions. Paid or unknown-cost models require explicit selection and are never used as automatic engine fallback. + +```bash +npm run providers -- login +``` + +This opens the official OpenCode CLI authentication flow in your terminal. Complete login, then refresh the model browser. Keys remain in OpenCode's own credential store. If a running engine has cached old provider configuration, restart OmniWork after connecting. [OpenCode provider setup](https://opencode.ai/docs/providers/) covers account and custom-endpoint configuration. No new client-identity synthesis is added by this feature; OmniRoute remains the existing gateway dependency. -| Env | Default | Effect | -|-----|---------|--------| -| `OMNIWORK_COMPRESSION` | on | `off` disables gateway-side tool-output compression | -| `OMNIWORK_MODEL_TIERS` | on | `off` runs `auto` on one tier instead of fast-then-escalate | -| `OMNIWORK_UTILITY_MODEL` | `auto/best-fast` | model for titles, memory, compaction summaries | +Run `npm run test:regression` before shipping. CI and release builds now require the deterministic suites, including failure reporting, nine-task batches, compaction, cancellation, free fallback selection, and output retrieval. -## Free models that stay up 🆓 +## Connect free model providers 🆓 The gateway's built-in keyless pool is a set of unofficial endpoints that providers shut off without notice — on a bad day every one of them is gone and `auto` returns 503. So OmniWork @@ -171,17 +228,17 @@ makes the durable kind of free easy: real free tiers behind a free account, no c | Provider | Free tier | Connect | |---|---|---| -| **OpenRouter** | 20 free models, 18 with tool calling · 20 req/min, 50 req/day (1,000/day after a one-time $10 top-up) | one click — browser sign-in, no key to paste | +| **OpenRouter** | Free and paid catalogs; account limits apply | one click — browser sign-in, no key to paste | | **Ollama Cloud** | DeepSeek V4, Kimi K2.6, GLM 5.1, Gemma 4 on a "light usage" tier | paste a key | | **Kilo Gateway** | `kilo-auto/free` router + Nemotron / MiniMax `:free` | paste a key | | **Groq** | gpt-oss-120b at 30 req/min, 1,000 req/day; Qwen 3 32B | paste a key | | **Cerebras**, **NVIDIA NIM**, **Gemini**, **Mistral** | free developer tiers | paste a key | -| **OpenCode engine** | Zen's free models with **no account**: Nemotron 3.5 Lightning (default, answers in seconds), MiMo V2.5, Big Pickle, Ling 3.0 Flash, Nemotron 3 Ultra (strongest, ~100 s a step) | ships with a clone and with the desktop app; otherwise one click downloads it (~45 MB) | +| **OpenCode engine** | Zen's current free models, plus models from connected accounts; use the live catalog | ships with a clone and with the desktop app; otherwise one click downloads it (~45 MB) | | **Ollama / LM Studio / llama.cpp / vLLM** | whatever runs on your machine | auto-detected | Three ways in, all the same code path: -- **App** — *🆓 free models* in the sidebar. Connected providers become every session's fallback chain on the spot. +- **App** — *Models & providers* in the sidebar. Connected providers become every session's fallback chain on the spot. - **Terminal** — `npm run providers` shows status; `npm run providers connect openrouter` opens the browser; `npm run providers connect groq `; `npm run providers connect local`; `npm run providers connect opencode` downloads the engine if a clone or installer didn't bring it. Also `npx omniwork-providers`. - **From a harness** — the MCP server has `list_providers` / `connect_provider`; the ACP server offers `openrouter` and `local` as auth methods. @@ -189,8 +246,7 @@ A pasted key is exercised once before it is kept, so a bad key never lands in th connected becomes the default fallback chain for the MCP and ACP servers (unless you set `OMNIWORK_MODEL_FALLBACKS` yourself), ordered strongest coder first, local last. -**The OpenCode engine is the floor.** OpenCode Zen only serves its free tier to OpenCode itself, so -OmniWork doesn't pretend to be OpenCode — it runs it. And nobody has to install anything or touch +**The OpenCode engine provides another execution path.** OmniWork runs the official engine and discovers its providers. Nobody has to install anything or touch their PATH: `npm install` brings OpenCode in as an optional dependency (`opencode-ai`, the binary for your platform), the desktop installers stage it per platform at build time, and if it is missing anyway one click fetches the same npm package (~45 MB) into OmniWork's data folder — @@ -202,7 +258,7 @@ runs a session on OpenCode's own headless server (`opencode serve`, the same thi drive) and its own tools, scoped to your workspace, streamed back as OmniWork events — text as text, the model's reasoning on its own lane, tool calls as tool calls. Pick one in the model menu, pass `model: "opencode/nemotron-3.5-lightning-free"` to `delegate`, or do nothing: when no -gateway model answers, the turn finishes on the engine and says so. What you give up there is +gateway model answers before tools have run, MCP delegation can retry on a free engine model and records the attempt. Availability remains provider-dependent. What you give up there is OmniWork's own skills and memory — OpenCode runs its tools, not ours. ## Drive OmniWork from OpenClaw, Zed, or any ACP harness 🔌 diff --git a/benchmarks/coding-cases.js b/benchmarks/coding-cases.js new file mode 100644 index 0000000..f95dc9e --- /dev/null +++ b/benchmarks/coding-cases.js @@ -0,0 +1,49 @@ +"use strict"; +// Controlled regressions in real OmniWork modules. Evaluator bodies remain outside +// worker checkouts. These are mutation tasks, not historical SWE-bench issues. +const base = `const assert=require('node:assert/strict'),path=require('node:path'),fs=require('node:fs'),os=require('node:os'); const root=path.resolve(process.argv[2]||process.cwd()); const load=f=>require(path.join(root,'electron',f));`; +const cases = [ + { + id:'output-paging', files:['electron/tool-output.js'], + task:'Repair original-output retrieval in ToolOutputStore. Long results must retain every character for later paging; read offsets and next-offset metadata must agree and must not skip or duplicate characters. Preserve bounded previews, default/clamped page sizes, short outputs, task-local references, and eviction accounting.', + mutations:[['electron/tool-output.js','this.entries.set(id, text);','this.entries.set(id, text.slice(0, limit));'],['electron/tool-output.js','text.slice(start, start + count)','text.slice(start + 1, start + count + 1)']], + public:String.raw`const {ToolOutputStore}=load('tool-output');const s=new ToolOutputStore();assert.equal(s.capture('short',100),'short');const raw='abcdefghijklmnop'.repeat(30);s.capture(raw,80);const id=[...s.entries.keys()][0];assert(s.read(id,100,30).startsWith(raw.slice(100,130)));`, + hidden:String.raw`const {ToolOutputStore}=load('tool-output');for(const n of [81,513,8193]){const s=new ToolOutputStore();const raw=Array.from({length:n},(_,i)=>String.fromCharCode(33+i%90)).join('');const preview=s.capture(raw,80);const id=[...s.entries.keys()][0];assert(preview.includes(id));assert.equal(s.entries.get(id),raw);let rebuilt='';for(let start=0;start 4 ? boundaries[boundaries.length - 4] : -1;','return -1;'],['electron/compactor.js','const originalTask = priorTask || task?.content;','const originalTask = task?.content;']], + public:String.raw`const c=load('compactor');const m=[{role:'system',content:'system'},{role:'user',content:'Keep API stable'}];for(let i=0;i<12;i++){m.push({role:'assistant',content:'x'.repeat(1000),tool_calls:[{id:'t'+i,function:{name:'read',arguments:'{}'}}]},{role:'tool',tool_call_id:'t'+i,content:'y'.repeat(1000)});}assert(c.cutIndex(m)>1);`, + hidden:String.raw`(async()=>{const c=load('compactor'),task='ORIGINAL_CONTRACT_88: Preserve API and never change tests';let m=[{role:'system',content:'SYS'},{role:'user',content:task}];const add=()=>{for(let i=0;i<15;i++){const id='t'+m.length;m.push({role:'assistant',content:'x'.repeat(1200),tool_calls:[{id,function:{name:'read',arguments:'{}'}}]},{role:'tool',tool_call_id:id,content:'y'.repeat(1400)});}};for(let round=0;round<3;round++){add();const before=c.estimateTokens(m);m=(await c.compact(m,async()=>{if(round===2)throw Error('offline');return 'Earlier work summarized';},{budget:12000})).messages;assert.equal(m[0].content,'SYS');assert(m.some(x=>String(x.content).includes(task)));assert(c.estimateTokens(m){console.error(e);process.exitCode=1});`, + }, + { + id:'repair-accounting',files:['electron/execution.js'], + task:'Repair executeTask verification and usage accounting across repair turns. A failed caller acceptance check must trigger a bounded repair in the same agent; failed final checks cannot be reported completed. Sum per-turn cumulative usage once, including detailed cache/request counts, across repairs. Preserve cancellation, original timeout, changes, and check history.', + mutations:[['electron/execution.js','if (!verification.some(c => !c.ok)) break;','if (verification.length) break;'],['electron/execution.js','completed = false; lastUsage = { inTokens: 0, outTokens: 0 };\n const repair','completed = false; // previous usage retained\n const repair']], + public:String.raw`(async()=>{let sends=0;const r=await load('execution').executeTask({task:'fix',repairAttempts:1,checks:['check'],createAgent:async emit=>({model:'fake',send:async()=>{sends++;emit('done',{inTokens:100,outTokens:10});}}),runCheck:async()=>({ok:sends===2,exitCode:sends===2?0:1,text:'bad'})});assert.equal(sends,2);assert.equal(r.usage.inTokens,200);assert.equal(r.verification.status,'passed');})().catch(e=>{console.error(e);process.exitCode=1});`, + hidden:String.raw`(async()=>{const {executeTask}=load('execution');for(const repairs of [0,1,2]){let n=0;const r=await executeTask({task:'fix',repairAttempts:repairs,checks:['a','b'],createAgent:async emit=>({model:'fake',send:async()=>{n++;const usage={inTokens:80,outTokens:10,uncachedInTokens:30,cacheReadTokens:50,cacheWriteTokens:0,reasoningTokens:2,modelRequests:1};emit('stats',usage);emit('done',usage);}}),runCheck:async()=>({ok:n>=3,exitCode:n>=3?0:1,text:'expected fixed'})});assert.equal(n,repairs+1);assert.equal(r.usage.inTokens,n*80);assert.equal(r.usage.cacheReadTokens,n*50);assert.equal(r.usage.modelRequests,n);assert.equal(r.checkHistory.length,Math.min(2,n));assert.equal(r.status,repairs===2?'completed':'failed');assert.equal(r.verification.status,repairs===2?'passed':'failed');}})().catch(e=>{console.error(e);process.exitCode=1});`, + }, + { + id:'workspace-boundaries',files:['electron/job-workspace.js'], + task:'Repair workspace ownership and path validation. Reject parent traversal and .git path components on either slash style. A directory ownership prefix ending in / must cover its descendants but never a similarly named sibling. Exact file ownership must remain exact. Preserve dirty-source snapshot isolation, patch artifacts, and the source index.', + mutations:[['electron/job-workspace.js','p === ".." || p === ".git"','p === ".git"'],['electron/job-workspace.js','f.startsWith(p)','f.startsWith(p.replace(/\\/$/, ""))']], + public:String.raw`const {relativeFile}=load('job-workspace');assert.throws(()=>relativeFile('../escape'));assert.throws(()=>relativeFile('a/../../escape'));assert.equal(relativeFile('src/file.js'),'src/file.js');`, + hidden:String.raw`(async()=>{const w=load('job-workspace');for(const p of ['../x','a/../x','a\\\\..\\\\x','.git/config','a/.git/x'])assert.throws(()=>w.relativeFile(p));const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ow-eval-scope-')),repo=path.join(dir,'repo'),store=path.join(dir,'store');fs.mkdirSync(repo);fs.mkdirSync(store);try{await w.git(repo,['init','-q']);await w.git(repo,['config','user.email','test@localhost']);await w.git(repo,['config','user.name','Test']);fs.writeFileSync(path.join(repo,'base'),'base');await w.git(repo,['add','.']);await w.git(repo,['commit','-qm','base']);fs.writeFileSync(path.join(repo,'base'),'dirty');const before=await w.git(repo,['diff','--cached']);const tree=await w.prepare({cwd:repo,isolation:'worktree'},store);fs.mkdirSync(path.join(tree.cwd,'src'));fs.mkdirSync(path.join(tree.cwd,'src-other'));fs.writeFileSync(path.join(tree.cwd,'src','ok'),'ok');fs.writeFileSync(path.join(tree.cwd,'src-other','bad'),'bad');const a=await w.collect(tree,store,['src/']);assert.deepEqual(a.outsideScope,['src-other/bad']);assert.equal(fs.readFileSync(path.join(repo,'base'),'utf8'),'dirty');assert.equal(await w.git(repo,['diff','--cached']),before);await w.git(repo,['worktree','remove','--force',tree.tree]);}finally{fs.rmSync(dir,{recursive:true,force:true});}})().catch(e=>{console.error(e);process.exitCode=1});`, + }, + { + id:'durable-admission',files:['electron/job-service.js'], + task:'Repair durable job admission and shared provider accounting. A job must reach durable storage before it can be scheduled; persistence failure must not leave executable in-memory jobs or deduplication keys. Preserve batch validation and idempotent retries. OpenCode Zen and Go share one provider capacity bucket rather than independent slots by model or alias.', + mutations:[['electron/job-service.js','this.save(job); this.jobs.set(id, job); this.keys.set(key, id);','this.jobs.set(id, job); this.keys.set(key, id); this.save(job);'],['electron/job-service.js','return model.source === "OpenCode" && /^opencode/.test(model.provider || model.providerID) ? "opencode" : model.provider || model.providerID || model.id.split("/")[0];','return model.id;']], + public:String.raw`const {providerKey}=load('job-service');assert.equal(providerKey({source:'OpenCode',provider:'opencode-go',id:'opencode/a'}),'opencode');`, + hidden:String.raw`(async()=>{const {JobService,providerKey,normalize}=load('job-service');const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ow-eval-admit-'));let s;try{s=new JobService({dir,catalog:async()=>[],run:async()=>{throw Error('must not run')},runCheck:async()=>({ok:true})});const save=s.save;s.save=()=>{throw Error('disk full')};assert.throws(()=>s.submit({request_id:'retry',tasks:[{cwd:root,task:'work'}]}),/disk full/);assert.equal(s.jobs.size,0);assert.equal(s.keys.size,0);s.save=save;const b=s.submit({request_id:'retry',tasks:[{cwd:root,task:'work'}]});assert(fs.existsSync(path.join(dir,b.jobs[0].id,'job.json')));assert.equal(s.submit({request_id:'retry',tasks:[{cwd:root,task:'work'}]}).jobs[0].id,b.jobs[0].id);assert.throws(()=>s.submit({request_id:'retry',tasks:[{cwd:root,task:'different'}]}));for(const provider of ['opencode','opencode-go'])assert.equal(providerKey({source:'OpenCode',provider,id:'opencode/'+provider+'/coder'}),'opencode');assert.equal(providerKey({provider:'local',id:'local/a'}),'local');assert.throws(()=>normalize({cwd:root,task:'x',policy:'allow_paid'}));}finally{await s?.stop();fs.rmSync(dir,{recursive:true,force:true});}})().catch(e=>{console.error(e);process.exitCode=1});`, + }, +]; +module.exports = { cases, program: body => base+'\n'+body+'\n' }; diff --git a/benchmarks/results/2026-09-20/RESULTS.md b/benchmarks/results/2026-09-20/RESULTS.md new file mode 100644 index 0000000..553bcc1 --- /dev/null +++ b/benchmarks/results/2026-09-20/RESULTS.md @@ -0,0 +1,85 @@ +# Engine overhead and coding benchmark results + +This report records the engine optimization experiment on OpenCode 1.18.31. Host-specific temporary workspace prefixes in the published JSON are replaced with ``. + +## Decision + +Keep `standard` as the worker default. Offer `scoped` for explicitly bounded local coding workloads, and retain `focused` as an experimental compact-prompt profile. Reducing the first request is useful, but cannot by itself establish coding quality or end-to-end efficiency. The small sample and task-level regressions do not justify changing every user's workflow. + +Scoped retains the upstream model-specific coding prompt and repository instructions, while omitting the automatic skills catalog and unrelated tools. Both restricted profiles retain read/search/edit/shell tools and use a 24-step conversation cap. The profile uses OpenCode's supported agent configuration; it does not modify provider identity or user configuration files. + +Batch clients can opt in with `defaults: { "engine_profile": "scoped" }` in `jobs_submit`. Individual task overrides remain available. See [worker profiles](../../../docs/worker-service.md#opencode-worker-profiles). + +## Direct request measurement + +The real installed OpenCode 1.18.31 engine sent identical requests to a local synthetic inference endpoint. The fixture included repository instructions and 20 unrelated skills; the local global skill configuration also contributes to the baseline. No paid or real inference was used by this probe. + +| Profile | Serialized messages + tool schemas | Reduction | +| --- | ---: | ---: | +| Standard | 86,634 characters | — | +| Scoped | 27,136 characters | 68.7% | +| Focused | 19,366 characters | 77.6% | + +All three retained the repository instruction marker. Both restricted profiles omitted the fixture skills and recursive/unrelated tools. These are character counts in this environment, not token counts or a universal savings percentage. [Raw request measurements](request-measurement.json). + +## Live coding comparison + +The completed paired run uses six controlled regressions in actual OmniWork modules, one attempt per profile per task, the same free Big Pickle model, a five-minute deadline, and a 600,000 observed input/output token budget per job. Acceptance requires withheld checks, successful application through the worker service, and a second withheld check on the integrated source. A passing public smoke test alone does not count. + +| Metric | Standard | Scoped | +| --- | ---: | ---: | +| Accepted fixes | 3/6 | 4/6 | +| Total observed input | 2,814,238 | 1,924,518 | +| Uncached input | 213,534 | 117,926 | +| Cache-read input | 2,600,704 | 1,806,592 | +| Output, including reasoning | 103,474 | 80,217 | +| Observed model requests | 65 | 73 | +| Input per accepted fix, failures included | 938,079 | 481,130 | +| Median job execution | 143.7 s | 124.8 s | + +Scoped reduced total input by **31.6%**, uncached input by **44.8%**, and input per accepted fix by **48.7%** in this run. Median execution was 13.2% lower, but observed model requests increased from 65 to 73. This is a result for this workload, not a guaranteed speedup. All 12 jobs returned usage; four budget-limited jobs may have additional unobserved in-flight usage. + +| Task | Standard: accepted / input / requests | Scoped: accepted / input / requests | +| --- | --- | --- | +| output-paging | Yes / 222,051 / 8 | Yes / 144,335 / 9 | +| catalog-policy | Yes / 277,041 / 10 | Yes / 105,107 / 6 | +| compaction-continuity | Yes / 465,039 / 11 | Yes / 540,320 / 21 | +| repair-accounting | No / 609,291 / 12 | No / 181,770 / 8 | +| workspace-boundaries | No / 591,190 / 13 | Yes / 360,419 / 15 | +| durable-admission | No / 649,626 / 11 | No / 592,567 / 14 | + +Both profiles changed the repair-history semantics and failed the withheld history-length check; standard also exhausted its observed token budget. Standard workspace-boundary work and both durable-admission jobs exhausted their budgets before successful completion. The standard workspace patch and scoped admission patch passed withheld tests, but their jobs did not meet the full completion-and-integration acceptance rule. Standard admission also failed withheld evaluation. Scoped compaction used 21 requests versus 11, consumed more total input, and took 177.3 seconds versus 127.8 seconds despite its smaller initial request. + +The service admitted all 12 tasks in 41 ms; that excludes service startup and task execution. The full paired run took 13 minutes 11 seconds with at most two jobs active for the provider. The runner exited nonzero because five jobs were unaccepted, while the report correctly records `complete: true` and no unfinished jobs. This is a completed experiment with model failures, not a crashed benchmark. + +[Raw paired results](scoped-comparison.json) and [individual patches](scoped-comparison.json.patches/) preserve the evidence. Budget exhaustion is recorded as a failed job; neither a plausible answer nor a passing patch alone is counted as an accepted result. + +## What changed + +- Added explicit standard/scoped/focused selection in durable task contracts and OpenCode requests. Permissions restrict the tool catalog at both agent and session level so broad session rules cannot silently restore it. +- Separated uncached input, cache reads/writes, reasoning and observed model requests. Repeated events are deduplicated and repair usage accumulates. +- Added six regression families: output paging, catalog policy, compaction continuity, repair accounting, workspace boundaries, and durable admission. Every reference passes; every seeded regression fails. Preflight runs the exact public command workers receive. +- Added atomic benchmark reports, interrupted-run checkpoints, read-only transport retries, retained workspaces on interruption, and resume without duplicated admission or blind patch replay. +- Included failed work in tokens per accepted fix and exposed missing usage separately. Service configuration fingerprints now include engine configuration, preventing incompatible daemon reuse. + +## Validation + +All 15 regression suites pass. The additional synthetic-inference recovery test interrupts the actual benchmark runner/daemon, resumes the original IDs, retains failures, and checks cleanup only after completion. No live model is needed for the deterministic suite. Syntax and whitespace checks pass. + +## Interpretation and remaining work + +The fixtures are controlled regressions chosen by the implementer, not organic issue samples or SWE-bench results. One model and one repetition cannot establish statistical significance or general coding superiority. The profiles differ in tool availability, catalog content, and step cap; this experiment does not isolate those effects separately. + +Observed total input includes cache reads/writes and repeated history across requests. It is not unique context size, billable input, or additional provider entitlement. Partial jobs can have unobserved in-flight usage. Engine child/background sessions are not a complete billing ledger. Provider quotas still apply. + +Earlier compact-prompt trials and interrupted comparisons were excluded from this completed paired comparison. The original pilot had a public-command path bug and is explicitly marked invalid. The old three-task smoke comparison used different runs and cannot establish a controlled speedup. + +The next quality gate is pinned historical issues from independent repositories, multiple models and repeated trials, with fixed held-out tests and existing-test preservation. Request-count growth, repeated source reads, and long tool outputs should be measured per task before further prompt removal. Do not remove model-specific guidance based solely on a smaller first request. + +## Research references + +- [OpenCode custom agents](https://opencode.ai/docs/agents/): supported prompt, permissions, and step configuration. +- [Pinned request construction, v1.18.31](https://github.com/anomalyco/opencode/blob/v1.18.31/packages/opencode/src/session/llm/request.ts), [system prompt construction](https://github.com/anomalyco/opencode/blob/v1.18.31/packages/opencode/src/session/system.ts), and [tool resolution](https://github.com/anomalyco/opencode/blob/v1.18.31/packages/opencode/src/session/tools.ts): reviewed to identify where agent prompts, skill catalogs, and merged permissions affect input. +- [SWE-bench evaluation](https://www.swebench.com/SWE-bench/guides/evaluation/) and [Aider benchmarks](https://aider.chat/docs/benchmarks.html): references for patch-and-test evaluation. This local corpus does not reproduce either benchmark. + +Reproduction commands and evaluator limitations are in [coding benchmarks](../../../docs/coding-benchmarks.md). diff --git a/benchmarks/results/2026-09-20/request-measurement.json b/benchmarks/results/2026-09-20/request-measurement.json new file mode 100644 index 0000000..3a0cd68 --- /dev/null +++ b/benchmarks/results/2026-09-20/request-measurement.json @@ -0,0 +1,62 @@ +{ + "scope": "Real OpenCode HTTP requests to a local synthetic inference server; character counts, not token estimates", + "rows": [ + { + "profile": "standard", + "systemChars": 62668, + "toolSchemaChars": 22371, + "messageChars": 64263, + "tools": [ + "bash", + "edit", + "glob", + "grep", + "question", + "read", + "skill", + "task", + "todowrite", + "webfetch", + "write" + ], + "preservesProjectRule": true, + "hasFixtureSkill": true + }, + { + "profile": "focused", + "systemChars": 6854, + "toolSchemaChars": 12341, + "messageChars": 7025, + "tools": [ + "bash", + "edit", + "glob", + "grep", + "read", + "write" + ], + "preservesProjectRule": true, + "hasFixtureSkill": false + }, + { + "profile": "scoped", + "systemChars": 14520, + "toolSchemaChars": 12341, + "messageChars": 14795, + "tools": [ + "bash", + "edit", + "glob", + "grep", + "read", + "write" + ], + "preservesProjectRule": true, + "hasFixtureSkill": false + } + ], + "reduction": { + "focused": 0.7764618971766282, + "scoped": 0.6867742456772168 + } +} diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json b/benchmarks/results/2026-09-20/scoped-comparison.json new file mode 100644 index 0000000..bd064ef --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json @@ -0,0 +1,954 @@ +{ + "testedAt": "2026-09-21T01:46:28.195Z", + "startedAt": 1789955188196, + "complete": true, + "sourceHash": "19345c7ad1f9f8ada5dc3ad60ce1775dc347d09b3fbcdb371e685c11450f0220", + "engineVersion": "1.18.31", + "model": "opencode/big-pickle", + "repeats": 1, + "profiles": [ + "standard", + "scoped" + ], + "scope": "Controlled regressions in actual OmniWork modules, alternating profile order, withheld evaluator tests. Not SWE-bench or an unbiased sample of organic issues.", + "timeoutMs": 300000, + "preflight": [ + { + "id": "output-paging", + "goldPassed": true, + "mutantRejected": true + }, + { + "id": "catalog-policy", + "goldPassed": true, + "mutantRejected": true + }, + { + "id": "compaction-continuity", + "goldPassed": true, + "mutantRejected": true + }, + { + "id": "repair-accounting", + "goldPassed": true, + "mutantRejected": true + }, + { + "id": "workspace-boundaries", + "goldPassed": true, + "mutantRejected": true + }, + { + "id": "durable-admission", + "goldPassed": true, + "mutantRejected": true + } + ], + "rows": [ + { + "id": "9a2b3f5e-d1a4-488d-a048-148d2a39573f", + "case": "output-paging", + "round": 0, + "profile": "standard", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "All checks pass. Fixed `electron/tool-output.js`:\n\n- **Store the full original** (`this.entries.set(id, text)`) instead of `text.slice(0, limit)` so later offsets retrieve every character; eviction accounting now matches the stored lengths (`bytes` stays the true sum).\n- **Removed the off-by-one** in `read` (`slice(start, start + count)`), so pages align exactly with the advertised offsets and `next offset` \u2014 no skipped or duplicated characters (verified segment 0\u2013500 and 500\u20131000 match `raw.slice` exactly).\n- Kept bounded previews, default/clamped page sizes, short-output passthrough, task-local UUID references, eviction cap, and the expired/missing-id error. `node public.cjs .` passes.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/9a2b3f5e-d1a4-488d-a048-148d2a39573f/worktree/electron/tool-output.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/9a2b3f5e-d1a4-488d-a048-148d2a39573f/worktree/electron/tool-output.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 8, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 222051, + "outTokens": 6433, + "estimated": false, + "available": true, + "uncachedInTokens": 24419, + "cacheReadTokens": 197632, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 8 + }, + "elapsedMs": 49950 + }, + "files": [ + "electron/tool-output.js" + ], + "heldout": { + "passed": true + }, + "integration": { + "applied": true, + "appliedAt": "2026-09-21T01:47:21.478Z", + "files": [ + "electron/tool-output.js" + ], + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "accepted": true + }, + { + "id": "8e44fc0f-915f-4fd7-ac8f-b0d7f71625e1", + "case": "output-paging", + "round": 0, + "profile": "scoped", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "Fixed. Only `electron/tool-output.js` changed; `node public.cjs .` passes.\n\nTwo defects repaired:\n- `capture` stored only `text.slice(0, limit)`, dropping characters beyond the limit and corrupting eviction accounting (`bytes` added full length but subtracted the truncated length). Now stores the full string, so every character is retrievable and `bytes` stays consistent.\n- `read` sliced `text.slice(start+1, start+count+1)` \u2014 an off-by-one that skipped the first character and duplicated/lost others. Now slices `text.slice(start, end)` with `end = min(start+count, length)`, and the metadata range and `next offset=${end}` exactly agree with the returned page, clamping offset to the output length.\n\nVerified: full round-trip paging reconstructs the raw string with no skip/duplication, partial final page marks `; end]` and omits `next offset`, limits default to 12000/clamp to 24000/min 1, previews stay bounded, short outputs return unchanged, and eviction holds the 8 MB bound with `bytes` matching stored lengths.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/8e44fc0f-915f-4fd7-ac8f-b0d7f71625e1/worktree/electron/tool-output.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/8e44fc0f-915f-4fd7-ac8f-b0d7f71625e1/worktree/electron/tool-output.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 9, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 144335, + "outTokens": 7390, + "estimated": false, + "available": true, + "uncachedInTokens": 15823, + "cacheReadTokens": 128512, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 9 + }, + "elapsedMs": 51143 + }, + "files": [ + "electron/tool-output.js" + ], + "heldout": { + "passed": true + }, + "integration": { + "applied": true, + "appliedAt": "2026-09-21T01:47:22.564Z", + "files": [ + "electron/tool-output.js" + ], + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "accepted": true + }, + { + "id": "33a5497f-73fa-4097-91c2-4aa43b61c52b", + "case": "catalog-policy", + "round": 0, + "profile": "scoped", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "Fixed both regressions in `electron/model-catalog.js`:\n\n1. **Free pricing** (model-catalog.js:12-14): now requires both input and output to be explicitly zero (`isZero` guards null/undefined/empty strings while keeping numeric strings like `\"0\"`); unknown or partly-specified pricing is no longer free. Local models and `:free`/`/free`/`-free` suffixes are preserved.\n2. **Tools-only filtering** (model-catalog.js:90): now requires `m.tools === true`, excluding unknown `null`/`undefined` capability.\n\nCase-insensitive search, paid entries, and combined filter semantics are unchanged. `node public.cjs .` passes.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/33a5497f-73fa-4097-91c2-4aa43b61c52b/worktree/electron/model-catalog.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/33a5497f-73fa-4097-91c2-4aa43b61c52b/worktree/electron/model-catalog.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 6, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 105107, + "outTokens": 3182, + "estimated": false, + "available": true, + "uncachedInTokens": 16019, + "cacheReadTokens": 89088, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 6 + }, + "elapsedMs": 24591 + }, + "files": [ + "electron/model-catalog.js" + ], + "heldout": { + "passed": true + }, + "integration": { + "applied": true, + "appliedAt": "2026-09-21T01:47:46.233Z", + "files": [ + "electron/model-catalog.js" + ], + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "accepted": true + }, + { + "id": "d32e910e-557e-4101-824a-bc57a5ad8f0b", + "case": "catalog-policy", + "round": 0, + "profile": "standard", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "Fixed in `electron/model-catalog.js`:\n\n- **Cost classification** (gatewayModel:12): `zero` now requires both input and output to be explicitly present (not `null`/`undefined`) and numerically zero via `Number(input) === 0 && Number(output) === 0`. Unknown or partly specified pricing is no longer treated as free; numeric strings like `\"0\"` still count as zero, and local models / `:free$|/free$|-free$` suffixes remain free.\n- **Filtering** (filterModels:90): `tools_only` now requires `m.tools === true`, excluding unknown (`null`/`undefined`) capability. Kept case-insensitive query, truthy-free (`free_only`), paid-entry inclusion, and combined filter semantics.\n\n`node public.cjs .` passes; behavior verified for partial prices, string prices, free suffixes, local providers, tools-only, and combined filters.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/d32e910e-557e-4101-824a-bc57a5ad8f0b/worktree/electron/model-catalog.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/d32e910e-557e-4101-824a-bc57a5ad8f0b/worktree/electron/model-catalog.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 9, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 277041, + "outTokens": 5260, + "estimated": false, + "available": true, + "uncachedInTokens": 25649, + "cacheReadTokens": 251392, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 10 + }, + "elapsedMs": 51183 + }, + "files": [ + "electron/model-catalog.js" + ], + "heldout": { + "passed": true + }, + "integration": { + "applied": true, + "appliedAt": "2026-09-21T01:48:13.956Z", + "files": [ + "electron/model-catalog.js" + ], + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "accepted": true + }, + { + "id": "925b8bdd-d295-4d9f-b1f8-5a598fbf3e68", + "case": "compaction-continuity", + "round": 0, + "profile": "standard", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "Fixed `electron/compactor.js:34-48` (cutIndex) and `:68-99` (compact). Only the owned file changed; `node public.cjs .` passes.\n\nChanges:\n- **Single-task compaction**: `cutIndex` now cuts at a complete assistant/tool-cycle boundary (4th-from-last assistant) when there's only one user turn, instead of returning `-1`. No tool result is ever orphaned since the tail always starts at an assistant message.\n- **Repeated compactions / failed summarizer**: the anchor prefers the previously preserved task (`priorTask`) over re-deriving from the first remaining user turn, so original constraints survive across repeated compactions and summarizer failures.\n- **System instructions**: all leading `system` messages are preserved verbatim.\n- **Multi-turn**: the existing user-turn cut, budget loop, and output shape are unchanged.\n\nVerified with public.cjs plus extra checks covering single-turn compaction, cycle retention/no orphaned results, repeated compaction, failed-then-successful summarizer chains, and multi-turn behavior.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/925b8bdd-d295-4d9f-b1f8-5a598fbf3e68/worktree/electron/compactor.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/925b8bdd-d295-4d9f-b1f8-5a598fbf3e68/worktree/electron/compactor.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/925b8bdd-d295-4d9f-b1f8-5a598fbf3e68/worktree/electron/compactor.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/925b8bdd-d295-4d9f-b1f8-5a598fbf3e68/worktree/electron/compactor.js" + }, + { + "tool": "write", + "path": "/var/folders/l5/ysts1j1x5d1bzjspwqzmftf40000gn/T/opencode/comptest.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 10, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 465039, + "outTokens": 21183, + "estimated": false, + "available": true, + "uncachedInTokens": 28559, + "cacheReadTokens": 436480, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 11 + }, + "elapsedMs": 127778 + }, + "files": [ + "electron/compactor.js" + ], + "heldout": { + "passed": true + }, + "integration": { + "applied": true, + "appliedAt": "2026-09-21T01:49:54.189Z", + "files": [ + "electron/compactor.js" + ], + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "accepted": true + }, + { + "id": "07537e00-5618-41f7-928e-17867da0ca1b", + "case": "compaction-continuity", + "round": 0, + "profile": "scoped", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "Fixed `electron/compactor.js` (only owned module touched), `node public.cjs .` passes.\n\nChanges:\n- `cutIndex` (compactor.js:34): the single-user-turn fallback built a `boundaries` list but always returned `-1`, so multi-cycle single-task sessions never compacted. It now cuts at a complete assistant/tool-cycle boundary, keeping the latest `KEEP_CYCLES` (4) cycles verbatim so no tool result is orphaned. It returns `-1` only when there aren't more cycles than retained, preserving old behavior for tiny transcripts.\n- `compact` (compactor.js:87): `originalTask = task?.content || priorTask` so the `TASK_MARKER` anchor persists across repeated compactions (including when the only user message is a prior summary and/or when the summarizer fails).\n\nVerified against a comprehensive script: single-task many-cycle compaction (4 cycles verbatim, no orphaned results, system message preserved), repeated compactions with both failing and succeeding summarizers (task constraints retained), and multi-turn compaction still working.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/07537e00-5618-41f7-928e-17867da0ca1b/worktree/electron/compactor.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/07537e00-5618-41f7-928e-17867da0ca1b/worktree/electron/compactor.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/07537e00-5618-41f7-928e-17867da0ca1b/worktree/electron/compactor.js" + }, + { + "tool": "write", + "path": "/var/folders/l5/ysts1j1x5d1bzjspwqzmftf40000gn/T/opencode/compactor-check.cjs" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/07537e00-5618-41f7-928e-17867da0ca1b/worktree/electron/compactor.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 22, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 540320, + "outTokens": 19150, + "estimated": false, + "available": true, + "uncachedInTokens": 16032, + "cacheReadTokens": 524288, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 21 + }, + "elapsedMs": 177317 + }, + "files": [ + "electron/compactor.js" + ], + "heldout": { + "passed": true + }, + "integration": { + "applied": true, + "appliedAt": "2026-09-21T01:51:11.428Z", + "files": [ + "electron/compactor.js" + ], + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "accepted": true + }, + { + "id": "e2840a53-3f18-49fa-9969-b1712526243e", + "case": "repair-accounting", + "round": 0, + "profile": "scoped", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "Done. Changes in `electron/execution.js:90-97`:\n\n- **Repair on failed checks**: Replaced `if (verification.length) break` (which always exited the loop when checks ran) with `checkHistory.push(...)` + `if (verification.every(c => c.ok)) break`. Failed checks now trigger a bounded repair (\u2264 `repairAttempts`) in the same agent, and each round's checks are recorded in `checkHistory`.\n- **Failed final checks**: When the last allowed round fails, `error = \"Acceptance check failed\"` is set before breaking, so status is `failed`/`partial`, never `completed`.\n- **Usage across turns**: Resets `lastUsage` before each repair `send` so per-turn cumulative totals (including `cacheReadTokens`, `modelRequests`, etc.) are summed once per turn instead of being double-counted/zeroed.\n\n`node public.cjs .` passes; verified cancellation, pass-first-try, single-repair, multi-emission delta counting, bounded repairs, and non-completed final failures.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/e2840a53-3f18-49fa-9969-b1712526243e/worktree/electron/execution.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 8, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 181770, + "outTokens": 14566, + "estimated": false, + "available": true, + "uncachedInTokens": 12042, + "cacheReadTokens": 169728, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 8 + }, + "elapsedMs": 109533 + }, + "files": [ + "electron/execution.js" + ], + "heldout": { + "passed": false, + "output": "AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:\n\n3 !== 2\n\n at /repair-accounting.heldout.cjs:2:620 {\n generatedMessage: true,\n code: 'ERR_ASSERTION',\n actual: 3,\n expected: 2,\n operator: 'strictEqual'\n}\n" + }, + "integration": null, + "accepted": false + }, + { + "id": "ce7050c1-8125-4948-bf44-b10636b70743", + "case": "workspace-boundaries", + "round": 0, + "profile": "standard", + "status": "partial", + "reason": "Observed token budget reached", + "result": { + "status": "partial", + "reason": "Observed token budget reached", + "summary": "", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/ce7050c1-8125-4948-bf44-b10636b70743/worktree/electron/job-workspace.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/ce7050c1-8125-4948-bf44-b10636b70743/worktree/electron/job-workspace.js" + }, + { + "tool": "write", + "path": "/var/folders/l5/ysts1j1x5d1bzjspwqzmftf40000gn/T/opencode/wspace-check.js" + }, + { + "tool": "edit", + "path": "/var/folders/l5/ysts1j1x5d1bzjspwqzmftf40000gn/T/opencode/wspace-check.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/ce7050c1-8125-4948-bf44-b10636b70743/worktree/electron/job-workspace.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 15, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "unverified", + "checks": [] + }, + "usage": { + "inTokens": 591190, + "outTokens": 22589, + "estimated": false, + "available": true, + "uncachedInTokens": 35414, + "cacheReadTokens": 555776, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 13 + }, + "elapsedMs": 159700 + }, + "files": [ + "electron/job-workspace.js" + ], + "heldout": { + "passed": true + }, + "integration": null, + "accepted": false + }, + { + "id": "2d717e70-5a7d-4976-bfb4-22bd5625f15d", + "case": "repair-accounting", + "round": 0, + "profile": "standard", + "status": "partial", + "reason": "Observed token budget reached", + "result": { + "status": "partial", + "reason": "Observed token budget reached", + "summary": "", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/2d717e70-5a7d-4976-bfb4-22bd5625f15d/worktree/electron/execution.js" + }, + { + "tool": "write", + "path": "/var/folders/l5/ysts1j1x5d1bzjspwqzmftf40000gn/T/opencode/exec-check.cjs" + }, + { + "tool": "edit", + "path": "/var/folders/l5/ysts1j1x5d1bzjspwqzmftf40000gn/T/opencode/exec-check.cjs" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 17, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "unverified", + "checks": [] + }, + "usage": { + "inTokens": 609291, + "outTokens": 24210, + "estimated": false, + "available": true, + "uncachedInTokens": 39179, + "cacheReadTokens": 570112, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 12 + }, + "elapsedMs": 241592 + }, + "files": [ + "electron/execution.js" + ], + "heldout": { + "passed": false, + "output": "AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:\n\n3 !== 2\n\n at /repair-accounting.heldout.cjs:2:620 {\n generatedMessage: true,\n code: 'ERR_ASSERTION',\n actual: 3,\n expected: 2,\n operator: 'strictEqual'\n}\n" + }, + "integration": null, + "accepted": false + }, + { + "id": "b0eb5c3c-4805-4d2b-b28d-028aa4ac524c", + "case": "workspace-boundaries", + "round": 0, + "profile": "scoped", + "status": "completed", + "reason": null, + "result": { + "status": "completed", + "reason": null, + "summary": "Done. Changes in `electron/job-workspace.js`:\n\n- `relativeFile` now normalizes `\\`\u2192`/` first, then rejects absolute paths, any `..` segment, and any `.git` segment on either slash style (`electron/job-workspace.js:12`). Parent traversal like `a\\..\\..\\escape` is now caught; `src/file.js` still passes.\n- `collect`'s ownership matching is boundary-aware (`electron/job-workspace.js:51`): a directory prefix like `src/app/` covers descendants (`src/app/foo.js`) but never similarly named siblings (`src/apple.js`); a path without a trailing slash stays an exact file match. Trailing slashes survive the `relativeCwd` join, and allowed paths are validated through `relativeFile`.\n\nSnapshot isolation (private `GIT_INDEX_FILE`), the `changes.patch` artifact (mode `0600`), and the source index are untouched. `node public.cjs .` passes.", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/b0eb5c3c-4805-4d2b-b28d-028aa4ac524c/worktree/electron/job-workspace.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/b0eb5c3c-4805-4d2b-b28d-028aa4ac524c/worktree/electron/job-workspace.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/b0eb5c3c-4805-4d2b-b28d-028aa4ac524c/worktree/electron/job-workspace.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 17, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "passed", + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "usage": { + "inTokens": 360419, + "outTokens": 17464, + "estimated": false, + "available": true, + "uncachedInTokens": 18147, + "cacheReadTokens": 342272, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 15 + }, + "elapsedMs": 141195 + }, + "files": [ + "electron/job-workspace.js" + ], + "heldout": { + "passed": true + }, + "integration": { + "applied": true, + "appliedAt": "2026-09-21T01:56:45.624Z", + "files": [ + "electron/job-workspace.js" + ], + "checks": [ + { + "command": "node public.cjs .", + "ok": true, + "exitCode": 0, + "output": "\n\n[exit code 0]" + } + ] + }, + "accepted": true + }, + { + "id": "05b4acb7-9029-4200-85d8-3b6eeeb80e21", + "case": "durable-admission", + "round": 0, + "profile": "scoped", + "status": "partial", + "reason": "Observed token budget reached", + "result": { + "status": "partial", + "reason": "Observed token budget reached", + "summary": "", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/05b4acb7-9029-4200-85d8-3b6eeeb80e21/worktree/electron/job-service.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/05b4acb7-9029-4200-85d8-3b6eeeb80e21/worktree/electron/job-service.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 20, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "unverified", + "checks": [] + }, + "usage": { + "inTokens": 592567, + "outTokens": 18465, + "estimated": false, + "available": true, + "uncachedInTokens": 39863, + "cacheReadTokens": 552704, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 14 + }, + "elapsedMs": 140056 + }, + "files": [ + "electron/job-service.js" + ], + "heldout": { + "passed": true + }, + "integration": null, + "accepted": false + }, + { + "id": "8c014d9b-3d67-4225-b243-a2a02344d00b", + "case": "durable-admission", + "round": 0, + "profile": "standard", + "status": "partial", + "reason": "Observed token budget reached", + "result": { + "status": "partial", + "reason": "Observed token budget reached", + "summary": "", + "changes": [ + { + "tool": "edit", + "path": "/data/jobs/tasks/8c014d9b-3d67-4225-b243-a2a02344d00b/worktree/electron/job-service.js" + }, + { + "tool": "edit", + "path": "/data/jobs/tasks/8c014d9b-3d67-4225-b243-a2a02344d00b/worktree/electron/job-service.js" + } + ], + "attempts": [ + { + "model": "opencode/big-pickle", + "error": null + } + ], + "toolCalls": 15, + "checkHistory": [], + "model": "opencode/big-pickle", + "verification": { + "status": "unverified", + "checks": [] + }, + "usage": { + "inTokens": 649626, + "outTokens": 23799, + "estimated": false, + "available": true, + "uncachedInTokens": 60314, + "cacheReadTokens": 589312, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "modelRequests": 11 + }, + "elapsedMs": 174565 + }, + "files": [ + "electron/job-service.js" + ], + "heldout": { + "passed": false, + "output": "AssertionError [ERR_ASSERTION]: Missing expected exception.\n at /durable-admission.heldout.cjs:2:329\n at Object. (/durable-admission.heldout.cjs:2:1158)\n at Module._compile (node:internal/modules/cjs/loader:1734:14)\n at Object..js (node:internal/modules/cjs/loader:1899:10)\n at Module.load (node:internal/modules/cjs/loader:1469:32)\n at Function._load (node:internal/modules/cjs/loader:1286:12)\n at TracingChannel.traceSync (node:diagnostics_channel:322:14)\n at wrapModuleLoad (node:internal/modules/cjs/loader:235:24)\n at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:151:5)\n at node:internal/main/run_main_module:33:47 {\n generatedMessage: false,\n code: 'ERR_ASSERTION',\n actual: undefined,\n expected: /disk full/,\n operator: 'throws'\n}\n" + }, + "integration": null, + "accepted": false + } + ], + "repositoryModules": 31, + "summary": { + "standard": { + "jobs": 6, + "attempted": 6, + "accepted": 3, + "usageAvailable": 6, + "totalInput": 2814238, + "uncachedInput": 213534, + "cacheRead": 2600704, + "cacheWrite": 0, + "output": 103474, + "modelRequests": 65, + "repairs": 0, + "inputPerAccepted": 938079.3333333334, + "medianExecutionMs": 143739 + }, + "scoped": { + "jobs": 6, + "attempted": 6, + "accepted": 4, + "usageAvailable": 6, + "totalInput": 1924518, + "uncachedInput": 117926, + "cacheRead": 1806592, + "cacheWrite": 0, + "output": 80217, + "modelRequests": 73, + "repairs": 0, + "inputPerAccepted": 481129.5, + "medianExecutionMs": 124794.5 + } + }, + "unfinished": [], + "elapsedMs": 791449, + "submissionMs": 41 +} diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/catalog-policy-r0-scoped.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/catalog-policy-r0-scoped.patch new file mode 100644 index 0000000..93089e2 --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/catalog-policy-r0-scoped.patch @@ -0,0 +1,23 @@ +diff --git a/electron/model-catalog.js b/electron/model-catalog.js +index 87343d5..c2831e4 100644 +--- a/electron/model-catalog.js ++++ b/electron/model-catalog.js +@@ -9,7 +9,8 @@ function gatewayModel(m) { + const price = m.pricing || m.cost; + const input = price?.prompt ?? price?.input; + const output = price?.completion ?? price?.output; +- const zero = Number(input || 0) === 0 || Number(output || 0) === 0; ++ const isZero = v => v !== undefined && v !== null && String(v).trim() !== "" && Number(v) === 0; ++ const zero = isZero(input) && isZero(output); + const local = LOCAL.some(p => p.alias === provider); + const free = local || zero || /:free$|\/free$|-free$/.test(id); + return { +@@ -87,7 +88,7 @@ async function probe(gw, id, { timeoutMs = 20_000 } = {}) { + + function filterModels(models, { query = "", free_only = false, tools_only = false } = {}) { + const q = String(query).toLowerCase(); +- return models.filter(m => (!free_only || m.free) && (!tools_only || m.tools !== false) && `${m.id} ${m.name} ${m.provider}`.toLowerCase().includes(q)); ++ return models.filter(m => (!free_only || m.free) && (!tools_only || m.tools === true) && `${m.id} ${m.name} ${m.provider}`.toLowerCase().includes(q)); + } + + module.exports = { catalog, gatewayModel, filterModels, probe }; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/catalog-policy-r0-standard.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/catalog-policy-r0-standard.patch new file mode 100644 index 0000000..385200d --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/catalog-policy-r0-standard.patch @@ -0,0 +1,22 @@ +diff --git a/electron/model-catalog.js b/electron/model-catalog.js +index 87343d5..27eca1e 100644 +--- a/electron/model-catalog.js ++++ b/electron/model-catalog.js +@@ -9,7 +9,7 @@ function gatewayModel(m) { + const price = m.pricing || m.cost; + const input = price?.prompt ?? price?.input; + const output = price?.completion ?? price?.output; +- const zero = Number(input || 0) === 0 || Number(output || 0) === 0; ++ const zero = input !== undefined && input !== null && output !== undefined && output !== null && Number(input) === 0 && Number(output) === 0; + const local = LOCAL.some(p => p.alias === provider); + const free = local || zero || /:free$|\/free$|-free$/.test(id); + return { +@@ -87,7 +87,7 @@ async function probe(gw, id, { timeoutMs = 20_000 } = {}) { + + function filterModels(models, { query = "", free_only = false, tools_only = false } = {}) { + const q = String(query).toLowerCase(); +- return models.filter(m => (!free_only || m.free) && (!tools_only || m.tools !== false) && `${m.id} ${m.name} ${m.provider}`.toLowerCase().includes(q)); ++ return models.filter(m => (!free_only || m.free === true) && (!tools_only || m.tools === true) && `${m.id} ${m.name} ${m.provider}`.toLowerCase().includes(q)); + } + + module.exports = { catalog, gatewayModel, filterModels, probe }; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/compaction-continuity-r0-scoped.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/compaction-continuity-r0-scoped.patch new file mode 100644 index 0000000..91d5cec --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/compaction-continuity-r0-scoped.patch @@ -0,0 +1,42 @@ +diff --git a/electron/compactor.js b/electron/compactor.js +index fc33406..5b38d0c 100644 +--- a/electron/compactor.js ++++ b/electron/compactor.js +@@ -7,6 +7,7 @@ + const DEFAULT_CONTEXT = 100_000; // tokens; conservative when the model is unknown + const COMPACT_AT = 0.8; // auto-compact at 80% of budget + const KEEP_TURNS = 6; // recent user turns kept verbatim ++const KEEP_CYCLES = 4; // assistant/tool cycles kept verbatim without user turns + const MAX_SUMMARY_INPUT = 48_000; // chars fed to the summarize call + + const SUMMARY_MARKER = "[Conversation summary — earlier context was compacted]"; +@@ -35,13 +36,17 @@ function cutIndex(messages, keepTurns = KEEP_TURNS) { + for (let i = messages.length - 1; i > 0; i--) { + if (messages[i].role === "user" && ++turns >= keepTurns) return i; + } +- // Delegations normally contain a single user turn. Cut only at a complete +- // assistant/tool-cycle boundary, retaining the latest four cycles verbatim. ++ // Delegations normally contain a single user turn but many assistant/tool ++ // cycles. Cut only at a complete assistant/tool-cycle boundary — an assistant ++ // message whose tool results all follow it — so no tool result is orphaned. ++ // Retain the latest KEEP_CYCLES cycles verbatim; if there aren't more cycles ++ // than that, nothing meaningful precedes the tail. + const boundaries = []; + for (let i = 2; i < messages.length; i++) { + if (messages[i].role === "assistant") boundaries.push(i); + } +- return -1; ++ if (boundaries.length <= KEEP_CYCLES) return -1; ++ return boundaries[boundaries.length - KEEP_CYCLES]; + } + + // Render older messages as plain text for the summarize call. +@@ -77,7 +82,7 @@ async function compact(messages, summarize, { budget = DEFAULT_CONTEXT } = {}) { + const task = messages.find((m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith(SUMMARY_MARKER)); + const previousSummary = messages.find(m => m.role === "user" && typeof m.content === "string" && m.content.startsWith(SUMMARY_MARKER)); + const priorTask = previousSummary?.content.includes(TASK_MARKER) ? previousSummary.content.slice(previousSummary.content.indexOf(TASK_MARKER) + TASK_MARKER.length) : ""; +- const originalTask = task?.content; ++ const originalTask = task?.content || priorTask; + const anchor = originalTask ? TASK_MARKER + originalTask.slice(0, 12000) : ""; + const summaryLimit = Math.max(256, Math.min(16000, Math.floor(budget * 0.8))); + let summaryMsg; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/compaction-continuity-r0-standard.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/compaction-continuity-r0-standard.patch new file mode 100644 index 0000000..d86bcdd --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/compaction-continuity-r0-standard.patch @@ -0,0 +1,65 @@ +diff --git a/electron/compactor.js b/electron/compactor.js +index fc33406..5b94987 100644 +--- a/electron/compactor.js ++++ b/electron/compactor.js +@@ -7,6 +7,7 @@ + const DEFAULT_CONTEXT = 100_000; // tokens; conservative when the model is unknown + const COMPACT_AT = 0.8; // auto-compact at 80% of budget + const KEEP_TURNS = 6; // recent user turns kept verbatim ++const KEEP_CYCLES = 4; // complete assistant/tool cycles kept verbatim + const MAX_SUMMARY_INPUT = 48_000; // chars fed to the summarize call + + const SUMMARY_MARKER = "[Conversation summary — earlier context was compacted]"; +@@ -30,16 +31,18 @@ function shouldCompact(messages, budget = DEFAULT_CONTEXT) { + // Index of the user message starting the Nth-from-last turn. Cutting at a user + // message keeps assistant tool_calls and their tool results together — a tool + // result can never be orphaned from its call. +-function cutIndex(messages, keepTurns = KEEP_TURNS) { ++function cutIndex(messages, keepTurns = KEEP_TURNS, keepCycles = KEEP_CYCLES) { + let turns = 0; + for (let i = messages.length - 1; i > 0; i--) { + if (messages[i].role === "user" && ++turns >= keepTurns) return i; + } + // Delegations normally contain a single user turn. Cut only at a complete +- // assistant/tool-cycle boundary, retaining the latest four cycles verbatim. +- const boundaries = []; +- for (let i = 2; i < messages.length; i++) { +- if (messages[i].role === "assistant") boundaries.push(i); ++ // assistant/tool-cycle boundary, retaining the latest complete cycles ++ // verbatim so a retained tool result is never orphaned from its call. ++ let cycles = 0; ++ for (let i = messages.length - 1; i > 1; i--) { ++ if (messages[i].role !== "assistant") continue; ++ if (++cycles >= keepCycles) return i; + } + return -1; + } +@@ -72,12 +75,17 @@ async function compact(messages, summarize, { budget = DEFAULT_CONTEXT } = {}) { + cut = next; + } + const before = estimateTokens(messages); +- const head = messages.slice(1, cut); ++ let leading = 0; ++ while (leading < messages.length && messages[leading].role === "system") leading++; ++ const head = messages.slice(leading, cut); + const tail = messages.slice(cut); + const task = messages.find((m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith(SUMMARY_MARKER)); + const previousSummary = messages.find(m => m.role === "user" && typeof m.content === "string" && m.content.startsWith(SUMMARY_MARKER)); + const priorTask = previousSummary?.content.includes(TASK_MARKER) ? previousSummary.content.slice(previousSummary.content.indexOf(TASK_MARKER) + TASK_MARKER.length) : ""; +- const originalTask = task?.content; ++ // Prefer the previously preserved task so the original constraints survive ++ // repeated compactions and failed summarizers; fall back to the first ++ // remaining user turn when compacting for the first time. ++ const originalTask = priorTask || task?.content; + const anchor = originalTask ? TASK_MARKER + originalTask.slice(0, 12000) : ""; + const summaryLimit = Math.max(256, Math.min(16000, Math.floor(budget * 0.8))); + let summaryMsg; +@@ -88,7 +96,7 @@ async function compact(messages, summarize, { budget = DEFAULT_CONTEXT } = {}) { + } catch { + summaryMsg = { role: "user", content: `${SUMMARY_MARKER}\n(Summary unavailable — older messages were truncated. Recheck files and completed work before repeating actions.)${anchor}` }; + } +- const out = [messages[0], summaryMsg, ...tail]; ++ const out = [...messages.slice(0, leading), summaryMsg, ...tail]; + const after = estimateTokens(out); + return { messages: out, note: `Compacted ${head.length} older messages into a summary (~${Math.round(before / 1000)}k → ~${Math.round(after / 1000)}k tokens).` }; + } diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/durable-admission-r0-scoped.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/durable-admission-r0-scoped.patch new file mode 100644 index 0000000..67e1044 --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/durable-admission-r0-scoped.patch @@ -0,0 +1,44 @@ +diff --git a/electron/job-service.js b/electron/job-service.js +index 5e703f5..f77b52a 100644 +--- a/electron/job-service.js ++++ b/electron/job-service.js +@@ -45,8 +45,13 @@ function atomic(file, data) { + fs.renameSync(tmp, file); + } + function providerKey(model) { +- // Zen and Go may share capacity. Do not multiply slots by model aliases. +- return model.id; ++ // OpenCode Zen and Go (and any account routed through the engine) share one ++ // capacity bucket: do not multiply slots by model id or provider alias. ++ // Gateway models bucket by their route prefix, one bucket per provider. ++ if (model && model.source === "OpenCode") return "opencode"; ++ const id = String((model && model.id) || ""); ++ const slash = id.indexOf("/"); ++ return slash > 0 ? id.slice(0, slash) : id; + } + class JobService extends EventEmitter { + constructor({ dir, catalog, run, runCheck, concurrency = 4, providerConcurrency = 2, requestsPerMinute = 20, providerLimits = {} }) { +@@ -92,10 +97,20 @@ class JobService extends EventEmitter { + const jobs = entries.map(({ spec, key, fingerprint, existing }) => { + if (existing) return this.view(existing); + const id = crypto.randomUUID(); +- fs.mkdirSync(path.join(this.dir, id), { mode: 0o700 }); ++ const dir = path.join(this.dir, id); ++ fs.mkdirSync(dir, { mode: 0o700 }); + const job = { id, key, fingerprint, batch, batchSize: specs.length, spec, status: "queued", createdAt: new Date().toISOString(), result: null, progress: null }; +- // Admission must reach disk before the scheduler can see this job. +- this.jobs.set(id, job); this.keys.set(key, id); this.save(job); return this.view(job); ++ this.jobs.set(id, job); this.keys.set(key, id); ++ // Admission must reach disk before the scheduler can see this job. A ++ // persistence failure must not leave a schedulable in-memory job or a ++ // deduplication key: roll both back and remove the empty directory. ++ try { this.save(job); } ++ catch (e) { ++ this.jobs.delete(id); if (this.keys.get(key) === id) this.keys.delete(key); ++ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} ++ throw e; ++ } ++ return this.view(job); + }); + setImmediate(() => this.tick()); + return { request_id: batch, revision: this.revision, jobs }; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/durable-admission-r0-standard.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/durable-admission-r0-standard.patch new file mode 100644 index 0000000..2989f7a --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/durable-admission-r0-standard.patch @@ -0,0 +1,41 @@ +diff --git a/electron/job-service.js b/electron/job-service.js +index 5e703f5..098f203 100644 +--- a/electron/job-service.js ++++ b/electron/job-service.js +@@ -45,8 +45,13 @@ function atomic(file, data) { + fs.renameSync(tmp, file); + } + function providerKey(model) { +- // Zen and Go may share capacity. Do not multiply slots by model aliases. +- return model.id; ++ // A capacity bucket is the account or route that serves the request, never ++ // the model id or an alias. OpenCode Zen and Go run on the same engine and ++ // share one slot pool; gateway models on a provider share that provider's ++ // pool instead of taking independent slots per model. ++ if (!model) return "default"; ++ if (model.source === "OpenCode") return "opencode"; ++ return model.provider || (typeof model.id === "string" ? model.id.split("/")[0] : null) || model.id || "default"; + } + class JobService extends EventEmitter { + constructor({ dir, catalog, run, runCheck, concurrency = 4, providerConcurrency = 2, requestsPerMinute = 20, providerLimits = {} }) { +@@ -94,8 +99,18 @@ class JobService extends EventEmitter { + const id = crypto.randomUUID(); + fs.mkdirSync(path.join(this.dir, id), { mode: 0o700 }); + const job = { id, key, fingerprint, batch, batchSize: specs.length, spec, status: "queued", createdAt: new Date().toISOString(), result: null, progress: null }; +- // Admission must reach disk before the scheduler can see this job. +- this.jobs.set(id, job); this.keys.set(key, id); this.save(job); return this.view(job); ++ // Admission must reach disk before the scheduler can see this job. A ++ // persistence failure must not leave an executable in-memory job or a ++ // deduplication key: register in the maps only after the durable write. ++ try { ++ job.revision = this.revision + 1; job.updatedAt = new Date().toISOString(); ++ atomic(path.join(this.dir, id, "job.json"), job); ++ } catch (e) { ++ fs.rmSync(path.join(this.dir, id), { recursive: true, force: true }); ++ throw e; ++ } ++ this.revision = job.revision; ++ this.jobs.set(id, job); this.keys.set(key, id); this.emit("change", job.id); return this.view(job); + }); + setImmediate(() => this.tick()); + return { request_id: batch, revision: this.revision, jobs }; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/output-paging-r0-scoped.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/output-paging-r0-scoped.patch new file mode 100644 index 0000000..a059ad9 --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/output-paging-r0-scoped.patch @@ -0,0 +1,26 @@ +diff --git a/electron/tool-output.js b/electron/tool-output.js +index e1200ad..5bc22ae 100644 +--- a/electron/tool-output.js ++++ b/electron/tool-output.js +@@ -9,7 +9,7 @@ class ToolOutputStore { + text = String(text); + if (text.length <= limit) return text; + const id = crypto.randomUUID(); +- this.entries.set(id, text.slice(0, limit)); this.bytes += text.length; ++ this.entries.set(id, text); this.bytes += text.length; + while (this.bytes > 8_000_000 && this.entries.size > 1) { + const first = this.entries.keys().next().value; + this.bytes -= this.entries.get(first).length; this.entries.delete(first); +@@ -20,9 +20,10 @@ class ToolOutputStore { + read(id, offset = 0, limit = 12000) { + const text = this.entries.get(id); + if (text == null) return "Error in read_output: output expired or belongs to another task; rerun the tool."; +- const start = Math.max(0, Math.floor(Number(offset) || 0)); ++ const start = Math.min(text.length, Math.max(0, Math.floor(Number(offset) || 0))); + const count = Math.min(24000, Math.max(1, Math.floor(Number(limit) || 12000))); +- return `${text.slice(start + 1, start + count + 1)}\n[characters ${start}-${Math.min(start + count, text.length)} of ${text.length}${start + count < text.length ? `; next offset=${start + count}` : "; end"}]`; ++ const end = Math.min(start + count, text.length); ++ return `${text.slice(start, end)}\n[characters ${start}-${end} of ${text.length}${end < text.length ? `; next offset=${end}` : "; end"}]`; + } + } + const OUTPUT_TOOL = { type: "function", function: { name: "read_output", description: "Retrieve original tool output by its session-local id and character offset when a result was paged. No compression or omitted middle within the requested page.", parameters: { type: "object", properties: { id: { type: "string" }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 24000 } }, required: ["id"] } } }; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/output-paging-r0-standard.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/output-paging-r0-standard.patch new file mode 100644 index 0000000..83b619c --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/output-paging-r0-standard.patch @@ -0,0 +1,23 @@ +diff --git a/electron/tool-output.js b/electron/tool-output.js +index e1200ad..96eaca6 100644 +--- a/electron/tool-output.js ++++ b/electron/tool-output.js +@@ -9,7 +9,7 @@ class ToolOutputStore { + text = String(text); + if (text.length <= limit) return text; + const id = crypto.randomUUID(); +- this.entries.set(id, text.slice(0, limit)); this.bytes += text.length; ++ this.entries.set(id, text); this.bytes += text.length; + while (this.bytes > 8_000_000 && this.entries.size > 1) { + const first = this.entries.keys().next().value; + this.bytes -= this.entries.get(first).length; this.entries.delete(first); +@@ -22,7 +22,8 @@ class ToolOutputStore { + if (text == null) return "Error in read_output: output expired or belongs to another task; rerun the tool."; + const start = Math.max(0, Math.floor(Number(offset) || 0)); + const count = Math.min(24000, Math.max(1, Math.floor(Number(limit) || 12000))); +- return `${text.slice(start + 1, start + count + 1)}\n[characters ${start}-${Math.min(start + count, text.length)} of ${text.length}${start + count < text.length ? `; next offset=${start + count}` : "; end"}]`; ++ const end = Math.min(start + count, text.length); ++ return `${text.slice(start, start + count)}\n[characters ${Math.min(start, text.length)}-${end} of ${text.length}${end < text.length ? `; next offset=${end}` : "; end"}]`; + } + } + const OUTPUT_TOOL = { type: "function", function: { name: "read_output", description: "Retrieve original tool output by its session-local id and character offset when a result was paged. No compression or omitted middle within the requested page.", parameters: { type: "object", properties: { id: { type: "string" }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 24000 } }, required: ["id"] } } }; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/repair-accounting-r0-scoped.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/repair-accounting-r0-scoped.patch new file mode 100644 index 0000000..5b6a76b --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/repair-accounting-r0-scoped.patch @@ -0,0 +1,20 @@ +diff --git a/electron/execution.js b/electron/execution.js +index 7e13d68..79994d9 100644 +--- a/electron/execution.js ++++ b/electron/execution.js +@@ -87,13 +87,14 @@ async function executeTask({ task, createAgent, recover, checks = [], runCheck, + if (controller.signal.aborted) return; + verification.push({ command, ok: result.ok, exitCode: result.exitCode, output: result.text.slice(-8000) }); + } +- if (verification.length) break; + checkHistory.push({ round, checks: verification.slice() }); ++ if (verification.every(c => c.ok)) break; + if (round === repairAttempts) { error = "Acceptance check failed"; break; } + progress(++steps, `repair ${round + 1}: acceptance checks failed`); + completed = false; // previous usage retained + const repair = { model: agent.model, phase: "repair", error: null }; attempts.push(repair); + const evidence = verification.filter(c => !c.ok).map(c => `${c.command}\n${c.output}`).join("\n\n").slice(-12000); ++ lastUsage = { inTokens: 0, outTokens: 0 }; + await agent.send(`Acceptance checks failed. Repair the implementation in this existing workspace; preserve the original task and ownership constraints. Do not weaken, delete or bypass tests.\n\n${evidence}`); + repair.error = error; + if (error || !completed || controller.signal.aborted) break; diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/repair-accounting-r0-standard.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/repair-accounting-r0-standard.patch new file mode 100644 index 0000000..d6bc669 --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/repair-accounting-r0-standard.patch @@ -0,0 +1,31 @@ +diff --git a/electron/execution.js b/electron/execution.js +index 7e13d68..68f0d4b 100644 +--- a/electron/execution.js ++++ b/electron/execution.js +@@ -80,6 +80,7 @@ async function executeTask({ task, createAgent, recover, checks = [], runCheck, + } + if (!error && completed && runCheck) { + for (let round = 0; round <= repairAttempts; round++) { ++ if (controller.signal.aborted) return; + verification.length = 0; + for (const command of checks) { + if (controller.signal.aborted) return; +@@ -87,15 +88,17 @@ async function executeTask({ task, createAgent, recover, checks = [], runCheck, + if (controller.signal.aborted) return; + verification.push({ command, ok: result.ok, exitCode: result.exitCode, output: result.text.slice(-8000) }); + } +- if (verification.length) break; + checkHistory.push({ round, checks: verification.slice() }); ++ if (verification.every(c => c.ok)) break; + if (round === repairAttempts) { error = "Acceptance check failed"; break; } + progress(++steps, `repair ${round + 1}: acceptance checks failed`); + completed = false; // previous usage retained + const repair = { model: agent.model, phase: "repair", error: null }; attempts.push(repair); + const evidence = verification.filter(c => !c.ok).map(c => `${c.command}\n${c.output}`).join("\n\n").slice(-12000); ++ lastUsage = { inTokens: 0, outTokens: 0 }; // stats reset per turn; count each turn's cumulative once + await agent.send(`Acceptance checks failed. Repair the implementation in this existing workspace; preserve the original task and ownership constraints. Do not weaken, delete or bypass tests.\n\n${evidence}`); + repair.error = error; ++ repair.model = agent.effectiveModel || agent.model; + if (error || !completed || controller.signal.aborted) break; + } + } diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/workspace-boundaries-r0-scoped.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/workspace-boundaries-r0-scoped.patch new file mode 100644 index 0000000..c25e8ff --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/workspace-boundaries-r0-scoped.patch @@ -0,0 +1,33 @@ +diff --git a/electron/job-workspace.js b/electron/job-workspace.js +index 34a2701..1478b87 100644 +--- a/electron/job-workspace.js ++++ b/electron/job-workspace.js +@@ -10,8 +10,10 @@ async function git(cwd, args, options = {}) { + return stdout; + } + function relativeFile(file) { +- if (typeof file !== "string" || !file || path.isAbsolute(file) || file.split(/[\\/]/).some(p => p === ".git") || file.includes("\0")) throw new Error("File paths must be relative and cannot traverse directories or target .git"); +- return file.replace(/\\/g, "/").replace(/^\.\//, ""); ++ if (typeof file !== "string" || !file || file.includes("\0")) throw new Error("File paths must be relative and cannot traverse directories or target .git"); ++ const normalized = file.replace(/\\/g, "/"); ++ if (path.isAbsolute(normalized) || normalized.split("/").some(p => p === ".." || p === ".git")) throw new Error("File paths must be relative and cannot traverse directories or target .git"); ++ return normalized.replace(/^\.\//, ""); + } + + // Snapshot tracked edits and nonignored new files through a private index. +@@ -46,8 +48,13 @@ async function collect(workspace, dir, allowedPaths = []) { + const patch = await git(workspace.tree, ["diff", "--cached", "--no-renames", "--binary", workspace.baseTree]); + const patchPath = path.join(dir, "changes.patch"); + await fs.writeFile(patchPath, patch, { mode: 0o600 }); +- const allowed = allowedPaths.map(p => path.posix.join(workspace.relativeCwd.replace(/\\/g, "/"), p)); +- const outsideScope = allowed.length ? files.filter(f => !allowed.some(p => f === p.replace(/\/$/, "") || (p.endsWith("/") && f.startsWith(p.replace(/\/$/, ""))))) : []; ++ const allowed = allowedPaths.map(p => { ++ const dir = p.endsWith("/"); ++ const clean = relativeFile(p).replace(/\/+$/, ""); ++ const joined = path.posix.join(workspace.relativeCwd.replace(/\\/g, "/"), clean); ++ return dir ? joined + "/" : joined; ++ }); ++ const outsideScope = allowed.length ? files.filter(f => !allowed.some(p => p.endsWith("/") ? f === p.slice(0, -1) || f.startsWith(p) : f === p)) : []; + return { isolated: true, files, patch: patchPath, bytes: Buffer.byteLength(patch), outsideScope }; + } + async function context(spec, workspace) { diff --git a/benchmarks/results/2026-09-20/scoped-comparison.json.patches/workspace-boundaries-r0-standard.patch b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/workspace-boundaries-r0-standard.patch new file mode 100644 index 0000000..f8088e7 --- /dev/null +++ b/benchmarks/results/2026-09-20/scoped-comparison.json.patches/workspace-boundaries-r0-standard.patch @@ -0,0 +1,30 @@ +diff --git a/electron/job-workspace.js b/electron/job-workspace.js +index 34a2701..c8b6d88 100644 +--- a/electron/job-workspace.js ++++ b/electron/job-workspace.js +@@ -10,7 +10,9 @@ async function git(cwd, args, options = {}) { + return stdout; + } + function relativeFile(file) { +- if (typeof file !== "string" || !file || path.isAbsolute(file) || file.split(/[\\/]/).some(p => p === ".git") || file.includes("\0")) throw new Error("File paths must be relative and cannot traverse directories or target .git"); ++ if (typeof file !== "string" || !file || file.includes("\0") || path.isAbsolute(file) || /^[A-Za-z]:[\\/]/.test(file)) throw new Error("File paths must be relative and cannot traverse directories or target .git"); ++ const parts = file.split(/[\\/]/); ++ if (!parts[0] || parts.some(p => p === ".." || p === ".git")) throw new Error("File paths must be relative and cannot traverse directories or target .git"); + return file.replace(/\\/g, "/").replace(/^\.\//, ""); + } + +@@ -46,8 +48,12 @@ async function collect(workspace, dir, allowedPaths = []) { + const patch = await git(workspace.tree, ["diff", "--cached", "--no-renames", "--binary", workspace.baseTree]); + const patchPath = path.join(dir, "changes.patch"); + await fs.writeFile(patchPath, patch, { mode: 0o600 }); +- const allowed = allowedPaths.map(p => path.posix.join(workspace.relativeCwd.replace(/\\/g, "/"), p)); +- const outsideScope = allowed.length ? files.filter(f => !allowed.some(p => f === p.replace(/\/$/, "") || (p.endsWith("/") && f.startsWith(p.replace(/\/$/, ""))))) : []; ++ const allowed = allowedPaths.map(p => { ++ const norm = relativeFile(p); ++ const joined = path.posix.join(workspace.relativeCwd.replace(/\\/g, "/"), norm).replace(/\/+$/, ""); ++ return norm.endsWith("/") ? joined + "/" : joined || "."; ++ }); ++ const outsideScope = allowed.length ? files.filter(f => !allowed.some(p => p.endsWith("/") ? f === p.slice(0, -1) || f.startsWith(p) : f === p)) : []; + return { isolated: true, files, patch: patchPath, bytes: Buffer.byteLength(patch), outsideScope }; + } + async function context(spec, workspace) { diff --git a/benchmarks/runner-support.js b/benchmarks/runner-support.js new file mode 100644 index 0000000..8be3cd8 --- /dev/null +++ b/benchmarks/runner-support.js @@ -0,0 +1,39 @@ +"use strict"; +const fs = require("node:fs"); +const TRANSIENT = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket|network/i; +const transient = e => ["TimeoutError", "AbortError"].includes(e?.name) || TRANSIENT.test(e?.message || ""); +async function readWithRetry(call, method, args, { attempts = 4, pause = ms => new Promise(r => setTimeout(r, ms)), onRetry = () => {} } = {}) { + if (!["wait", "get", "list", "read", "stats"].includes(method)) throw new Error("Only read operations may be retried"); + for (let n = 1; ; n++) { + try { return await call(method, args, { timeout: 60000 }); } + catch (error) { + if (!transient(error) || n >= attempts) throw error; + onRetry({ method, attempt: n, error: error.message }); + await pause(Math.min(1000, n * 250)); + } + } +} +function writeJSON(file, value) { + fs.writeFileSync(file + ".tmp", JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(file + ".tmp", file); +} +function summarize(rows, profiles) { + const median = values => { + const s = values.filter(Number.isFinite).sort((a, b) => a - b); + return s.length ? (s[Math.floor((s.length - 1) / 2)] + s[Math.floor(s.length / 2)]) / 2 : null; + }; + return Object.fromEntries(profiles.map(profile => { + const jobs = rows.filter(r => r.profile === profile); + const accepted = jobs.filter(r => r.accepted).length; + const total = key => jobs.reduce((n, j) => n + (j.result?.usage?.[key] || 0), 0); + const totalInput = total("inTokens"); + return [profile, { jobs: jobs.length, attempted: jobs.filter(j => j.result).length, + accepted, usageAvailable: jobs.filter(j => j.result?.usage?.available).length, + totalInput, uncachedInput: total("uncachedInTokens"), cacheRead: total("cacheReadTokens"), + cacheWrite: total("cacheWriteTokens"), output: total("outTokens"), modelRequests: total("modelRequests"), + repairs: jobs.reduce((n, j) => n + (j.result?.attempts?.filter(a => a.phase === "repair").length || 0), 0), + inputPerAccepted: accepted ? totalInput / accepted : null, + medianExecutionMs: median(jobs.map(j => j.result?.elapsedMs)) }]; + })); +} +module.exports = { transient, readWithRetry, writeJSON, summarize }; diff --git a/docs/coding-benchmarks.md b/docs/coding-benchmarks.md new file mode 100644 index 0000000..d4d474c --- /dev/null +++ b/docs/coding-benchmarks.md @@ -0,0 +1,64 @@ +# Coding efficiency benchmarks + +Optimize verified useful work, not prompt size alone. A smaller request can lose its advantage if it causes more tool calls, failed checks, or repairs. The benchmark records those outcomes separately. + +The [September 20 comparison](../benchmarks/results/2026-09-20/RESULTS.md) records the completed 12-job run, including failures and task-level regressions. Standard remains the default; scoped is selectable for bounded local coding. + +## Controlled request inspection + +```sh +npm run benchmark:engine-prompt -- --output reviews/request-size.json +``` + +This starts the installed OpenCode engine against a local synthetic OpenAI-compatible endpoint. It makes no real model inference calls. The temporary project includes a recognizable repository instruction and 20 unrelated fixture skills. Standard, focused, and scoped sessions receive the same request. Scoped keeps the upstream model-specific prompt; focused substitutes a compact custom prompt. The probe asserts that all three preserve the repository instruction, that restricted profiles remove the fixture skills catalog, and that recursive/unrelated tools are absent from restricted profiles. + +The saved report contains message and tool-schema character counts and tool names, never full prompt text. Character counts measure serialized request size; they are not tokenizer measurements. Global configuration and installed skills can change the result, so the report describes the measured environment rather than a universal percentage. + +## Repository regression comparison + +```sh +# Offline: verify the task fixtures and evaluator first. +npm run benchmark:coding + +# Real inference: free-only selection, two repetitions per profile. +npm run benchmark:coding -- --live --repeats 2 --profiles standard,scoped --model opencode/big-pickle \ + --output reviews/coding-comparison.json +``` + +This compares `standard` and `scoped` engine profiles by default; `--profiles standard,focused` evaluates the compact-prompt experiment. It runs six controlled regressions in actual OmniWork modules. Each task has two seeded faults and a behavior contract. The fixture copies the current `electron/*.js` tree so workers can inspect surrounding code. Each worker starts from its own mutated Git repository with no reference solution in its history. + +| Task | Behavior under evaluation | +| --- | --- | +| Original output paging | Lossless retrieval, contiguous offsets, bounded previews, metadata and retention accounting. | +| Catalog policy | Free/paid/unknown classification, partial prices, explicit tool capability, combined filters. | +| Compaction continuity | Single-task tool cycles, repeated summaries, original constraints, summarizer failure, no orphaned tool results. | +| Repair accounting | Bounded same-worker repair, failed-check status, cumulative token/cache/request metrics. | +| Workspace boundaries | Traversal rejection, directory versus sibling ownership, private snapshots, source/index preservation. | +| Durable admission | Storage failure, idempotent retry, persistence before scheduling, shared provider identity. | + +Preflight runs the evaluator on the reference implementation and requires success, then inserts the regression and requires failure. It also runs the **exact public command workers will run**, including its working directory and arguments. This catches harness mistakes before spending inference tokens. + +The public smoke test is available to workers. Additional evaluator programs remain outside their checkouts and are not returned as repair feedback. After a terminal result, the harness runs those checks, applies successful patches through `jobs_apply`, and reruns withheld checks on the integrated source. Only that final success counts as accepted. Ownership excludes the public test and instructions. Worktrees are not adversarial isolation: withheld here means omitted from the worker checkout and prompt, not inaccessible to a malicious process with the user's privileges. + +Task/profile ordering alternates between repetitions to reduce systematic order bias. A shared queue runs at most two jobs per provider, preserving a realistic concurrent workload. Each job gets 180 seconds by default (`--timeout-ms` can set the same deadline for both profiles), 600,000 observed tokens, and one repair. A full two-repetition run submits 24 jobs; provider quotas and real latency apply. The benchmark uses only the requested model if the catalog marks it free. It never silently falls back to a paid model. + +## Read the report + +The JSON records the source hash, engine version, model, preflight, per-task results, patches, verification, usage and elapsed time. Companion `.patches/` files make solutions inspectable. Temporary service/worktrees are removed after complete runs. A transient read timeout is retried without replaying mutations. After an interruption or unrecoverable transport error, the service is stopped and an atomic checkpoint plus worktrees are retained. Resume with `node scripts/benchmark-coding.js --resume `; existing job IDs are recovered, interrupted execution is not silently replayed, and an already recorded application is verified rather than applied again. Logical job failures remain scored failures. Use `--cases` with comma-separated task IDs for a smaller diagnostic run. The report has an explicit `complete` flag and unfinished IDs. The runner exits nonzero if any task is unaccepted or unfinished, but retains the report so failures remain part of the comparison. + +The profile summaries include: + +- Accepted jobs, total jobs, attempted jobs, and usage availability. +- Total input, uncached input, cache reads/writes, output and observed model requests. +- Repair count and median execution time. +- **Input per accepted task:** all input consumed by that profile, including failed jobs, divided by accepted jobs. It is undefined when none succeed. + +`inTokens` is cumulative input across observed requests, including cache. It is not unique context size or a price estimate. Provider accounting of cached tokens against quota can differ. Missing usage and unobserved engine child/background sessions limit completeness. The detailed per-job records let you inspect such cases rather than infer that missing tokens were free. + +This corpus is larger than the initial three tiny implementation fixtures, but it is still six controlled regressions chosen by the implementer. It does not establish general coding superiority, SWE-bench performance, or statistical significance. Two repetitions are useful for catching obvious variance; they are not enough for a broad ranking. Inspect per-task regressions instead of selecting a profile from aggregate tokens alone. + +## Extend toward external coding quality + +The next tier is pinned historical issue tasks from independent repositories, with clean environment setup, withheld regression tests, existing-test preservation, and explicit patch application. Keep initial tests and evaluation fixed before comparing profiles; include failures in the denominator and costs in the numerator. Run across multiple task families and models before widening defaults based on quality claims. + +This follows the patch-and-test evaluation approach documented by [SWE-bench](https://www.swebench.com/SWE-bench/guides/evaluation/). Its official harness uses containerized repository environments; this local benchmark does not reproduce that isolation or dataset. [Aider's benchmarks](https://aider.chat/docs/benchmarks.html) provide another reference for evaluating coding rather than fluent answers. diff --git a/docs/worker-service.md b/docs/worker-service.md new file mode 100644 index 0000000..cbcf3d8 --- /dev/null +++ b/docs/worker-service.md @@ -0,0 +1,122 @@ +# Durable coding workers + +The `jobs_*` MCP tools and `omniwork-jobs` CLI share one background Node service per OmniWork data directory. Submit a batch, retain the returned IDs, and disconnect if needed. The service owns execution and stores results independently of the submitting client. It reuses the gateway and OpenCode engine across jobs. + +These are ordinary MCP tools. They do not claim support for the optional MCP Tasks protocol extension. + +## Start a batch + +From an installed source checkout, register `node /absolute/path/to/omniwork/electron/mcp-server.js` with your MCP client. For Codex: + +```sh +codex mcp add omniwork -- node /absolute/path/to/omniwork/electron/mcp-server.js +``` + +Claude Code's interactive setup is `npm run connect`. It previews and requests permission for its global configuration edits. Installing this implementation does not itself modify those configurations. + +Call `jobs_submit` with: + +```json +{ + "cwd": "/absolute/path/to/project", + "request_id": "parser-fix-v1", + "defaults": { + "policy": "free_only", + "timeout_ms": 180000, + "repair_attempts": 1 + }, + "tasks": [ + { + "task": "Make the parser reject incomplete quoted strings. Preserve the existing API.", + "allowed_paths": ["src/parser.js"], + "context_files": ["src/parser.js", "test/parser.test.js"], + "checks": ["node --test test/parser.test.js"] + }, + { + "task": "Document the current configuration options with examples.", + "allowed_paths": ["docs/configuration.md"], + "context_files": ["src/config.js"], + "checks": ["npm run lint:docs"] + } + ] +} +``` + +Use checks that exist in your project. Tasks may override defaults and `cwd`. Exact files or directory prefixes ending in `/` specify ownership; an empty list imposes no path constraint. Stable `request_id` values make retries idempotent. Reusing a key with a changed task contract or batch size is rejected. Each admitted job is saved before becoming executable; a partially persisted batch can be retried with the same key. + +The CLI accepts the same JSON as `npm run jobs -- submit tasks.json`, or a single task object with its own `cwd`. It also accepts standard input via `submit -`. + +## Retrieve and integrate + +| Tool | Purpose | +| --- | --- | +| `jobs_submit` | Persist 1–100 task contracts and return IDs immediately. | +| `jobs_wait` | Wait up to 25 seconds for a terminal result, or a change after `after_revision`. | +| `jobs_get` | Compact evidence; `detail: true` includes the contract, attempts, and acceptance output. | +| `jobs_list` | Recover recent IDs; filter by status and paginate. | +| `jobs_read` | Read a character page from `patch` or `result`; continue at `next_offset`. | +| `jobs_cancel` | Stop queued or active work while retaining evidence and workspaces. | +| `jobs_apply` | Validate and apply a completed isolated patch to the original source. | +| `jobs_status` | Inspect shared capacity, provider cooldowns, and counts. | + +CLI commands are `submit`, `get`, `wait`, `list`, `cancel`, `apply`, `status`, `refresh`, and `stop`. `wait` accepts comma-separated IDs. One wait does not guarantee the entire batch has finished; inspect each returned status and wait again for unfinished jobs. MCP callers should pass the last revision cursor when waiting for further changes. + +Statuses include `queued`, `blocked`, `preparing`, `running`, `collecting`, `cancelling`, and terminal `completed`, `partial`, `failed`, `cancelled`, or `interrupted`. A job without acceptance commands can complete with `verification: unverified`; it cannot be applied through `jobs_apply`. + +Default isolation snapshots tracked files and nonignored untracked files, including current dirty edits, using a temporary Git index and a detached worktree. The source index and branch are preserved. Git and an existing HEAD commit are required. Ignored dependencies and secrets are not copied. Optional `setup` commands install dependencies in the worktree and run again in the integration worktree. `isolation: shared` instead runs directly in the supplied directory and cannot offer patch isolation. + +A failed acceptance check can trigger a bounded repair in the same worker conversation. The repair receives check evidence and shares the original job deadline and token budget. Artifacts include binary patches and new files. Changes outside ownership block application. + +Application is serialized across clients. It rejects overlapping source changes, constructs a fresh integration snapshot including other current edits, applies the patch there, and reruns setup and acceptance commands. It refuses checks that alter patch files and refuses a source snapshot that changes during validation. Only then does it apply to the original working directory. Reapplying a recorded application is idempotent. This is a local Git workflow, not a filesystem transaction against unrelated external processes. + +## Capacity and model policy + +Defaults are four active jobs and two per provider. Provider selection considers available slots, observed failures, and latency. This is operational routing, not a learned measure of coding ability. Native inference requests are paced at 20 requests per minute per provider; provider cooldowns are shared across clients. A native provider outage before any tool call may retry twice within the original deadline. Engine execution and work with tool side effects are not automatically replayed. + +Omitting `model` selects a connected tool-capable model marked free by the catalog. `free_only` blocks paid or unknown-cost entries. `allow_paid` requires a concrete model ID; there is no automatic paid fallback. Search with `list_models`, connect accounts through the provider tools or `npm run providers -- login`, then submit or refresh the queue. Free classification reflects provider/catalog metadata and is not a billing guarantee or quota exemption. + +| Setting | Default | Scope | +| --- | --- | --- | +| `OMNIWORK_JOB_CONCURRENCY` | `4` | Active jobs, maximum 32. | +| `OMNIWORK_JOB_PROVIDER_CONCURRENCY` | `2` | Active jobs per provider, maximum 32. | +| `OMNIWORK_JOB_RPM` | `20` | Native requests per minute, and engine conversation admission pacing. | +| `OMNIWORK_JOB_PROVIDER_LIMITS` | `{}` | JSON keyed by provider, each entry containing integer `concurrency` and `rpm`. | +| `OMNIWORK_DATA_DIR` | OS app data | Separate service and artifact location. | + +For example, `OMNIWORK_JOB_PROVIDER_LIMITS='{"local":{"concurrency":4,"rpm":120}}'` overrides a provider identified as `local`. Consult `jobs_status` for actual provider keys. Configure environment variables on the MCP process that first starts the service. Stop and restart the service to change scheduling settings. Different gateway credentials or engine configuration cannot silently reuse an incompatible running service. + +Per-job defaults: 180 seconds, 200,000 observed input/output tokens, 24 native steps per conversation turn, and one repair. Limits are 30 minutes, 2,000,000 tokens, 80 steps, and three repairs. A request in flight can exceed the observed token limit. OpenCode controls its internal requests and tools; OmniWork can pace conversation starts and limit active jobs, deadlines, and observed usage. Scoped and focused engine workers have their own fixed 24-step cap; the per-job `max_steps` setting still controls the native loop only. Usage from failed native provider attempts reduces the remaining retry budget and is retained in `providerHistory`; final result usage describes the execution attempt, including its repairs. + +## OpenCode worker profiles + +Durable jobs default to `engine_profile: "standard"`. Profiles are explicit because lower input overhead does not guarantee better coding outcomes. + +| Profile | Coding instructions | Available tools | +| --- | --- | --- | +| `standard` (default) | Upstream model-specific prompt and project rules | Full OpenCode agent, including skills and connected tools. | +| `scoped` | Upstream model-specific prompt and project rules | Local read/search/edit/shell tools; automatic skills catalog and unrelated tools omitted. | +| `focused` (experimental) | Compact custom prompt and project rules | Same local tool selection as scoped. | + +For independent local coding jobs, try `defaults: { "engine_profile": "scoped" }` in `jobs_submit`; override individual tasks when they need skills, web access, connected MCP tools, or recursive delegation. Scoped/focused use supported OpenCode custom agents with a 24-step conversation limit. Their configuration is passed to the child engine through its environment; user OpenCode configuration files are not rewritten. See [coding benchmarks](coding-benchmarks.md) for reproduction and quality limits. + +Desktop sessions and legacy delegations keep their existing default agent. Permissions still follow the selected approval mode; tool selection is not an operating-system sandbox. Existing running daemons must be stopped/restarted to load updated engine code. Explicit profiles in already admitted jobs are retained. + +Engine usage now reports `uncachedInTokens`, `cacheReadTokens`, `cacheWriteTokens`, `reasoningTokens`, and `modelRequests` alongside existing totals. `inTokens` includes uncached input plus cache reads/writes; `outTokens` includes output plus reasoning. Repeated usage events are deduplicated by message, and repair turns accumulate usage. These measure the observed session; independently running engine background/child sessions are not a complete account billing ledger. Missing detailed fields mean unavailable, not zero. + +## Context and practical boundaries + +Workers receive a bounded task contract, selected file excerpts, and a cached repository orientation map of relevant paths and declarations. The map is capped at 4,000 characters; explicit file excerpts total at most 24,000 characters. Full content remains accessible through tools. Parent clients receive compact evidence and can retrieve artifacts without absorbing every worker transcript. + +The service benefits `jobs_*` calls. Legacy `delegate`, `delegate_parallel`, ACP turns, and desktop sessions run outside its scheduler. OpenCode's own internal tool execution or subagents are not individually scheduled by this service. + +Worktrees isolate ordinary edits; they are not operating-system sandboxes. Worker commands and acceptance commands run with the user's privileges. Keep acceptance tests outside worker-owned paths and review meaningful changes. Passing the supplied commands establishes only what those commands test. + +There is no dependency graph, automatic cross-job conflict resolution, durable provider conversation resumption after a daemon crash, or automatic workspace garbage collection yet. Parallel tasks should have independent ownership. Retained worktrees and artifacts use disk space. On restart, uncertain in-flight execution is marked interrupted instead of silently replayed; inspect it before resubmission. An interrupted integration may require inspecting both source and the retained integration directory. The service exits after 15 idle minutes without queued or active jobs, and the next client restarts it. Completed results remain on disk. + +Artifacts live beneath `/jobs/tasks//`; `job.json`, `result.json`, `changes.patch`, and the worktree provide evidence. The service descriptor contains a private token for its loopback API; do not share it. `service.log` records service errors. To clean a retained Git worktree, stop relevant jobs, inspect the output, and use `git worktree remove ` before deleting its artifact directory. + +## Validation and design references + +`npm run test:regression` includes deterministic service tests and a real multi-client MCP/daemon fixture. `node scripts/benchmark-jobs.js --live` runs three small coding fixtures using a connected free model, then integrates and retests their patches. This is opt-in because it sends real inference requests. For paired repository regression tasks and a local request-size probe, see [coding benchmarks](coding-benchmarks.md). + +The design borrows bounded repository orientation from [Aider's repository map](https://aider.chat/docs/repomap.html), using a simpler deterministic path/declaration index here. Reconnectable work follows the durable-handle motivation in [MCP Tasks](https://modelcontextprotocol.io/extensions/tasks/overview), while retaining ordinary tool compatibility. Neither inspiration establishes equivalent implementation or benchmark performance. diff --git a/electron/acp-server.js b/electron/acp-server.js index 5d291aa..9890397 100755 --- a/electron/acp-server.js +++ b/electron/acp-server.js @@ -21,7 +21,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { ensureShellPath } = require("./shell-path"); -const { agentEnv, ensureGateway, prewarmGateway, resolveModelsLive, listModels, invalidateModels, noModelHint, providers, makeAgent, isNoModelFailure, engineFallbackModel, opencode, DATA_DIR, SKILLS_DIR } = require("./headless"); +const { agentEnv, ensureGateway, prewarmGateway, resolveModels, resolveModelsLive, listModels, invalidateModels, noModelHint, providers, makeAgent, isNoModelFailure, engineFallbackModel, opencode, DATA_DIR, SKILLS_DIR } = require("./headless"); const skillsApi = require("./skills"); ensureShellPath(); // ACP clients can launch us with a minimal environment too @@ -156,8 +156,9 @@ function persist(sess) { } async function buildSession({ id, cwd, mode, messages, model, fallbackModels, engineSession }) { - const gw = await ensureGateway(log); - const models = await resolveModelsLive(gw, { model, fallbackModels }); + const selected = resolveModels({ model, fallbackModels }); + const gw = opencode.isEngineModel(selected.model) ? { baseUrl: "http://127.0.0.1", apiKey: "" } : await ensureGateway(log); + const models = opencode.isEngineModel(selected.model) ? selected : await resolveModelsLive(gw, { model, fallbackModels }); const sess = { id, cwd, mode: mode || DEFAULT_MODE, sink: () => {}, // replaced per turn @@ -166,6 +167,7 @@ async function buildSession({ id, cwd, mode, messages, model, fallbackModels, en agent: null, }; sess.gw = gw; + sess.modelContexts = models.modelContexts || {}; attachAgent(sess, models.model, models.fallbackModels, { messages: Array.isArray(messages) && messages.length ? messages : null, sessionID: engineSession || null }); sessions.set(id, sess); return sess; @@ -180,6 +182,7 @@ function attachAgent(sess, model, fallbackModels, { messages = null, sessionID = apiKey: gw.apiKey, model, fallbackModels, + modelContexts: sess.modelContexts, messages, // the engine carries these into its first prompt; the gateway agent restores them below sessionID, // an OpenCode session to resume workspace: sess.cwd, @@ -225,7 +228,7 @@ async function modelOption(sess) { const current = sess.agent.model; const chain = sess.agent.fallbackModels; let catalog = []; - try { catalog = await listModels(await ensureGateway(log)); } catch {} + try { if (!sess.agent.isEngine) catalog = await listModels(await ensureGateway(log)); } catch {} const options = []; const seen = new Set(); let engine = []; @@ -237,7 +240,7 @@ async function modelOption(sess) { const notes = []; if (n >= 0) notes.push(`Fallback #${n + 1}`); if (id === "auto") notes.push("free pool, routed by the gateway"); - if (opencode.isEngineModel(id)) notes.push("OpenCode engine · free, no account"); + if (opencode.isEngineModel(id)) { const entry = engine.find(m => m.id === id); notes.push(`OpenCode engine · ${entry?.free ? "free" : "connected account; charges may apply"}`); } options.push({ value: id, name: id, ...(notes.length ? { description: notes.join(" · ") } : {}) }); if (options.length >= MAX_MODEL_OPTIONS) break; } @@ -259,13 +262,19 @@ async function setModel(sess, modelId) { const id = String(modelId == null ? "" : modelId).trim(); if (!id) throw new RpcError(-32602, "model id is required"); if (sess.running) throw new RpcError(-32000, "cannot change the model while a prompt is running"); - const catalog = await listModels(await ensureGateway(log)).catch(() => []); + if (!opencode.isEngineModel(id)) sess.gw = await ensureGateway(log); + const catalog = opencode.isEngineModel(id) ? [] : await listModels(sess.gw).catch(() => []); + if (!opencode.isEngineModel(id)) { + const resolved = await resolveModelsLive(sess.gw, { model: id, fallbackModels: sess.agent.fallbackModels }); + sess.modelContexts = resolved.modelContexts || {}; + if (!sess.agent.isEngine) sess.agent.modelContexts = sess.modelContexts; + } // Not an error: passthrough providers accept ids the catalog doesn't list. if (catalog.length && !catalog.includes(id)) log(`model ${id} is not in the gateway catalog — passing it through`); if (Boolean(sess.agent.isEngine) !== opencode.isEngineModel(id)) { attachAgent(sess, id, sess.agent.fallbackModels.filter((m) => m !== id), { messages: sess.agent.messages }); } else { - sess.agent.model = id; + sess.agent.setModel(id); sess.agent.fallbackModels = sess.agent.fallbackModels.filter((m) => m !== id); } persist(sess); diff --git a/electron/agent.js b/electron/agent.js index 9e45786..f25544c 100644 --- a/electron/agent.js +++ b/electron/agent.js @@ -6,7 +6,10 @@ // `spawn_subagents` tool — the "Agent Deck". Subagents run concurrently, each // with its own context/tool-loop, and report a summary back to the parent. -const { TOOL_SCHEMA, executeTool } = require("./tools"); +const { TOOL_SCHEMA, executeToolResult } = require("./tools"); +const { ToolOutputStore, OUTPUT_TOOL } = require("./tool-output"); +const { mapLimit } = require("./execution"); +const cooldowns = new Map(); const { MEMORY_TOOL, saveMemory, loadForPrompt, KNOWLEDGE_TOOL, knowledgeSection, readKnowledge } = require("./memory"); const { DEFAULT_CONTEXT, estimateTokens, shouldCompact, compact } = require("./compactor"); const skills = require("./skills"); @@ -122,7 +125,9 @@ const failureSignature = (err) => `${err.status || 0}:${String(err.message || "" const tuning = require("./tuning"); class Agent { - constructor({ baseUrl, apiKey, model, fallbackModels = [], workspace, emit, mcp, memory = null, skillsDir = null, browser = null, canSpawn = true, depth = 0, approvalMode = "auto", approver = null, streaming = true, utilityModel, tiers, sessionId } = {}) { + constructor({ baseUrl, apiKey, model, fallbackModels = [], workspace, emit, mcp, memory = null, skillsDir = null, browser = null, canSpawn = true, depth = 0, approvalMode = "auto", approver = null, streaming = true, utilityModel, tiers, sessionId, contextTokens, modelContexts, beforeRequest, onRateLimit, maxSteps = MAX_STEPS, maxOutputTokens, allowedTools } = {}) { + this.allowedTools = allowedTools ? new Set(allowedTools) : null; + this.beforeRequest = beforeRequest; this.onRateLimit = onRateLimit; this.maxSteps = maxSteps; this.maxOutputTokens = maxOutputTokens; this.baseUrl = baseUrl.replace(/\/$/, ""); this.apiKey = apiKey; this.model = model || "auto"; @@ -135,16 +140,21 @@ class Agent { // gateway keeps prompt-cache affinity; the utility model runs housekeeping; // tiers run grunt work on the fast pool and escalate to the coding pool. this.sessionId = sessionId || tuning.newSessionId(); + this.utilityOverride = utilityModel; this.utilityModel = utilityModel || tuning.utilityModel(this.model); this.tiers = tiers !== undefined ? tiers : tuning.tiersFor(this.model); - this.cooling = new Map(); // model -> ms timestamp it is rate-limited until + if (!cooldowns.has(this.baseUrl)) cooldowns.set(this.baseUrl, new Map()); + this.cooling = cooldowns.get(this.baseUrl); + this.children = new Set(); + this.outputs = new ToolOutputStore(); + this.modelContexts = modelContexts || {}; this.workspace = workspace; this.emit = emit; // (event, payload) => void this.mcp = mcp || null; this.memory = memory; // { globalDir, projectDir, knowledgeDir? } | null this.skillsDir = skillsDir || null; // global skills root this.browser = browser || null; // BrowserManager - this.contextTokens = DEFAULT_CONTEXT; + this.contextTokens = contextTokens || this.modelContexts[this.model] || DEFAULT_CONTEXT; this.canSpawn = canSpawn; this.depth = depth; this.approvalMode = approvalMode; // "auto" | "ask" @@ -162,13 +172,14 @@ class Agent { // ── per-turn usage accounting ──────────────────────────────────── // Real API usage when the gateway reports it; chars/4 as a marked estimate // when it doesn't. Every model call in the turn counts, compaction included. - #addUsage(usage, fallbackChars) { + #addUsage(usage, fallbackChars, inputEstimate) { const s = this.turnStats; if (!s) return; if (usage && (usage.completion_tokens != null || usage.prompt_tokens != null)) { s.inTokens += usage.prompt_tokens || 0; s.outTokens += usage.completion_tokens || 0; } else { + s.inTokens += inputEstimate ?? (estimateTokens(this.messages) + Math.ceil(JSON.stringify(this.toolSchema()).length / 4)); s.outTokens += Math.ceil((fallbackChars || 0) / 4); s.estimated = true; } @@ -237,7 +248,7 @@ class Agent { const kn = this.memory && this.memory.knowledgeDir ? [KNOWLEDGE_TOOL] : []; const sk = this.skillsDir ? [skills.USE_SKILL_TOOL, skills.SAVE_SKILL_TOOL, skills.INSTALL_SKILLS_TOOL] : []; const web = this.browser ? [require("./browser").WEB_SEARCH_TOOL, require("./browser").BROWSE_TOOL] : []; - return [...TOOL_SCHEMA, ...sub, ...mem, ...kn, ...sk, ...web, ...extra]; + return [...TOOL_SCHEMA, OUTPUT_TOOL, ...sub, ...mem, ...kn, ...sk, ...web, ...extra].filter(t => !this.allowedTools || this.allowedTools.has(t.function.name)); } // Aborting must also kill in-flight network work — a flag alone leaves the @@ -245,23 +256,31 @@ class Agent { abort() { this.aborted = true; try { this.abortCtl?.abort(); } catch {} + for (const child of this.children) child.abort(); } setWorkspace(dir) { this.workspace = dir; } + setModel(model) { + this.model = model; + this.tiers = tuning.tiersFor(model); + this.utilityModel = this.utilityOverride || tuning.utilityModel(model); + this.contextTokens = this.modelContexts[model] || DEFAULT_CONTEXT; + } // One-off model call outside the session's message history (summaries, // titles, memory capture). async oneShot(prompt, { model = this.utilityModel } = {}) { const call = async (m) => { + await this.beforeRequest?.(this.abortCtl?.signal); const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", - signal: AbortSignal.timeout(60_000), - headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, "x-session-id": this.sessionId }, - body: JSON.stringify({ model: m, messages: [{ role: "user", content: prompt }], temperature: 0.2 }), + signal: this.abortCtl ? AbortSignal.any([this.abortCtl.signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, "x-session-id": this.sessionId, ...tuning.requestHeaders() }, + body: JSON.stringify({ model: m, messages: [{ role: "user", content: prompt }], temperature: 0.2, max_tokens: 2048 }), }); - if (!res.ok) { const e = new Error(`Gateway ${res.status}`); e.status = res.status; throw e; } + if (!res.ok) { const e = new Error(`Gateway ${res.status}`); e.status = res.status; if (res.status === 429) this.onRateLimit?.(60000); throw e; } const data = await res.json(); const content = data.choices?.[0]?.message?.content || ""; - this.#addUsage(data.usage, content.length); // counts only while a turn is active + this.#addUsage(data.usage, content.length, Math.ceil(prompt.length / 4)); return content; }; // Housekeeping runs on the cheap utility model; if the gateway doesn't know @@ -276,8 +295,9 @@ class Agent { // Compact when over threshold (or forced by /compact). Returns the notice, or null. async compactNow({ force = false } = {}) { - if (!force && !shouldCompact(this.messages, this.contextTokens)) return null; - const { messages, note } = await compact(this.messages, (p) => this.oneShot(p)); + const budget = Math.max(2000, this.contextTokens - Math.ceil(JSON.stringify(this.toolSchema()).length / 4) - 4096); + if (!force && !shouldCompact(this.messages, budget)) return null; + const { messages, note } = await compact(this.messages, (p) => this.oneShot(p), { budget }); this.messages = messages; this.#emitContext(); if (note) return note; @@ -300,11 +320,13 @@ class Agent { const failures = []; let lastErr = null; let lastSig = null; - let transientPrimary = false; // primary only hit 429 — keep it, don't switch + let transientPrimary = this.cooling.get(this.model) > now; // primary only hit 429 — keep it, don't switch for (let i = 0; i < chain.length; i++) { const model = chain[i]; const last = i === chain.length - 1; let msg; + if (this.modelContexts[model]) this.contextTokens = this.modelContexts[model]; + await this.compactNow(); try { msg = await this.#callOnce(model); } catch (err) { if (this.aborted || !isModelFailure(err)) throw err; @@ -313,14 +335,14 @@ class Agent { // rotate to the next provider, but keep the primary for later steps so // connected free tiers share the load instead of one draining first. if (err.status === 429) { - this.cooling.set(model, Date.now() + 60_000); + this.cooling.set(model, Date.now() + (err.retryAfterMs || 60_000)); if (model === this.model) transientPrimary = true; lastErr = err; continue; } - // Two models failing identically is the gateway talking, not the - // models; walking further would only replay the conversation again. + // Only a known gateway-wide exhaustion message justifies stopping. + // Two providers can independently reject credentials with identical text. const sig = failureSignature(err); - if (lastSig !== null && sig === lastSig) { lastErr = err; failures.push("(stopped: the next model failed the same way — this is the gateway, not the model)"); break; } + if (lastSig !== null && sig === lastSig && /Maximum combo retry limit reached/i.test(err.message)) { lastErr = err; failures.push("(stopped: the next model failed the same way — gateway pool exhausted)"); break; } lastSig = sig; lastErr = err; continue; } @@ -335,6 +357,7 @@ class Agent { } // Rotating past a rate-limited primary is temporary — don't make the // switch permanent, so the primary is retried once its cooldown passes. + this.effectiveModel = model; if (model !== this.model && !transientPrimary) this.#switchModel(model, failures.join("; ")); return msg; } @@ -358,6 +381,7 @@ class Agent { #switchModel(to, reason) { const from = this.model; this.model = to; + this.contextTokens = this.modelContexts[to] || this.contextTokens; this.modelSwitches.push({ from, to, reason }); this.emit("model_switch", { from, to, reason }); this.emit("system", { content: `⇄ ${from} failed (${reason.slice(0, 160)}) — continuing on ${to}` }); @@ -379,7 +403,7 @@ class Agent { // Auth and unknown-model rejections don't depend on streaming — don't pay // a second request to learn the same thing. Anything else (a provider that // rejects stream_options, a flaky 5xx) still gets the non-streaming retry. - if (err.status === 401 || err.status === 403 || err.status === 404) throw err; + if (this.aborted || [401, 403, 404, 429].includes(err.status)) throw err; streamed = null; } if (hasContent(streamed)) return streamed; @@ -387,16 +411,18 @@ class Agent { } async #requestModel(stream, model = this.model) { + await this.beforeRequest?.(this.abortCtl?.signal); const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", signal: this.abortCtl?.signal, - headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, "x-session-id": this.sessionId }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, "x-session-id": this.sessionId, ...tuning.requestHeaders() }, body: JSON.stringify({ model, messages: this.messages, tools: this.toolSchema(), tool_choice: "auto", temperature: 0.3, + ...(this.maxOutputTokens ? { max_tokens: this.maxOutputTokens } : {}), stream, // Ask for exact usage in the final stream chunk (OpenAI-compatible). // Providers that reject it fail the stream; callModel then falls back @@ -408,6 +434,10 @@ class Agent { const body = await res.text().catch(() => ""); const err = new Error(`Gateway ${res.status}: ${body.slice(0, 500)}`); err.status = res.status; + const retry = res.headers.get("retry-after"); + const delay = retry && (/^\d+(\.\d+)?$/.test(retry) ? Number(retry) * 1000 : Date.parse(retry) - Date.now()); + if (Number.isFinite(delay) && delay > 0) err.retryAfterMs = Math.min(delay, 300_000); + if (res.status === 429) this.onRateLimit?.(err.retryAfterMs || 60000); throw err; } const ctype = res.headers.get("content-type") || ""; @@ -484,44 +514,60 @@ class Agent { // Fan out to parallel subagents and return the combined summaries. async runSubagents(tasks) { - const list = Array.isArray(tasks) ? tasks.slice(0, 8) : []; + const list = Array.isArray(tasks) ? tasks : []; + if (list.length > 100) return "Error in spawn_subagents: maximum 100 tasks per call; none started."; if (!list.length) return "No subtasks provided."; const groupId = "g" + Date.now().toString(36); this.emit("subagent", { groupId, kind: "group_start", count: list.length, titles: list.map((t) => t.title || "subagent") }); - const results = await Promise.all( - list.map(async (t, i) => { + const results = await mapLimit(list, 4, async (t, i) => { + if (this.aborted) return { title: t.title || `subagent ${i + 1}`, result: "[status: cancelled] Parent stopped" }; const subId = `${groupId}_${i}`; const title = t.title || `subagent ${i + 1}`; this.emit("subagent", { groupId, subId, title, kind: "start" }); + let failure = null, done = false, childUsage = { inTokens: 0, outTokens: 0 }; const child = new Agent({ baseUrl: this.baseUrl, apiKey: this.apiKey, model: this.model, fallbackModels: this.fallbackModels, - utilityModel: this.utilityModel, tiers: this.tiers, + utilityModel: this.utilityOverride, tiers: this.tiers, + approvalMode: this.approvalMode, approver: this.approver, memory: this.memory, skillsDir: this.skillsDir, modelContexts: this.modelContexts, workspace: this.workspace, mcp: this.mcp, browser: this.browser, canSpawn: false, depth: this.depth + 1, // Nobody reads a subagent's token-by-token output — only its tool // labels and final summary. Skipping the stream attempt halves the // worst-case request count across the whole fan-out. streaming: false, emit: (type, payload) => { + if (type === "tool_call" || type === "tool_result") this.emit("subagent", { groupId, subId, kind: type, payload }); + if (type === "error") failure = payload.message; + if (type === "done") done = true; + if (type === "stats" && this.turnStats) { + this.turnStats.inTokens += Math.max(0, payload.inTokens - childUsage.inTokens); + this.turnStats.outTokens += Math.max(0, payload.outTokens - childUsage.outTokens); + this.turnStats.estimated ||= payload.estimated; + childUsage = payload; + } if (type === "tool_call") this.emit("subagent", { groupId, subId, kind: "tool", tool: subLabel(payload.name, payload.args) }); else if (type === "assistant") this.emit("subagent", { groupId, subId, kind: "text", snippet: String(payload.content || "").slice(0, 160) }); else if (type === "error") this.emit("subagent", { groupId, subId, kind: "error", message: payload.message }); else if (type === "model_switch") this.emit("subagent", { groupId, subId, kind: "model", from: payload.from, to: payload.to }); }, }); + this.children.add(child); try { await child.send(t.prompt || t.task || title); } - catch (e) { this.emit("subagent", { groupId, subId, kind: "error", message: e.message }); } - this.emit("subagent", { groupId, subId, kind: "done" }); - return { title, result: child.lastText || "(no summary returned)" }; - }) - ); + catch (e) { failure = e.message; this.emit("subagent", { groupId, subId, kind: "error", message: e.message }); } + finally { this.children.delete(child); } + this.emit("subagent", { groupId, subId, kind: "done", status: child.aborted ? "cancelled" : failure || !done ? "failed" : "completed" }); + return { title, result: `[status: ${child.aborted ? "cancelled" : failure || !done ? "failed" : "completed"}] ${failure || ""}\n${child.lastText || "(no summary returned)"}` }; + }); this.emit("subagent", { groupId, kind: "group_done" }); - return results.map((r) => `## ${r.title}\n${r.result}`).join("\n\n---\n\n"); + const failed = results.filter(r => /^\[status: (failed|cancelled)\]/.test(r.result)).length; + return (failed ? `Error in spawn_subagents: ${failed} task(s) did not complete.\n\n` : "") + + results.map((r) => `## ${r.title}\n${r.result}`).join("\n\n---\n\n"); } async send(userText, images) { this.aborted = false; + this.lastText = ""; this.abortCtl = new AbortController(); this.turnUndo = new Map(); this.undoAvailable = false; @@ -545,7 +591,7 @@ class Agent { this.messages.push({ role: "user", content: userText }); } - for (let step = 0; step < MAX_STEPS; step++) { + for (let step = 0; step < this.maxSteps; step++) { if (this.aborted) { this.emit("aborted", this.#statsPayload()); return; } // Auto-compact before the call would overflow; a failed summary degrades // to truncation inside compact(), never a crashed turn. @@ -558,7 +604,7 @@ class Agent { // backstop deep in — not on raw step count, so a fast model steadily // editing many files stays cheap instead of jumping to the coding tier. if (step >= 8 && this._tierFails > 0) this.#escalate("stalling on the fast tier"); - else if (step >= Math.floor(MAX_STEPS / 2)) this.#escalate("halfway through the step budget"); + else if (step >= Math.floor(this.maxSteps / 2)) this.#escalate("halfway through the step budget"); this.emit("thinking", { step }); let msg; @@ -590,16 +636,16 @@ class Agent { this.emit("tool_call", { id: call.id, name, args: parsedArgs }); // Approval gate (top-level agents only): mode decides per tool. - const decision = approvalDecision(this.approvalMode, name, !!(this.mcp && this.mcp.isMcpTool(name))); + const decision = this.allowedTools && !this.allowedTools.has(name) ? "block" : approvalDecision(this.approvalMode, name, !!(this.mcp && this.mcp.isMcpTool(name))); if (decision === "block") { - const blocked = "⏸ Plan mode — changes are disabled. Present your plan instead; the user can switch modes to execute it."; + const blocked = this.allowedTools && !this.allowedTools.has(name) ? `Error in ${name}: tool is not available for this worker.` : "⏸ Plan mode — changes are disabled. Present your plan instead; the user can switch modes to execute it."; this.emit("tool_result", { id: call.id, name, result: blocked }); this.messages.push({ role: "tool", tool_call_id: call.id, content: blocked }); continue; } - if (decision === "ask" && this.approver) { + if (decision === "ask") { const preview = this.#approvalPreview(name, parsedArgs); - const ok = await this.approver(call.id, name, parsedArgs, preview); + const ok = this.approver ? await this.approver(call.id, name, parsedArgs, preview) : false; if (!ok) { const denied = "❌ Denied by user."; this.emit("tool_result", { id: call.id, name, result: denied }); @@ -608,8 +654,10 @@ class Agent { } } - let result; - if (name === "spawn_subagents" && this.canSpawn) { + let result, outcome; + if (name === "read_output") { + result = this.outputs.read(parsedArgs.id, parsedArgs.offset, parsedArgs.limit); + } else if (name === "spawn_subagents" && this.canSpawn) { result = await this.runSubagents(parsedArgs.tasks); } else if (name === "web_search" && this.browser) { try { result = await this.browser.search(parsedArgs.query); } @@ -640,7 +688,8 @@ class Agent { } else if (this.mcp && this.mcp.isMcpTool(name)) { result = await this.mcp.callTool(name, parsedArgs); } else { - result = await executeTool(name, parsedArgs, { + outcome = await executeToolResult(name, parsedArgs, { + signal: this.abortCtl.signal, workspace: this.workspace, onChunk: (chunk) => this.emit("tool_stream", { id: call.id, chunk }), recordUndo: (rel, original) => { @@ -649,13 +698,16 @@ class Agent { }); } - this.emit("tool_result", { id: call.id, name, result }); + if (outcome) result = outcome.text; + const ok = outcome ? outcome.ok : !tuning.toolFailed(result); + if (name !== "read_output") result = this.outputs.capture(result, Math.max(1000, Math.min(12000, Math.floor(this.contextTokens / 4)))); + this.emit("tool_result", { id: call.id, name, result, ok, exitCode: outcome?.exitCode, changed: outcome?.changed || false }); this.messages.push({ role: "tool", tool_call_id: call.id, content: result }); - if (tuning.toolFailed(result)) { if (++this._tierFails >= 2) this.#escalate("two failed steps in a row"); } + if (!ok) { if (++this._tierFails >= 2) this.#escalate("two failed steps in a row"); } else this._tierFails = 0; } } - this.emit("error", { message: `Reached max steps (${MAX_STEPS}) without finishing.` }); + this.emit("error", { message: `Reached max steps (${this.maxSteps}) without finishing.` }); } } diff --git a/electron/compactor.js b/electron/compactor.js index ff02163..350436b 100644 --- a/electron/compactor.js +++ b/electron/compactor.js @@ -10,6 +10,7 @@ const KEEP_TURNS = 6; // recent user turns kept verbatim const MAX_SUMMARY_INPUT = 48_000; // chars fed to the summarize call const SUMMARY_MARKER = "[Conversation summary — earlier context was compacted]"; +const TASK_MARKER = "\n\nOriginal task (preserve its constraints):\n"; // chars/4 is a good-enough estimate; no tokenizer dependency. function estimateTokens(messages) { @@ -34,11 +35,17 @@ function cutIndex(messages, keepTurns = KEEP_TURNS) { for (let i = messages.length - 1; i > 0; i--) { if (messages[i].role === "user" && ++turns >= keepTurns) return i; } - return -1; // fewer than keepTurns turns — nothing to cut + // Delegations normally contain a single user turn. Cut only at a complete + // assistant/tool-cycle boundary, retaining the latest four cycles verbatim. + const boundaries = []; + for (let i = 2; i < messages.length; i++) { + if (messages[i].role === "assistant") boundaries.push(i); + } + return boundaries.length > 4 ? boundaries[boundaries.length - 4] : -1; } // Render older messages as plain text for the summarize call. -function renderForSummary(messages) { +function renderForSummary(messages, maxChars = MAX_SUMMARY_INPUT) { const lines = []; for (const m of messages) { const text = typeof m.content === "string" ? m.content : (m.content || []).map((c) => c.text || "[image]").join(" "); @@ -46,7 +53,7 @@ function renderForSummary(messages) { if (text.trim()) lines.push(`${m.role}: ${text.slice(0, 2000)}`); } let out = lines.join("\n"); - if (out.length > MAX_SUMMARY_INPUT) out = out.slice(-MAX_SUMMARY_INPUT); + if (out.length > maxChars) out = out.slice(0, Math.floor(maxChars / 4)) + "\n[intermediate history omitted]\n" + out.slice(-Math.floor(maxChars * 3 / 4)); return out; } @@ -55,19 +62,31 @@ const SUMMARY_PROMPT = (transcript) => // Returns { messages, note } — never throws. `summarize(prompt)` is an async // model call; on failure we fall back to hard truncation with a notice. -async function compact(messages, summarize) { - const cut = cutIndex(messages); +async function compact(messages, summarize, { budget = DEFAULT_CONTEXT } = {}) { + let cut = cutIndex(messages); if (cut <= 1) return { messages, note: null }; // nothing meaningful before the tail + // Keep fewer complete cycles when routing to a small-context model. + while (estimateTokens(messages.slice(cut)) > budget * 0.5) { + const next = messages.findIndex((m, i) => i > cut && m.role === "assistant"); + if (next < 0) break; + cut = next; + } const before = estimateTokens(messages); const head = messages.slice(1, cut); const tail = messages.slice(cut); + const task = messages.find((m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith(SUMMARY_MARKER)); + const previousSummary = messages.find(m => m.role === "user" && typeof m.content === "string" && m.content.startsWith(SUMMARY_MARKER)); + const priorTask = previousSummary?.content.includes(TASK_MARKER) ? previousSummary.content.slice(previousSummary.content.indexOf(TASK_MARKER) + TASK_MARKER.length) : ""; + const originalTask = priorTask || task?.content; + const anchor = originalTask ? TASK_MARKER + originalTask.slice(0, 12000) : ""; + const summaryLimit = Math.max(256, Math.min(16000, Math.floor(budget * 0.8))); let summaryMsg; try { - const summary = await summarize(SUMMARY_PROMPT(renderForSummary(head))); + const summary = await summarize(SUMMARY_PROMPT(renderForSummary(head, Math.min(MAX_SUMMARY_INPUT, Math.floor(budget * 1.5))))); if (!summary || !summary.trim()) throw new Error("empty summary"); - summaryMsg = { role: "user", content: `${SUMMARY_MARKER}\n${summary.trim()}` }; + summaryMsg = { role: "user", content: `${SUMMARY_MARKER}\n${summary.trim().slice(0, summaryLimit)}${anchor}` }; } catch { - summaryMsg = { role: "user", content: `${SUMMARY_MARKER}\n(Summary unavailable — older messages were truncated.)` }; + summaryMsg = { role: "user", content: `${SUMMARY_MARKER}\n(Summary unavailable — older messages were truncated. Recheck files and completed work before repeating actions.)${anchor}` }; } const out = [messages[0], summaryMsg, ...tail]; const after = estimateTokens(out); diff --git a/electron/engine-profile.js b/electron/engine-profile.js new file mode 100644 index 0000000..3b90e59 --- /dev/null +++ b/electron/engine-profile.js @@ -0,0 +1,21 @@ +"use strict"; +// Supported OpenCode agent configuration, scoped to explicitly selected workers. +const NAME = "omniwork-worker"; +const CORE = ["read", "edit", "write", "apply_patch", "bash", "glob", "grep", "list"]; +const PROMPT = `You are a focused coding worker in an existing repository. Complete the caller's task and follow the repository's instructions. +Inspect relevant source before editing. Use the supplied file context and paths to avoid broad exploration. Search narrowly and read only needed ranges. Make cohesive changes within the caller's ownership; preserve unrelated work and public behavior. Do not weaken tests to make them pass. +Use local tools directly. Batch independent reads and small related edits when practical. Keep command output bounded; inspect additional output when needed. Run the specified acceptance checks, diagnose failures, and fix the implementation within the available steps. Never claim a check passed without running it. Do not commit or publish unless requested. +Finish with a brief statement of changes, checks actually run, and unresolved issues.`; +function config({ compact = true } = {}) { + return { description: "Focused local coding worker with bounded steps", mode: "primary", hidden: true, + ...(compact ? { prompt: PROMPT } : {}), steps: 24, permission: { "*": "deny", ...Object.fromEntries(CORE.map(k => [k, "allow"])), external_directory: "allow" } }; +} +function permissions(mode) { + const action = mode === "auto" ? "allow" : "ask"; + const edits = ["edit", "write", "apply_patch"]; + return [{ permission: "*", pattern: "*", action: "deny" }, + ...CORE.map(permission => ({ permission, pattern: "*", action: ["read", "glob", "grep", "list"].includes(permission) ? "allow" : mode === "plan" ? "deny" : mode === "edits" && edits.includes(permission) ? "allow" : action })), + { permission: "external_directory", pattern: "*", action }]; +} +const SCOPED_NAME = "omniwork-scoped"; +module.exports = { NAME, SCOPED_NAME, CORE, PROMPT, config, permissions }; diff --git a/electron/execution.js b/electron/execution.js new file mode 100644 index 0000000..e8b0d43 --- /dev/null +++ b/electron/execution.js @@ -0,0 +1,125 @@ +"use strict"; + +// One bounded queue for both in-loop workers and MCP delegations. Never drop jobs. +async function mapLimit(items, limit, run) { + const results = new Array(items.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(items.length, Math.max(1, limit)) }, async () => { + while (cursor < items.length) { + const i = cursor++; + results[i] = await run(items[i], i); + } + })); + return results; +} + +// Observed outcomes, not the model's description of what it intended to do. +async function executeTask({ task, createAgent, recover, checks = [], runCheck, repairAttempts = 0, maxTokens = Infinity, timeoutMs = 600_000, signal, progress = () => {} }) { + const started = Date.now(); + const controller = new AbortController(); + let agent, error = null, completed = false, timedOut = false, steps = 0, budgetReason = null; + const changes = [], calls = new Map(), attempts = [], verification = []; + const checkHistory = []; + const usage = { inTokens: 0, outTokens: 0, estimated: false, available: false }; + let lastUsage = { inTokens: 0, outTokens: 0 }; + const emit = (type, p) => { + if (controller.signal.aborted) return; + if (type === "subagent" && (p.kind === "tool_call" || p.kind === "tool_result")) { + emit(p.kind, { ...p.payload, id: `${p.subId}:${p.payload.id}` }); + return; + } + if (type === "error") error = p.message || "Agent failed"; + if (type === "done") completed = true; + if (type === "thinking") progress(++steps, "working"); + if (type === "tool_call") { calls.set(p.id, p); progress(steps, p.name); } + if (type === "tool_result") { + const call = calls.get(p.id); + if (p.ok === true && call && /^(write_file|edit_file|write|edit|patch|apply_patch)$/.test(call.name)) { + changes.push({ tool: call.name, path: call.args.path || call.args.filePath || call.args.file || null }); + } + } + if (type === "stats" || type === "done") { + if (p.inTokens != null && p.outTokens != null) { + usage.inTokens += Math.max(0, p.inTokens - lastUsage.inTokens); + usage.outTokens += Math.max(0, p.outTokens - lastUsage.outTokens); + for (const key of ["uncachedInTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens", "modelRequests"]) { + if (Number.isFinite(p[key])) usage[key] = (usage[key] || 0) + Math.max(0, p[key] - (lastUsage[key] || 0)); + } + lastUsage = p; + usage.available = true; + } + usage.estimated ||= Boolean(p.estimated); + if (usage.available && usage.inTokens + usage.outTokens >= maxTokens) { budgetReason = "Observed token budget reached"; cancel(); } + } + }; + let unblock; + const cancelled = new Promise((r) => { unblock = r; }); + const cancel = () => { controller.abort(); agent?.abort(); unblock(); }; + const timer = setTimeout(() => { timedOut = true; cancel(); }, timeoutMs); + signal?.addEventListener("abort", cancel, { once: true }); + if (signal?.aborted) cancel(); + const work = async () => { + if (controller.signal.aborted) return; + agent = await createAgent(emit, controller.signal); + if (controller.signal.aborted) { agent.abort(); return; } + const attempt = { model: agent.model, error: null }; + attempts.push(attempt); + await agent.send(task); + Object.assign(attempt, { model: agent.effectiveModel || agent.model, error }); + // Recovery is safe only before any tools were attempted. Otherwise retrying + // a task from scratch can duplicate side effects. + if (error && !calls.size && recover && !controller.signal.aborted) { + const next = await recover(error, agent, emit, controller.signal); + if (next) { + agent = next; error = null; completed = false; lastUsage = { inTokens: 0, outTokens: 0 }; + const retry = { model: agent.model, error: null }; + attempts.push(retry); + await agent.send(task); + Object.assign(retry, { model: agent.effectiveModel || agent.model, error }); + } + } + if (!error && completed && runCheck) { + for (let round = 0; round <= repairAttempts; round++) { + verification.length = 0; + for (const command of checks) { + if (controller.signal.aborted) return; + const result = await runCheck(command, controller.signal); + if (controller.signal.aborted) return; + verification.push({ command, ok: result.ok, exitCode: result.exitCode, output: result.text.slice(-8000) }); + } + if (!verification.some(c => !c.ok)) break; + checkHistory.push({ round, checks: verification.slice() }); + if (round === repairAttempts) { error = "Acceptance check failed"; break; } + progress(++steps, `repair ${round + 1}: acceptance checks failed`); + completed = false; lastUsage = { inTokens: 0, outTokens: 0 }; + const repair = { model: agent.model, phase: "repair", error: null }; attempts.push(repair); + const evidence = verification.filter(c => !c.ok).map(c => `${c.command}\n${c.output}`).join("\n\n").slice(-12000); + await agent.send(`Acceptance checks failed. Repair the implementation in this existing workspace; preserve the original task and ownership constraints. Do not weaken, delete or bypass tests.\n\n${evidence}`); + repair.error = error; + if (error || !completed || controller.signal.aborted) break; + } + } + }; + try { + await Promise.race([work().catch(e => { error = e.message; }), cancelled]); + } finally { + clearTimeout(timer); signal?.removeEventListener("abort", cancel); + } + const stopped = budgetReason || (timedOut ? "Task deadline exceeded" : controller.signal.aborted ? "Cancelled by caller" : error || (!completed ? "Agent stopped without a completion event" : null)); + if (stopped && attempts.length) attempts[attempts.length - 1].error ||= stopped; + const status = controller.signal.aborted ? (timedOut || budgetReason ? "partial" : "cancelled") : stopped ? (changes.length ? "partial" : "failed") : "completed"; + return { + status, reason: stopped, summary: agent?.lastText || "", changes, attempts, toolCalls: calls.size, checkHistory, + model: agent?.effectiveModel || agent?.model || null, verification: { status: verification.length ? (verification.every(c => c.ok) ? "passed" : "failed") : "unverified", checks: verification }, + usage: usage.available ? usage : { inTokens: null, outTokens: null, estimated: true, available: false }, elapsedMs: Date.now() - started, + }; +} + +function formatResult(r) { + return `[status: ${r.status}]${r.reason ? " " + r.reason : ""}\n${r.summary || "(no summary)"}` + + (r.changes.length ? "\n\nChanges:\n" + r.changes.map(c => `- ${c.tool}: ${c.path || "see workspace diff"}`).join("\n") : "") + + `\n\n[model: ${r.model || "unavailable"}]\n[verify: ${r.verification.status}]` + + r.verification.checks.map(c => `\n${c.ok ? "PASS" : "FAIL"}: ${c.command}\n${c.output}`).join(""); +} + +module.exports = { mapLimit, executeTask, formatResult }; diff --git a/electron/headless.js b/electron/headless.js index 15f4c34..02c6402 100644 --- a/electron/headless.js +++ b/electron/headless.js @@ -27,7 +27,7 @@ function appDataDir() { return path.join(process.env.XDG_CONFIG_HOME || path.join(h, ".config"), "omniwork"); } -const DATA_DIR = appDataDir(); +const DATA_DIR = process.env.OMNIWORK_DATA_DIR || appDataDir(); const SKILLS_DIR = path.join(DATA_DIR, "skills"); const GLOBAL_MEMORY_DIR = path.join(DATA_DIR, "memory"); @@ -117,7 +117,7 @@ function resolveModels({ model, fallbackModels } = {}) { // The gateway's catalog, cached briefly: an ACP client asks for it on every // session, and it only changes when someone adds a provider key. -let modelCache = { at: 0, ids: [] }; +let modelCache = { at: 0, ids: [], contexts: {} }; async function listModels(gw) { if (Date.now() - modelCache.at < 30_000) return modelCache.ids; let ids = modelCache.ids; @@ -128,10 +128,11 @@ async function listModels(gw) { }); if (res.ok) { const data = await res.json(); + modelCache.contexts = Object.fromEntries((data.data || []).filter(m => m.id && (m.context_length || m.max_input_tokens)).map(m => [m.id, m.context_length || m.max_input_tokens])); ids = (data.data || []).map((m) => m && m.id).filter((id) => typeof id === "string" && id); } } catch {} - modelCache = { at: Date.now(), ids }; + modelCache = { ...modelCache, at: Date.now(), ids }; return ids; } @@ -151,9 +152,10 @@ function invalidateModels() { modelCache = { at: 0, ids: [] }; chainCache = { at async function resolveModelsLive(gw, opts = {}) { const r = resolveModels(opts); + await listModels(gw); // Pinned models need context metadata too. const decided = opts.fallbackModels != null || process.env.OMNIWORK_MODEL_FALLBACKS != null; if (!decided) r.fallbackModels = (await defaultFallbacks(gw)).filter((m) => m !== r.model); - return r; + return { ...r, modelContexts: modelCache.contexts, contextTokens: modelCache.contexts?.[r.model] }; } // What to tell a caller whose every model failed. The built-in free pool is @@ -185,8 +187,8 @@ async function engineFallbackModel() { if (/^(0|off|false|no)$/i.test(String(process.env.OMNIWORK_ENGINE_FALLBACK || ""))) return null; if (!opencode.available()) return null; try { - const list = await opencode.getEngine().models(); - return list.length ? list[0].id : `${opencode.PREFIX}nemotron-3.5-lightning-free`; + const list = (await opencode.getEngine().models()).filter(m => m.free && m.tools !== false); + return list.length ? list[0].id : null; } catch { return null; } } diff --git a/electron/job-client.js b/electron/job-client.js new file mode 100644 index 0000000..806ad97 --- /dev/null +++ b/electron/job-client.js @@ -0,0 +1,59 @@ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { spawn } = require("node:child_process"); +const os = require("node:os"); +function dataDir() { + if (process.env.OMNIWORK_DATA_DIR) return path.resolve(process.env.OMNIWORK_DATA_DIR); + const home = os.homedir(); + return process.platform === "darwin" ? path.join(home, "Library", "Application Support", "omniwork") : process.platform === "win32" ? path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "omniwork") : path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "omniwork"); +} +function configId() { + return crypto.createHash("sha256").update(JSON.stringify([process.env.OMNIWORK_BASE_URL || "", process.env.OMNIWORK_API_KEY || "", process.env.OMNIWORK_OPENCODE_BIN || "", process.env.OPENCODE_CONFIG_CONTENT || ""])).digest("hex"); +} +function location() { return path.join(dataDir(), "jobs"); } +function descriptor() { try { return JSON.parse(fs.readFileSync(path.join(location(), "service.json"), "utf8")); } catch { return null; } } +async function request(endpoint, method, args = {}, { signal, timeout = 30000 } = {}) { + if (endpoint.version !== 1 || !Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535 || !/^[a-f0-9]{64}$/.test(endpoint.token)) throw new Error("Invalid worker service descriptor"); + const response = await fetch(`http://127.0.0.1:${endpoint.port}/rpc`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${endpoint.token}` }, body: JSON.stringify({ method, args }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout) }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || `Worker service HTTP ${response.status}`); + return result; +} +let connecting; +async function ensure() { + if (connecting) return connecting; + connecting = (async () => { + let old = descriptor(); + if (old) { + const live = await request(old, "health", {}, { timeout: 1000 }).catch(() => null); + if (live) { + if (old.configId !== configId()) throw new Error("The worker service is running with different provider settings. Stop it explicitly with omniwork-jobs stop before changing settings."); + return old; + } + } + fs.mkdirSync(location(), { recursive: true, mode: 0o700 }); + const log = fs.openSync(path.join(location(), "service.log"), "a", 0o600); + const child = spawn(process.execPath, [path.join(__dirname, "job-daemon.js")], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, detached: true, windowsHide: true, stdio: ["ignore", log, log] }); + let launchError; child.on("error", e => { launchError = e; }); child.unref(); fs.closeSync(log); + for (let i = 0; i < 100; i++) { + if (launchError) throw launchError; + await new Promise(r => setTimeout(r, 100)); + const found = descriptor(); + if (found && await request(found, "health", {}, { timeout: 500 }).catch(() => null)) { + if (found.configId !== configId()) throw new Error("Worker service provider settings differ; stop it before changing settings"); + return found; + } + } + throw new Error(`Worker service did not start. Inspect ${path.join(location(), "service.log")}`); + })(); + try { return await connecting; } finally { connecting = null; } +} +async function call(method, args = {}, options = {}) { + if (method === "submit" && !args.request_id) args = { ...args, request_id: crypto.randomUUID() }; + const endpoint = method === "stop" ? descriptor() : await ensure(); + if (!endpoint) return { stopped: true }; + return request(endpoint, method, args, options); +} +module.exports = { dataDir, location, configId, request, descriptor, ensure, call }; diff --git a/electron/job-context.js b/electron/job-context.js new file mode 100644 index 0000000..97738fc --- /dev/null +++ b/electron/job-context.js @@ -0,0 +1,34 @@ +"use strict"; +const fs = require("node:fs/promises"); +const path = require("node:path"); +const cache = new Map(); +const ignored = /(^|\/)(node_modules|vendor|dist|build|coverage|\.git)(\/|$)|(?:\.min\.js|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/; +// A bounded, deterministic orientation map. No embedding/model call, and no +// claim of semantic completeness. Content-addressed trees share the file index. +async function orientation(spec, workspace, git) { + if (!workspace.isolated) return ""; + const key = `${workspace.sourceRoot}:${workspace.baseTree}`; + if (!cache.has(key)) { + const promise = git(workspace.sourceRoot, ["ls-tree", "-r", "--name-only", "-z", workspace.baseTree]).then(text => text.split("\0").filter(f => f && !ignored.test(f))); + cache.set(key, promise); promise.catch(() => cache.delete(key)); + if (cache.size > 32) cache.delete(cache.keys().next().value); + } + const files = await cache.get(key); + const terms = [...new Set((spec.task.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) || []).filter(t => !["file", "create", "implement", "with", "that", "this", "test", "tests"].includes(t)))]; + const requested = [...spec.context_files, ...spec.allowed_paths].map(p => path.posix.join(workspace.relativeCwd.replace(/\\/g, "/"), p)); + const ranked = files.map(file => ({ file, score: terms.reduce((n,t) => n + (file.toLowerCase().includes(t) ? 2 : 0), 0) + (requested.some(p => p === file || (p.endsWith("/") && file.startsWith(p))) ? 10 : 0) })).sort((a,b) => b.score - a.score || a.file.localeCompare(b.file)); + const lines = ["Repository orientation (selected paths and declarations; read source before editing):"]; + for (const { file, score } of ranked.slice(0, 24)) { + lines.push(file); + if (score <= 0 || !/\.(?:[cm]?[jt]sx?|py|rs|go|java|rb|cs)$/.test(file)) continue; + const full = path.join(workspace.tree, file); + const stat = await fs.lstat(full); + if (!stat.isFile() || stat.size > 200000) continue; + const source = await fs.readFile(full, "utf8"); + const declarations = source.split("\n").map((s,i) => ({s,i})).filter(({s}) => /^\s*(?:(?:export|public|private|static|async|default)\s+)*(?:class|interface|type|function|def|fn|func|struct|enum)\s+[\w$]+/.test(s)).slice(0, 8); + for (const {s,i} of declarations) lines.push(` ${i+1}: ${s.trim().slice(0,140)}`); + if (lines.join("\n").length > 4000) break; + } + return lines.join("\n").slice(0,4000); +} +module.exports = { orientation }; diff --git a/electron/job-daemon.js b/electron/job-daemon.js new file mode 100755 index 0000000..4196190 --- /dev/null +++ b/electron/job-daemon.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); +const http = require("node:http"); +const crypto = require("node:crypto"); +const { location, configId } = require("./job-client"); +const { JobService } = require("./job-service"); +const { executeTask } = require("./execution"); +const { executeToolResult } = require("./tools"); +const { ensureShellPath } = require("./shell-path"); +ensureShellPath(); +const root = location(), lock = path.join(root, "service.lock"); +fs.mkdirSync(root, { recursive: true, mode: 0o700 }); +function acquire() { + try { fs.mkdirSync(lock); } catch (e) { + if (e.code !== "EEXIST") throw e; + let owner; + try { owner = JSON.parse(fs.readFileSync(path.join(lock, "owner.json"), "utf8")); } catch {} + let alive = false; + if (owner?.pid) { try { process.kill(owner.pid, 0); alive = true; } catch (e) { if (e.code === "EPERM") alive = true; } } + if (alive || (!owner && Date.now() - fs.statSync(lock).mtimeMs < 15000)) return false; + // Serialize stale-lock recovery; a second launcher must not delete a + // replacement lock acquired by the first launcher. + let recovery; + try { recovery = fs.openSync(lock + ".recovery", "wx", 0o600); } catch { return false; } + try { + let current; + try { current = JSON.parse(fs.readFileSync(path.join(lock, "owner.json"), "utf8")); } catch {} + if (current?.pid && current.pid !== owner?.pid) return false; + fs.rmSync(lock, { recursive: true, force: true }); + try { fs.mkdirSync(lock); } catch { return false; } + fs.writeFileSync(path.join(lock, "owner.json"), JSON.stringify({ pid: process.pid }), { mode: 0o600 }); + return true; + } finally { fs.closeSync(recovery); fs.rmSync(lock + ".recovery", { force: true }); } + } + fs.writeFileSync(path.join(lock, "owner.json"), JSON.stringify({ pid: process.pid }), { mode: 0o600 }); return true; +} +if (!acquire()) process.exit(0); +const headless = require("./headless"); +const modelCatalog = require("./model-catalog"); +const log = (...a) => console.error(new Date().toISOString(), ...a); +let gateway = null; +const bounded = (key, fallback, max) => { const n = Number(process.env[key]); return Number.isInteger(n) && n > 0 && n <= max ? n : fallback; }; +let providerLimits = {}; +try { + const config = JSON.parse(process.env.OMNIWORK_JOB_PROVIDER_LIMITS || "{}"); + for (const [key, value] of Object.entries(config)) { + if (Number.isInteger(value.concurrency) && value.concurrency > 0 && value.concurrency <= 32 && Number.isInteger(value.rpm) && value.rpm > 0 && value.rpm <= 60000) providerLimits[key] = value; + } +} catch (e) { log("Ignoring invalid provider limits:", e.message); } +const service = new JobService({ + dir: path.join(root, "tasks"), + concurrency: bounded("OMNIWORK_JOB_CONCURRENCY", 4, 32), + providerConcurrency: bounded("OMNIWORK_JOB_PROVIDER_CONCURRENCY", 2, 32), + requestsPerMinute: bounded("OMNIWORK_JOB_RPM", 20, 60000), providerLimits, + catalog: async () => { + const result = await modelCatalog.catalog(gateway); + if (!result.models.length && result.errors.length) throw new Error(result.errors.map(e => e.message).join("; ")); + return result.models; + }, + runCheck: (command, cwd, signal) => executeToolResult("run_command", { command }, { workspace: cwd, signal }), + run: async opts => executeTask({ task: opts.task, signal: opts.signal, timeoutMs: opts.timeoutMs, + checks: opts.checks, repairAttempts: opts.repair_attempts, maxTokens: opts.max_tokens, progress: opts.progress, + createAgent: async emit => { + const isEngine = headless.opencode.isEngineModel(opts.model); + const gw = isEngine ? null : await headless.ensureGateway(log); + const resolved = isEngine ? {} : await headless.resolveModelsLive(gw, { model: opts.model, fallbackModels: [] }); + const agent = headless.makeAgent({ ...resolved, baseUrl: gw?.baseUrl, apiKey: gw?.apiKey, model: opts.model, fallbackModels: [], + workspace: opts.cwd, ...headless.agentEnv(opts.sourceCwd), emit, streaming: false, canSpawn: false, + engineProfile: opts.engine_profile, utilityModel: opts.model, tiers: false, maxSteps: opts.max_steps, maxOutputTokens: 4096, + allowedTools: ["list_dir", "read_file", "write_file", "edit_file", "run_command", "read_output", "use_skill", "read_knowledge"], + beforeRequest: opts.beforeRequest, onRateLimit: opts.onRateLimit, + }); + if (isEngine) { + const send = agent.send.bind(agent); + agent.send = async (...args) => { await opts.beforeRequest(opts.signal); return send(...args); }; + } + return agent; + }, + runCheck: (command, signal) => executeToolResult("run_command", { command }, { workspace: opts.cwd, signal }), + }), +}); +service.on("serviceError", e => log("Job persistence/execution error:", e.stack)); +headless.ensureGateway(log).then(gw => { gateway = gw; service.catalogAt = 0; service.tick(); }).catch(e => log("Gateway unavailable; engine jobs may still run:", e.message)); +const token = crypto.randomBytes(32).toString("hex"); +let shutdownStarted = false, lastActivity = Date.now(); +const server = http.createServer(async (req, res) => { + const answer = (status, value) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(value)); }; + if (req.headers.authorization !== `Bearer ${token}` || req.headers.origin) return answer(401, { error: "Unauthorized" }); + if (req.method !== "POST" || req.url !== "/rpc") return answer(404, { error: "Not found" }); + lastActivity = Date.now(); + try { + let raw = ""; + for await (const chunk of req) { raw += chunk; if (raw.length > 5000000) return answer(413, { error: "Request too large" }); } + const { method, args = {} } = JSON.parse(raw); + let value; + switch (method) { + case "health": value = { pid: process.pid, version: 1 }; break; + case "submit": value = service.submit(args); break; + case "get": value = service.get(args.id, Boolean(args.detail)); break; + case "list": value = service.list(args); break; + case "wait": value = await service.wait(args); break; + case "cancel": value = service.cancel(args.id); break; + case "read": value = await service.read(args); break; + case "apply": value = await service.apply(args.id); break; + case "stats": value = service.stats(); break; + case "refresh": service.catalogAt = 0; setImmediate(() => service.tick()); value = { refreshing: true }; break; + case "stop": value = { stopped: true }; setTimeout(shutdown, 50); break; + default: throw new Error("Unknown worker service method"); + } + answer(200, value); + } catch (e) { answer(400, { error: e.message }); } +}); +server.requestTimeout = 35000; +server.listen(0, "127.0.0.1", () => { + const info = { version: 1, pid: process.pid, port: server.address().port, token, configId: configId(), startedAt: new Date().toISOString() }; + const file = path.join(root, "service.json"); fs.writeFileSync(file + ".tmp", JSON.stringify(info), { mode: 0o600 }); fs.renameSync(file + ".tmp", file); + log("Worker service ready", process.pid); +}); +const idle = setInterval(() => { if (!service.active.size && ![...service.jobs.values()].some(j => j.status === "queued") && Date.now() - lastActivity > 15 * 60000) shutdown(); }, 60000); idle.unref(); +async function shutdown() { + if (shutdownStarted) return; shutdownStarted = true; + await service.stop(); headless.opencode.getEngine().stop(); + server.close(); server.closeAllConnections(); + fs.rmSync(path.join(root, "service.json"), { force: true }); fs.rmSync(lock, { recursive: true, force: true }); process.exit(0); +} +process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); diff --git a/electron/job-service.js b/electron/job-service.js new file mode 100644 index 0000000..a3002b2 --- /dev/null +++ b/electron/job-service.js @@ -0,0 +1,293 @@ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { EventEmitter } = require("node:events"); +const workspace = require("./job-workspace"); +const TERMINAL = new Set(["completed", "failed", "partial", "cancelled", "interrupted"]); +const integer = (v, fallback, min, max) => { + if (v == null) return fallback; + if (!Number.isInteger(v) || v < min || v > max) throw new Error(`Expected integer between ${min} and ${max}`); + return v; +}; +const strings = (v, limit, width = 8000) => { + if (v == null) return []; + if (!Array.isArray(v) || v.length > limit || v.some(s => typeof s !== "string" || !s.trim() || s.length > width)) throw new Error(`Expected up to ${limit} nonempty strings, at most ${width} characters each`); + return v.slice(); +}; +function normalize(input) { + if (!input || typeof input.task !== "string" || !input.task.trim() || input.task.length > 40000) throw new Error("task must contain 1–40000 characters"); + if (typeof input.cwd !== "string" || !path.isAbsolute(input.cwd) || !fs.statSync(input.cwd).isDirectory()) throw new Error("cwd must be an existing absolute directory"); + const policy = input.policy || "free_only"; + if (!["free_only", "allow_paid"].includes(policy)) throw new Error("policy must be free_only or allow_paid"); + const model = input.model || null; + if (model != null && (typeof model !== "string" || !model.trim() || model.length > 300)) throw new Error("model must be a model ID"); + if (model && /^auto(?:\/|$)/.test(model)) throw new Error("Omit model for managed selection, or pin a concrete model; opaque auto routes cannot enforce the job policy"); + if (policy === "allow_paid" && !model) throw new Error("allow_paid requires an explicit model"); + const engine_profile = input.engine_profile || "standard"; + if (!["focused", "scoped", "standard"].includes(engine_profile)) throw new Error("engine_profile must be focused, scoped or standard"); + const isolation = input.isolation || "worktree"; + if (!["worktree", "shared"].includes(isolation)) throw new Error("isolation must be worktree or shared"); + return { task: input.task, cwd: fs.realpathSync(input.cwd), model, policy, isolation, engine_profile, + checks: strings(input.checks, 10), setup: strings(input.setup, 4), + allowed_paths: strings(input.allowed_paths, 100, 1000).map(workspace.relativeFile), + context_files: strings(input.context_files, 20, 1000).map(workspace.relativeFile), + timeout_ms: integer(input.timeout_ms, 180000, 1000, 1800000), + max_tokens: integer(input.max_tokens, 200000, 1000, 2000000), + max_steps: integer(input.max_steps, 24, 1, 80), + repair_attempts: integer(input.repair_attempts, 1, 0, 3), + }; +} +function atomic(file, data) { + const tmp = file + ".tmp"; + const fd = fs.openSync(tmp, "w", 0o600); + try { fs.writeFileSync(fd, JSON.stringify(data)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + fs.renameSync(tmp, file); +} +function providerKey(model) { + // Zen and Go may share capacity. Do not multiply slots by model aliases. + return model.source === "OpenCode" && /^opencode/.test(model.provider || model.providerID) ? "opencode" : model.provider || model.providerID || model.id.split("/")[0]; +} +class JobService extends EventEmitter { + constructor({ dir, catalog, run, runCheck, concurrency = 4, providerConcurrency = 2, requestsPerMinute = 20, providerLimits = {} }) { + super(); this.setMaxListeners(0); + this.dir = dir; this.catalog = catalog; this.run = run; this.runCheck = runCheck; + this.concurrency = concurrency; this.providerConcurrency = providerConcurrency; this.requestsPerMinute = requestsPerMinute; this.providerLimits = providerLimits; + this.jobs = new Map(); this.keys = new Map(); this.active = new Map(); this.providers = new Map(); + this.models = []; this.catalogAt = 0; this.catalogError = null; this.revision = 0; this.stopping = false; this.integration = Promise.resolve(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + for (const id of fs.readdirSync(dir)) { + if (!/^[a-f0-9-]{36}$/.test(id)) continue; + const file = path.join(dir, id, "job.json"); + if (!fs.existsSync(file)) continue; + const job = JSON.parse(fs.readFileSync(file, "utf8")); + this.revision = Math.max(this.revision, job.revision || 0); + this.jobs.set(job.id, job); if (job.key) this.keys.set(job.key, job.id); + if (["running", "preparing", "collecting", "cancelling"].includes(job.status)) { + job.status = "interrupted"; job.reason = "Worker service restarted. Inspect the isolated workspace before resubmitting; execution was not replayed."; this.save(job); + } + if (job.integration?.status === "checking") { job.integration = { status: "interrupted", reason: "Integration was interrupted; inspect the source and retained integration worktree before retrying" }; this.save(job); } + } + this.timer = setInterval(() => this.tick(), 250); this.timer.unref(); + setImmediate(() => this.tick()); + } + save(job) { + job.revision = ++this.revision; job.updatedAt = new Date().toISOString(); + atomic(path.join(this.dir, job.id, "job.json"), job); + this.emit("change", job.id); + } + submit({ tasks, request_id }) { + if (!Array.isArray(tasks) || !tasks.length || tasks.length > 100) throw new Error("Submit 1–100 task objects"); + if (request_id != null && (typeof request_id !== "string" || request_id.length > 200 || !request_id)) throw new Error("request_id must be a nonempty string up to 200 characters"); + const specs = tasks.map(normalize); // Validate the entire batch first. + const batch = request_id || crypto.randomUUID(); + const entries = specs.map((spec, i) => { + const key = `${batch}:${i}`, fingerprint = crypto.createHash("sha256").update(JSON.stringify(spec)).digest("hex"); + const existing = this.jobs.get(this.keys.get(key)); + if (existing && existing.fingerprint !== fingerprint) throw new Error("request_id was already used with different tasks"); + return { spec, key, fingerprint, existing }; + }); + const prior = [...this.jobs.values()].find(j => j.batch === batch); + if (prior && prior.batchSize !== specs.length) throw new Error("request_id was already used with a different batch size"); + const jobs = entries.map(({ spec, key, fingerprint, existing }) => { + if (existing) return this.view(existing); + const id = crypto.randomUUID(); + fs.mkdirSync(path.join(this.dir, id), { mode: 0o700 }); + const job = { id, key, fingerprint, batch, batchSize: specs.length, spec, status: "queued", createdAt: new Date().toISOString(), result: null, progress: null }; + // Admission must reach disk before the scheduler can see this job. + this.save(job); this.jobs.set(id, job); this.keys.set(key, id); return this.view(job); + }); + setImmediate(() => this.tick()); + return { request_id: batch, revision: this.revision, jobs }; + } + job(id) { const j = this.jobs.get(id); if (!j) throw new Error("Unknown job ID"); return j; } + view(job, detail = false) { + if (detail) return JSON.parse(JSON.stringify(job)); + return { id: job.id, status: job.status, revision: job.revision, model: job.model?.id || job.spec.model, + createdAt: job.createdAt, updatedAt: job.updatedAt, reason: job.reason || job.result?.reason || null, + progress: job.progress, verification: job.result?.verification?.status || "unverified", + summary: job.result?.summary?.slice(0, 1600) || "", usage: job.result?.usage || null, + files: job.artifact?.files || [], outsideScope: job.artifact?.outsideScope || [], + workspace: job.workspace?.cwd || null, patch: job.artifact?.patch || null, appliedAt: job.appliedAt || null, integration: job.integration?.status || null }; + } + get(id, detail) { return this.view(this.job(id), detail); } + list({ limit = 20, offset = 0, status } = {}) { + const all = [...this.jobs.values()].reverse().filter(j => !status || j.status === status); + const start = integer(offset, 0, 0, 1000000); + return { total: all.length, jobs: all.slice(start, start + integer(limit, 20, 1, 100)).map(j => this.view(j)) }; + } + state(key) { + if (!this.providers.has(key)) this.providers.set(key, { active: 0, nextRequestAt: 0, cooldownUntil: 0, requests: 0, completed: 0, failed: 0, latencyMs: null }); + return this.providers.get(key); + } + async beforeRequest(key, signal) { + const state = this.state(key); + const rpm = this.providerLimits[key]?.rpm || this.requestsPerMinute; + for (;;) { + signal?.throwIfAborted(); + const now = Date.now(), delay = Math.max(state.nextRequestAt, state.cooldownUntil) - now; + if (delay <= 0) { state.nextRequestAt = now + 60000 / rpm; state.requests++; return; } + await new Promise((resolve, reject) => { + const cleanup = () => signal?.removeEventListener("abort", abort); + const timer = setTimeout(() => { cleanup(); resolve(); }, Math.min(delay, 1000)); + const abort = () => { clearTimeout(timer); cleanup(); reject(signal.reason || new Error("Cancelled")); }; + signal?.addEventListener("abort", abort, { once: true }); + }); + } + } + rateLimited(key, ms = 60000) { this.state(key).cooldownUntil = Math.max(this.state(key).cooldownUntil, Date.now() + Math.min(Math.max(ms, 1000), 300000)); } + async tick() { + if (this.ticking || this.stopping) return; + this.ticking = true; + try { + const waiting = [...this.jobs.values()].filter(j => ["queued", "blocked"].includes(j.status)); + if (!waiting.length) return; + if (Date.now() - this.catalogAt > 30000) { + try { this.models = await this.catalog(); this.catalogError = null; } catch (e) { this.catalogError = e.message; } + this.catalogAt = Date.now(); + } + for (const job of waiting) { + if (this.stopping) break; + if (!["queued", "blocked"].includes(job.status)) continue; + if (job.deadlineAt && Date.now() >= job.deadlineAt) { job.status = "partial"; job.reason = "Job deadline exceeded while waiting for provider recovery"; this.save(job); continue; } + if (this.active.size >= this.concurrency) continue; + let candidates = this.models.filter(m => (!job.spec.model || job.spec.model === m.id) && m.tools !== false && !/^auto(?:\/|$)/.test(m.id)); + if (job.spec.policy === "free_only") candidates = candidates.filter(m => m.free === true); + if (!candidates.length) { + const reason = this.catalogError || "No connected model meets the job policy. Connect a provider or submit an explicitly allowed model."; + if (job.status !== "blocked" || job.reason !== reason) { job.status = "blocked"; job.reason = reason; this.save(job); } + continue; + } + candidates = candidates.filter(m => { const k = providerKey(m), s = this.state(k); return s.active < (this.providerLimits[k]?.concurrency || this.providerConcurrency) && s.cooldownUntil <= Date.now(); }); + candidates.sort((a,b) => { + const x = this.state(providerKey(a)), y = this.state(providerKey(b)); + return x.active - y.active || x.failed / (x.completed + 1) - y.failed / (y.completed + 1) || (x.latencyMs || 10000) - (y.latencyMs || 10000); + }); + if (candidates.length) this.start(job, candidates[0]); + } + } catch (e) { this.emit("serviceError", e); } + finally { this.ticking = false; } + } + start(job, model) { + const ctl = new AbortController(), key = providerKey(model), state = this.state(key); + job.model = model; job.provider = key; job.status = "preparing"; job.reason = null; job.startedAt ||= new Date().toISOString(); + job.deadlineAt ||= Date.now() + job.spec.timeout_ms; + this.save(job); this.active.set(job.id, ctl); state.active++; + const dir = path.join(this.dir, job.id), started = Date.now(); + const deadline = setTimeout(() => ctl.abort(new Error("Job deadline exceeded")), Math.max(1, job.deadlineAt - Date.now())); + let lastProgress = 0; + (async () => { + try { + job.workspace ||= await workspace.prepare(job.spec, dir); ctl.signal.throwIfAborted(); this.save(job); + for (const command of job.setupComplete ? [] : job.spec.setup) { + const r = await this.runCheck(command, job.workspace.cwd, ctl.signal); + if (!r.ok) throw new Error(`Setup failed: ${r.text.slice(-4000)}`); + } + job.setupComplete = true; this.save(job); + const prompt = await workspace.context(job.spec, job.workspace); + ctl.signal.throwIfAborted(); job.status = "running"; this.save(job); + const priorTokens = (job.providerHistory || []).reduce((n, attempt) => n + (attempt.usage?.inTokens || 0) + (attempt.usage?.outTokens || 0), 0); + job.result = await this.run({ ...job.spec, task: prompt, cwd: job.workspace.cwd, sourceCwd: job.spec.cwd, model: model.id, signal: ctl.signal, + max_tokens: Math.max(1, job.spec.max_tokens - priorTokens), + timeoutMs: Math.max(1, job.deadlineAt - Date.now()), + beforeRequest: signal => this.beforeRequest(key, signal), onRateLimit: ms => this.rateLimited(key, ms), + progress: (step, message) => { job.progress = { step, message: String(message).slice(0, 300) }; if (Date.now() - lastProgress > 500) { lastProgress = Date.now(); this.save(job); } }, + }); + job.status = this.stopping ? "interrupted" : ctl.signal.aborted ? (ctl.signal.reason?.message === "Job deadline exceeded" ? "partial" : "cancelled") : job.result.status; + job.reason = this.stopping ? "Worker service stopped" : ctl.signal.aborted ? ctl.signal.reason?.message || "Cancelled" : job.result.reason; + } catch (e) { + job.status = this.stopping ? "interrupted" : ctl.signal.aborted ? (ctl.signal.reason?.message === "Job deadline exceeded" ? "partial" : "cancelled") : "failed"; + job.reason = e.message; + } finally { + clearTimeout(deadline); + let outcome = job.status; + job.status = "collecting"; this.save(job); + if (job.workspace) { + try { job.artifact = await workspace.collect(job.workspace, dir, job.spec.allowed_paths); } + catch (e) { job.reason = `Could not collect patch: ${e.message}`; if (outcome === "completed") outcome = "partial"; } + } + if (job.artifact?.outsideScope?.length) { outcome = "partial"; job.reason = "Worker edited files outside allowed_paths; patch requires review"; } + if (job.result) atomic(path.join(dir, "result.json"), job.result); + job.status = this.stopping ? "interrupted" : ctl.signal.aborted && ctl.signal.reason?.message !== "Job deadline exceeded" ? "cancelled" : outcome; + // Retry only native provider failures before ANY tool ran. Never replay + // side effects or opaque engine execution. Keep the original deadline. + const retryable = !model.id.startsWith("opencode/") && job.result?.toolCalls === 0 && /Gateway (429|503)|Lost connection to the engine/i.test(job.reason || ""); + const spentTokens = [...(job.providerHistory || []), job.result || {}].reduce((n, attempt) => n + (attempt.usage?.inTokens || 0) + (attempt.usage?.outTokens || 0), 0); + if (job.status === "failed" && retryable && spentTokens >= job.spec.max_tokens) { + job.status = "partial"; job.reason = "Observed token budget exhausted before provider retry"; + } + if (!this.stopping && !ctl.signal.aborted && job.status === "failed" && retryable && (job.providerRetries || 0) < 2) { + job.providerRetries = (job.providerRetries || 0) + 1; + job.providerHistory ||= []; job.providerHistory.push({ model: model.id, reason: job.reason, usage: job.result.usage }); + this.rateLimited(key, /429/.test(job.reason) ? 60000 : 5000); + job.status = "queued"; job.reason = "Waiting for provider recovery before a safe retry"; + } + if (TERMINAL.has(job.status)) job.finishedAt = new Date().toISOString(); + this.save(job); + state.active--; state[job.status === "completed" ? "completed" : "failed"]++; + state.latencyMs = state.latencyMs == null ? Date.now() - started : state.latencyMs * 0.8 + (Date.now() - started) * 0.2; + if (/429|rate.limit/i.test(job.result?.reason || job.reason || "")) this.rateLimited(key); + this.active.delete(job.id); setImmediate(() => this.tick()); + } + })().catch(e => { + // A persistence failure cannot leave a live worker or a leaked capacity slot. + ctl.abort(e); clearTimeout(deadline); + if (this.active.delete(job.id)) state.active--; + this.emit("serviceError", e); + }); + } + cancel(id) { + const job = this.job(id); + if (TERMINAL.has(job.status)) return this.view(job); + const active = this.active.get(id); + job.status = active ? "cancelling" : "cancelled"; job.reason = "Cancelled by caller"; this.save(job); + active?.abort(new Error("Cancelled by caller")); return this.view(job); + } + async wait({ ids, after_revision, timeout_ms = 25000 }) { + if (!Array.isArray(ids) || !ids.length || ids.length > 100) throw new Error("wait requires 1–100 job IDs"); + ids.forEach(id => this.job(id)); + const snapshot = () => ({ revision: Math.max(...ids.map(id => this.job(id).revision)), jobs: ids.map(id => this.get(id)) }); + const changed = () => after_revision == null ? ids.some(id => TERMINAL.has(this.job(id).status)) : ids.some(id => this.job(id).revision > after_revision); + if (changed() || timeout_ms === 0) return snapshot(); + await new Promise(resolve => { + const cleanup = () => { clearTimeout(timer); this.removeListener("change", onChange); resolve(); }; + const onChange = id => { if (ids.includes(id) && changed()) cleanup(); }; + const timer = setTimeout(cleanup, integer(timeout_ms, 25000, 0, 25000)); + this.on("change", onChange); + if (changed()) cleanup(); + }); + return snapshot(); + } + async read({ id, artifact = "patch", offset = 0, limit = 12000 }) { + const job = this.job(id); + if (!["patch", "result"].includes(artifact)) throw new Error("artifact must be patch or result"); + const file = artifact === "patch" ? job.artifact?.patch : job.result && path.join(this.dir, id, "result.json"); + if (!file) throw new Error("Artifact is not available yet"); + const text = await fs.promises.readFile(file, "utf8"); + const start = integer(offset, 0, 0, text.length), count = integer(limit, 12000, 1, 24000); + return { id, artifact, text: text.slice(start, start + count), total: text.length, next_offset: start + count < text.length ? start + count : null }; + } + async apply(id) { + const run = async () => { + const job = this.job(id); + if (job.appliedAt) return { appliedAt: job.appliedAt, alreadyApplied: true }; + job.integration = { status: "checking" }; this.save(job); + try { + const result = await workspace.apply(job, path.join(this.dir, id), (cmd,cwd) => this.runCheck(cmd, cwd, AbortSignal.timeout(60000))); + job.integration = { ...result, status: result.appliedAt ? "applied" : "failed" }; if (result.appliedAt) job.appliedAt = result.appliedAt; this.save(job); return result; + } catch (e) { job.integration = { status: "failed", reason: e.message }; this.save(job); throw e; } + }; + const pending = this.integration.then(run); this.integration = pending.catch(() => {}); return pending; + } + stats() { + const counts = {}; for (const j of this.jobs.values()) counts[j.status] = (counts[j.status] || 0) + 1; + return { counts, active: this.active.size, concurrency: this.concurrency, providers: Object.fromEntries(this.providers), catalogError: this.catalogError }; + } + async stop() { + this.stopping = true; clearInterval(this.timer); + for (const ctl of this.active.values()) ctl.abort(new Error("Worker service stopped")); + const end = Date.now() + 5000; + while (this.active.size && Date.now() < end) await new Promise(r => setTimeout(r, 25)); + } +} +module.exports = { JobService, normalize, TERMINAL, providerKey }; diff --git a/electron/job-tools.js b/electron/job-tools.js new file mode 100644 index 0000000..28b4b05 --- /dev/null +++ b/electron/job-tools.js @@ -0,0 +1,42 @@ +"use strict"; +const client = require("./job-client"); +const strings = { type: "array", items: { type: "string" } }; +const properties = { + task: { type: "string", description: "Self-contained task and required behavior." }, + cwd: { type: "string", description: "Absolute project directory; defaults to the submission cwd." }, + model: { type: "string", description: "Optional concrete model ID. Omit for managed free-model selection." }, + engine_profile: { type: "string", enum: ["standard", "scoped", "focused"], description: "Standard (default) preserves the full OpenCode agent and skills. Scoped keeps model-specific instructions with local coding tools and no automatic skills catalog. Focused also substitutes a compact experimental prompt. Scoped/focused conversations allow up to 24 steps." }, + policy: { type: "string", enum: ["free_only", "allow_paid"], description: "Default free_only. allow_paid requires an explicit model; no automatic paid fallback." }, + checks: { ...strings, maxItems: 10, description: "Acceptance commands. Required to apply an isolated patch." }, + setup: { ...strings, maxItems: 4, description: "Optional dependency/setup commands in the isolated workspace; also run for integration checks." }, + allowed_paths: { ...strings, maxItems: 100, description: "Owned relative files or directory prefixes ending in /. Edits outside these paths block application." }, + context_files: { ...strings, maxItems: 20, description: "Relevant relative files included as bounded excerpts, so workers need less exploration." }, + isolation: { type: "string", enum: ["worktree", "shared"], description: "Default worktree snapshots current Git changes. shared edits the original directory directly." }, + timeout_ms: { type: "integer", minimum: 1000, maximum: 1800000, description: "Total job deadline, including setup, verification and repair; default 180000." }, + max_tokens: { type: "integer", minimum: 1000, maximum: 2000000, description: "Stop after observed input/output usage reaches this budget; an in-flight request can overshoot. Default 200000." }, + max_steps: { type: "integer", minimum: 1, maximum: 80, description: "Native agent step limit per turn; default 24. Engine jobs use deadline/token controls." }, + repair_attempts: { type: "integer", minimum: 0, maximum: 3, description: "Same-worker repairs after acceptance failure, default 1; within the original deadline and token budget." }, +}; +const id = { type: "string", description: "Durable job ID returned by jobs_submit." }; +const TOOLS = [ + { name: "jobs_submit", description: "Submit independent coding tasks to OmniWork's shared durable worker service and return IDs immediately. Workers use isolated Git snapshots, managed capacity, and free-only selection by default. Supply owned paths and checks. Jobs survive client disconnects. Use request_id to deduplicate retries; use jobs_wait for results and jobs_apply for verified patches.", inputSchema: { type: "object", properties: { + cwd: properties.cwd, tasks: { type: "array", minItems: 1, maxItems: 100, items: { anyOf: [{ type: "string" }, { type: "object", properties, required: ["task"] }] } }, + defaults: { type: "object", properties }, request_id: { type: "string", description: "Stable unique batch key for safe retry. Reusing it with different tasks is rejected." }, + }, required: ["cwd", "tasks"] } }, + { name: "jobs_wait", description: "Wait up to 25 seconds for the first terminal job, or for a revision change when after_revision is supplied. Returns compact results and a revision cursor. Waiting does not cancel jobs on client disconnect; use jobs_cancel explicitly. Prefer this to repeated polling.", inputSchema: { type: "object", properties: { ids: { ...strings, minItems: 1, maxItems: 100 }, after_revision: { type: "integer", minimum: 0 }, timeout_ms: { type: "integer", minimum: 0, maximum: 25000 } }, required: ["ids"] } }, + { name: "jobs_get", description: "Inspect a durable job's status, evidence, usage, workspace and patch. Compact by default; detail=true includes the full task contract, attempts, acceptance output and repair history. Use jobs_read for large patch or result artifacts.", inputSchema: { type: "object", properties: { id, detail: { type: "boolean" } }, required: ["id"] } }, + { name: "jobs_list", description: "Recover job IDs after reconnecting, or inspect the shared worker backlog. Lists recent jobs with compact status and evidence, supports status filtering and pagination, and does not start new model work.", inputSchema: { type: "object", properties: { status: { type: "string" }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 100 } } } }, + { name: "jobs_read", description: "Read a bounded character page from a job's patch or full execution result. Follow next_offset when more content is needed. This avoids copying every worker transcript into the parent agent's context.", inputSchema: { type: "object", properties: { id, artifact: { type: "string", enum: ["patch", "result"] }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 24000 } }, required: ["id"] } }, + { name: "jobs_cancel", description: "Explicitly cancel a queued or active durable job. Active workers and their shell commands are signalled to stop. Isolated work and evidence are retained for inspection; already terminal jobs are unchanged.", inputSchema: { type: "object", properties: { id }, required: ["id"] } }, + { name: "jobs_apply", description: "Apply a completed isolated job's verified patch to its original workspace. Requires passing checks and owned-path compliance. Rejects overlapping source edits, reruns checks against a fresh integration snapshot, and preserves the user's Git index and branch. This changes source files.", inputSchema: { type: "object", properties: { id }, required: ["id"] } }, + { name: "jobs_status", description: "Inspect global worker capacity, provider concurrency, native-request pacing, cooldowns and completion counts. Use when jobs are queued or blocked; capacity limits are shared across clients of this service.", inputSchema: { type: "object", properties: {} } }, +]; +function submission(args) { + if (!Array.isArray(args.tasks)) throw new Error("tasks must be an array"); + return { request_id: args.request_id, tasks: args.tasks.map(t => ({ ...(args.defaults || {}), cwd: args.cwd, ...(typeof t === "string" ? { task: t } : t) })) }; +} +function call(name, args, signal) { + const method = name.slice(5); + return client.call(method === "status" ? "stats" : method, method === "submit" ? submission(args) : args, { signal, timeout: method === "apply" ? 900000 : 30000 }); +} +module.exports = { TOOLS, submission, call }; diff --git a/electron/job-workspace.js b/electron/job-workspace.js new file mode 100644 index 0000000..6743f15 --- /dev/null +++ b/electron/job-workspace.js @@ -0,0 +1,111 @@ +"use strict"; +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); +const exec = promisify(execFile); + +async function git(cwd, args, options = {}) { + const { stdout } = await exec("git", ["-c", "core.hooksPath=/dev/null", ...args], { cwd, maxBuffer: 64 * 1024 * 1024, timeout: 60_000, ...options }); + return stdout; +} +function relativeFile(file) { + if (typeof file !== "string" || !file || path.isAbsolute(file) || file.split(/[\\/]/).some(p => p === ".." || p === ".git") || file.includes("\0")) throw new Error("File paths must be relative and cannot traverse directories or target .git"); + return file.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +// Snapshot tracked edits and nonignored new files through a private index. +// The user's index, branch and working directory are never changed. +async function snapshot(root, dir) { + const index = path.join(dir, `index-${require("node:crypto").randomUUID()}`); + const env = { ...process.env, GIT_INDEX_FILE: index, GIT_LITERAL_PATHSPECS: "1" }; + try { + await git(root, ["read-tree", "HEAD"], { env }); + await git(root, ["add", "-A", "--", "."], { env }); + return (await git(root, ["write-tree"], { env })).trim(); + } finally { await fs.rm(index, { force: true }); await fs.rm(index + ".lock", { force: true }); } +} +async function prepare(spec, dir) { + if (spec.isolation === "shared") return { cwd: spec.cwd, sourceRoot: spec.cwd, isolated: false }; + const root = (await git(spec.cwd, ["rev-parse", "--show-toplevel"])).trim(); + const sourceRoot = await fs.realpath(root); + const sourceCwd = await fs.realpath(spec.cwd); + const relativeCwd = path.relative(sourceRoot, sourceCwd); + const baseCommit = (await git(sourceRoot, ["rev-parse", "HEAD"])).trim(); + const baseTree = await snapshot(sourceRoot, dir); + const env = { ...process.env, GIT_AUTHOR_NAME: "OmniWork", GIT_AUTHOR_EMAIL: "omniwork@localhost", GIT_COMMITTER_NAME: "OmniWork", GIT_COMMITTER_EMAIL: "omniwork@localhost" }; + const snapshotCommit = (await git(sourceRoot, ["commit-tree", baseTree, "-p", baseCommit, "-m", "OmniWork isolated task snapshot"], { env })).trim(); + const tree = path.join(dir, "worktree"); + await git(sourceRoot, ["worktree", "add", "--detach", "--quiet", tree, snapshotCommit]); + return { cwd: path.join(tree, relativeCwd), tree, sourceRoot, sourceCwd, relativeCwd, baseCommit, baseTree, snapshotCommit, isolated: true }; +} +async function collect(workspace, dir, allowedPaths = []) { + if (!workspace.isolated) return { isolated: false, files: [], patch: null }; + await git(workspace.tree, ["add", "-A", "--", "."]); + const files = (await git(workspace.tree, ["diff", "--cached", "--no-renames", "--name-only", "-z", workspace.baseTree])).split("\0").filter(Boolean); + const patch = await git(workspace.tree, ["diff", "--cached", "--no-renames", "--binary", workspace.baseTree]); + const patchPath = path.join(dir, "changes.patch"); + await fs.writeFile(patchPath, patch, { mode: 0o600 }); + const allowed = allowedPaths.map(p => path.posix.join(workspace.relativeCwd.replace(/\\/g, "/"), p)); + const outsideScope = allowed.length ? files.filter(f => !allowed.some(p => f === p.replace(/\/$/, "") || (p.endsWith("/") && f.startsWith(p)))) : []; + return { isolated: true, files, patch: patchPath, bytes: Buffer.byteLength(patch), outsideScope }; +} +async function context(spec, workspace) { + let left = 24000; + const parts = []; + for (const name of spec.context_files || []) { + const absolute = await fs.realpath(path.join(workspace.cwd, name)); + const rel = path.relative(await fs.realpath(workspace.cwd), absolute); + if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Context file escapes workspace: ${name}`); + const stat = await fs.stat(absolute); + if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error(`Context file is not a small regular file: ${name}`); + const text = await fs.readFile(absolute, "utf8"); + const take = Math.min(left, 8000); + parts.push(`File: ${name}\n${text.slice(0, take)}${text.length > take ? "\n[excerpt; read the file for more]" : ""}`); + left -= Math.min(take, text.length); + if (left <= 0) break; + } + return [spec.task, + spec.allowed_paths.length ? `Own only these paths (directory entries end in /): ${spec.allowed_paths.join(", ")}. Other workers run independently; do not edit outside your ownership.` : "", + spec.checks.length ? `Acceptance commands (do not weaken or remove tests to pass):\n${spec.checks.join("\n")}` : "", + await require("./job-context").orientation(spec, workspace, git), ...parts, + ].filter(Boolean).join("\n\n"); +} + +async function apply(job, dir, runCheck) { + const w = job.workspace, artifact = job.artifact; + if (!w?.isolated || !artifact?.patch) throw new Error("Only isolated jobs have an applicable patch"); + if (job.status !== "completed" || artifact.outsideScope?.length) throw new Error("Only completed jobs within their allowed paths can be applied"); + if (!job.spec.checks.length || job.result?.verification?.status !== "passed") throw new Error("Application requires passing acceptance checks"); + if (job.appliedAt) return { appliedAt: job.appliedAt, alreadyApplied: true }; + if (!artifact.files.length) return { appliedAt: new Date().toISOString(), files: [], checks: [] }; + const currentTree = await snapshot(w.sourceRoot, dir); + // Refuse overlapping edits, including binary changes, additions and removals. + for (const file of artifact.files) { + const before = await git(w.sourceRoot, ["ls-tree", w.baseTree, "--", file]); + const now = await git(w.sourceRoot, ["ls-tree", currentTree, "--", file]); + if (before !== now) throw new Error(`Source changed since submission: ${file}. Rebase/review the patch before applying.`); + } + const integration = path.join(dir, "integration"); + await git(w.sourceRoot, ["worktree", "add", "--detach", "--quiet", integration, w.snapshotCommit]); + const checks = []; + try { + await git(integration, ["read-tree", "--reset", "-u", currentTree]); + await git(integration, ["apply", "--binary", artifact.patch]); + const expectedTree = await snapshot(integration, dir); + for (const command of [...job.spec.setup, ...job.spec.checks]) { + const r = await runCheck(command, path.join(integration, w.relativeCwd)); + checks.push({ command, ok: r.ok, exitCode: r.exitCode, output: r.text.slice(-8000) }); + if (!r.ok) return { applied: false, reason: "Integration check failed", checks }; + } + const testedTree = await snapshot(integration, dir); + for (const file of artifact.files) { + if (await git(integration, ["ls-tree", expectedTree, "--", file]) !== await git(integration, ["ls-tree", testedTree, "--", file])) throw new Error(`Acceptance/setup commands changed a patch file: ${file}; review the generated changes before applying`); + } + if (await snapshot(w.sourceRoot, dir) !== currentTree) throw new Error("Source changed during integration checks; retry application"); + await git(w.sourceRoot, ["apply", "--check", "--binary", artifact.patch]); + await git(w.sourceRoot, ["apply", "--binary", artifact.patch]); + return { applied: true, appliedAt: new Date().toISOString(), files: artifact.files, checks }; + } finally { await git(w.sourceRoot, ["worktree", "remove", "--force", integration]).catch(() => {}); } +} +module.exports = { git, relativeFile, snapshot, prepare, collect, context, apply }; diff --git a/electron/main.js b/electron/main.js index ca88518..e27750f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -144,6 +144,9 @@ ipcMain.handle("gateway:openDashboard", () => { if (gateway) shell.openExternal( // Whatever is connected becomes every session's fallback chain, live. const providers = require("./providers"); const opencodeEngine = require("./opencode-engine"); +const modelCatalog = require("./model-catalog"); +ipcMain.handle("models:probe", (_e, model) => modelCatalog.probe(gateway, model)); +ipcMain.handle("models:catalog", (_e, options) => modelCatalog.catalog(gateway, { force: Boolean(options?.refresh) })); async function applyFallbacks() { if (!gateway || !sessions) return []; try { @@ -173,7 +176,7 @@ ipcMain.handle("providers:remove", async (_e, provider) => { }); // Only the catalog's own "get a key" pages — never an arbitrary URL from the renderer. ipcMain.handle("app:openUrl", (_e, url) => { - const allowed = providers.CATALOG.some((p) => p.keyUrl === url); + const allowed = providers.CATALOG.some((p) => p.keyUrl === url) || url === "https://opencode.ai/docs/providers/"; if (allowed) shell.openExternal(url); return allowed; }); diff --git a/electron/mcp-server.js b/electron/mcp-server.js index b662843..ba623ca 100755 --- a/electron/mcp-server.js +++ b/electron/mcp-server.js @@ -15,9 +15,13 @@ const fs = require("node:fs"); const { ensureShellPath } = require("./shell-path"); -const { agentEnv, browser, ensureGateway, prewarmGateway, resolveModelsLive, listModels, invalidateModels, noModelHint, providers, makeAgent, isNoModelFailure, engineFallbackModel, opencode, SKILLS_DIR } = require("./headless"); +const { agentEnv, browser, ensureGateway, prewarmGateway, resolveModelsLive, invalidateModels, providers, makeAgent, isNoModelFailure, engineFallbackModel, opencode, SKILLS_DIR } = require("./headless"); const skillsApi = require("./skills"); -const tuning = require("./tuning"); +const { executeTask, mapLimit, formatResult } = require("./execution"); +const { executeToolResult } = require("./tools"); +const modelCatalog = require("./model-catalog"); +const { resolveModels } = require("./headless"); +const jobTools = require("./job-tools"); ensureShellPath(); // MCP clients can launch us with a minimal environment too @@ -28,108 +32,44 @@ const log = (...a) => process.stderr.write("[omniwork-mcp] " + a.join(" ") + "\n // we abort the agent and return whatever it managed to finish. const DELEGATE_TIMEOUT_MS = Number(process.env.OMNIWORK_DELEGATE_TIMEOUT_MS || 600_000); -// Run one delegated task; capture a change log + final summary. -async function runDelegate({ task, cwd, model, fallbackModels, progress }) { - const gw = await ensureGateway(log); - const workspace = cwd && fs.existsSync(cwd) ? cwd : process.cwd(); - const changes = []; - let steps = 0; - let failure = null; - const emit = (type, p) => { - if (type === "thinking") progress(++steps, "thinking…"); - else if (type === "error") failure = String(p.message || ""); - else if (type === "tool_call") { - const a = p.args || {}; - const file = a.path || a.filePath || a.file; - if (/^(write_file|write)$/.test(p.name)) changes.push(`wrote ${file}`); - else if (/^(edit_file|edit|patch)$/.test(p.name)) changes.push(`edited ${file}`); - else if (/^(run_command|bash)$/.test(p.name)) changes.push(`ran: ${String(a.command).slice(0, 80)}`); - progress(steps, `${p.name} ${String(file || a.command || a.query || a.pattern || "").slice(0, 60)}`.trim()); - } - }; - const build = (models) => makeAgent({ - baseUrl: gw.baseUrl, apiKey: gw.apiKey, ...models, - workspace, canSpawn: true, ...agentEnv(workspace), - // The caller sees a single tool result, never the token stream. - streaming: false, - emit, - }); - let agent = build(await resolveModelsLive(gw, { model, fallbackModels })); - - let timedOut = false; - let engineNote = ""; - const timer = setTimeout(() => { timedOut = true; agent.abort(); }, DELEGATE_TIMEOUT_MS); - try { - await agent.send(task); - // No gateway model answered, but OpenCode is installed: run the same task on - // its engine instead of returning a dead delegation. - if (failure && !agent.lastText && isNoModelFailure(failure) && !agent.isEngine && !timedOut) { +// The same contract and deadline for one task or every item in a batch. +async function runDelegate({ task, cwd, model, fallbackModels, checks = [], progress = () => {}, signal }) { + if (!task || typeof task !== "string") throw new Error("task must be a non-empty string"); + if (!cwd || !require("node:path").isAbsolute(cwd) || !fs.statSync(cwd).isDirectory()) throw new Error("cwd must be an existing absolute directory"); + if (!Array.isArray(checks) || checks.some(c => typeof c !== "string" || !c.trim()) || checks.length > 10) throw new Error("checks must contain up to 10 shell commands"); + const workspace = cwd; + let gw; + const build = (models, emit) => makeAgent({ baseUrl: gw?.baseUrl || "http://127.0.0.1", apiKey: gw?.apiKey, ...models, workspace, canSpawn: true, ...agentEnv(workspace), streaming: false, emit }); + return executeTask({ task, checks, signal, timeoutMs: DELEGATE_TIMEOUT_MS, progress, + createAgent: async (emit, signal) => { + const selected = resolveModels({ model, fallbackModels }); + // Explicit engine choices must work even when OmniRoute cannot boot. + if (opencode.isEngineModel(selected.model)) return build(selected, emit); + gw = await ensureGateway(log); + if (signal.aborted) throw new Error("Cancelled before execution"); + return build(await resolveModelsLive(gw, { model, fallbackModels }), emit); + }, + recover: async (failure, previous, emit, signal) => { + if (previous.isEngine || !isNoModelFailure(failure) || signal.aborted) return null; const engineModel = await engineFallbackModel(); - if (engineModel) { - engineNote = `\n\n[model: no gateway model answered (${failure.split("\n")[0].slice(0, 100)}) — ran on the OpenCode engine, ${engineModel}]`; - failure = null; - agent = build({ model: engineModel, fallbackModels: [] }); - await agent.send(task); - } - } - } finally { clearTimeout(timer); } - // A turn that produced nothing but an error is an error, not "(no summary)". - // When the error is "no model answered" it comes with the way out. - if (failure && !agent.lastText) { - throw new Error(isNoModelFailure(failure) ? `${failure}\n\n${noModelHint()}` : failure); - } - - const summary = agent.lastText || "(no summary)"; - const changeLog = changes.length ? `\n\nChanges:\n- ${changes.join("\n- ")}` : ""; - const note = timedOut ? `\n\n[stopped after ${Math.round(DELEGATE_TIMEOUT_MS / 1000)}s — this is partial work]` : ""; - // A cheap PASS/FAIL gate on the utility model, so the orchestrator re-delegates - // only when the work actually fell short — the expensive path is the caller - // re-reading and re-issuing, and this cuts it when the task already succeeded. - const verifyNote = (timedOut || !tuning.shouldVerify(task, changes.length > 0)) ? "" : await verifyDelegate(agent, task, summary, changeLog).catch(() => ""); - return `${summary}${changeLog}${modelNote(agent)}${engineNote}${verifyNote}${note}`; -} - -async function verifyDelegate(agent, task, summary, changeLog) { - if (!agent || typeof agent.oneShot !== "function" || !summary || summary === "(no summary)") return ""; - const prompt = - "You are grading whether a coding agent completed a task. Reply with exactly PASS or FAIL, then a dash and one short reason.\n\n" + - `TASK:\n${String(task).slice(0, 1500)}\n\nAGENT SUMMARY:\n${String(summary).slice(0, 1500)}${changeLog.slice(0, 600)}`; - let out = ""; - try { out = String(await agent.oneShot(prompt)).trim(); } catch { return ""; } - const m = /^(PASS|FAIL)\b[\s-]*(.*)$/i.exec(out.split("\n")[0] || ""); - if (!m) return ""; - const verdict = m[1].toUpperCase(); - return `\n\n[verify: ${verdict}${m[2] ? " — " + m[2].slice(0, 140) : ""}]`; -} - -// The caller asked for a model; if it got a different one, it should know. -function modelNote(agent) { - if (!agent.modelSwitches.length) return ""; - const hops = agent.modelSwitches.map((s) => `${s.from} failed (${s.reason.slice(0, 120)})`).join("; "); - return `\n\n[model: ran on ${agent.model} — ${hops}]`; + return engineModel && !signal.aborted ? build({ model: engineModel, fallbackModels: [] }, emit) : null; + }, + runCheck: (command, signal) => executeToolResult("run_command", { command }, { workspace, signal }), + }); } -// The catalog, grouped by provider prefix so a long list reads at a glance. -async function listModelsText() { - const ids = await listModels(await ensureGateway(log)); - if (!ids.length) return "The gateway returned no models (is it still starting? retry in a few seconds)."; - const groups = new Map(); - for (const id of ids) { - const i = id.indexOf("/"); - const p = i > 0 ? id.slice(0, i) : "(other)"; - if (!groups.has(p)) groups.set(p, []); - groups.get(p).push(id); - } - const lines = [`${ids.length} models. \`auto\` routes the free pool; a provider prefix pins one. Models behind a provider key you haven't added will fail — put them in fallback_models after a free one, or use them alone only if the key is configured in the router dashboard.`, ""]; - for (const [p, list] of groups) lines.push(`## ${p} (${list.length})`, list.join(", "), ""); - if (opencode.available()) { - let engine = []; - try { engine = await opencode.getEngine().models(); } catch (e) { lines.push(`## opencode (engine)`, `could not list: ${e.message}`, ""); } - if (engine.length) lines.push(`## opencode (engine — free, no account; runs on OpenCode's own server and tools)`, engine.map((m) => m.id).join(", "), ""); - } else { - lines.push(`## opencode (engine) — not present yet`, `${opencode.INSTALL_COMMAND} (a ~45 MB download the user runs) adds its free Zen models (Nemotron 3.5 Lightning, MiMo V2.5, Big Pickle, Ling 3.0 Flash, Nemotron 3 Ultra) as an engine that needs no account.`, ""); - } - return lines.join("\n").trim(); +async function listModelsText(options = {}) { + const gw = await ensureGateway(log).catch(() => null); + const result = await modelCatalog.catalog(gw, { force: Boolean(options.refresh) }); + const all = modelCatalog.filterModels(result.models, options); + const offset = Math.max(0, Math.floor(Number(options.offset) || 0)); + const limit = Math.min(100, Math.max(1, Math.floor(Number(options.limit) || 40))); + const rows = all.slice(offset, offset + limit); + return `${all.length} matching models; showing ${offset + 1}-${offset + rows.length}. Catalog availability is untested. Paid models require explicit selection.\n` + + rows.map(m => `${m.id} | ${m.pricing} | tools: ${m.tools == null ? "unknown" : m.tools ? "yes" : "no"} | context: ${m.context || "unknown"} | ${m.access}`).join("\n") + + (offset + rows.length < all.length ? `\nMore: list_models(offset=${offset + rows.length})` : "") + + result.errors.map(e => `\n${e.source}: ${e.message}`).join("") + + `\nConnect additional providers using OpenCode's own auth login, or OmniWork's provider panel. Refresh after connecting.`; } const MODEL_PARAM = { @@ -142,6 +82,7 @@ const FALLBACK_PARAM = { }; const TOOLS = [ + ...jobTools.TOOLS, { name: "delegate", description: @@ -153,8 +94,9 @@ const TOOLS = [ cwd: { type: "string", description: "Absolute working directory. Always pass this explicitly." }, model: MODEL_PARAM, fallback_models: FALLBACK_PARAM, + checks: { type: "array", maxItems: 10, items: { type: "string" }, description: "Acceptance commands to run after completion, e.g. npm test. Results include exit codes. Without checks the result is unverified." }, }, - required: ["task"], + required: ["task", "cwd"], }, }, { @@ -164,12 +106,13 @@ const TOOLS = [ inputSchema: { type: "object", properties: { - tasks: { type: "array", items: { type: "string" }, description: "Independent, self-contained subtask instructions." }, + tasks: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" }, description: "Independent, self-contained subtask instructions." }, cwd: { type: "string", description: "Absolute working directory. Always pass this explicitly." }, model: MODEL_PARAM, fallback_models: FALLBACK_PARAM, + checks: { type: "array", maxItems: 10, items: { type: "string" }, description: "Acceptance commands to run after completion, e.g. npm test. Results include exit codes. Without checks the result is unverified." }, }, - required: ["tasks"], + required: ["tasks", "cwd"], }, }, { @@ -192,9 +135,11 @@ const TOOLS = [ }, { name: "list_models", - description: - "List the model ids OmniWork's gateway can route right now, grouped by provider. USE before pinning a model in delegate/delegate_parallel, or when a pinned model failed — free catalogs change as providers retire models.", - inputSchema: { type: "object", properties: {}, required: [] }, + description: "Search available gateway and connected OpenCode model catalogs, with pricing class, tool support and context size. Catalog entries are untested. Use filters before pinning a model. Refresh after connecting a provider.", + inputSchema: { type: "object", properties: { + query: { type: "string" }, free_only: { type: "boolean" }, tools_only: { type: "boolean" }, + refresh: { type: "boolean" }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 100 }, + } }, }, { name: "web_search", @@ -222,9 +167,10 @@ const TOOLS = [ }, ]; -async function callTool(name, args, progress = () => {}) { - if (name === "delegate") return await runDelegate({ task: args.task, cwd: args.cwd, model: args.model, fallbackModels: args.fallback_models, progress }); - if (name === "list_models") return await listModelsText(); +async function callTool(name, args, progress = () => {}, signal) { + if (jobTools.TOOLS.some(t => t.name === name)) return jobTools.call(name, args, signal); + if (name === "delegate") return await runDelegate({ task: args.task, cwd: args.cwd, model: args.model, fallbackModels: args.fallback_models, checks: args.checks, progress, signal }); + if (name === "list_models") return await listModelsText(args); if (name === "list_providers") return providers.describe(await providers.status(await ensureGateway(log))); if (name === "connect_provider") { const gw = await ensureGateway(log); @@ -255,37 +201,9 @@ async function callTool(name, args, progress = () => {}) { return `${head}${url ? `\n(sign-in URL was ${url})` : ""}\nFallback chain now: ${st.chain.length ? st.chain.join(" → ") : "(empty)"}`; } if (name === "delegate_parallel") { - const gw = await ensureGateway(log); - const workspace = args.cwd && fs.existsSync(args.cwd) ? args.cwd : process.cwd(); - let done = 0; - const tasks = (args.tasks || []).map((t, i) => ({ title: `task ${i + 1}`, prompt: t })); - const switched = []; - const agent = makeAgent({ - baseUrl: gw.baseUrl, apiKey: gw.apiKey, ...(await resolveModelsLive(gw, { model: args.model, fallbackModels: args.fallback_models })), - workspace, canSpawn: true, ...agentEnv(workspace), streaming: false, - emit: (type, p) => { - if (type !== "subagent") return; - if (p.kind === "done") progress(++done, `${done}/${tasks.length} subagents finished`, tasks.length); - else if (p.kind === "tool") progress(done, `${p.title || "subagent"}: ${p.tool}`, tasks.length); - else if (p.kind === "model") { switched.push(`${p.from} → ${p.to}`); progress(done, `${p.title || "subagent"}: model ${p.from} failed, continuing on ${p.to}`, tasks.length); } - }, - }); - if (agent.isEngine) { - // The engine has no in-loop subagents; each task gets its own session. - const results = await Promise.all(tasks.map(async (t) => { - const one = makeAgent({ - baseUrl: gw.baseUrl, apiKey: gw.apiKey, model: agent.model, fallbackModels: [], - workspace, canSpawn: false, ...agentEnv(workspace), streaming: false, - emit: (type, p) => { if (type === "tool_call") progress(done, `${t.title}: ${p.name}`, tasks.length); }, - }); - await one.send(t.prompt); - progress(++done, `${done}/${tasks.length} engine sessions finished`, tasks.length); - return `## ${t.title}\n${one.lastText || "(no summary returned)"}`; - })); - return results.join("\n\n---\n\n"); - } - const out = await agent.runSubagents(tasks); - return switched.length ? `${out}\n\n[model: some subagents fell back — ${[...new Set(switched)].join(", ")}]` : out; + if (!Array.isArray(args.tasks) || !args.tasks.length || args.tasks.length > 100 || args.tasks.some(t => typeof t !== "string" || !t.trim())) throw new Error("tasks must contain 1–100 non-empty task strings"); + const results = await mapLimit(args.tasks, 4, (task, i) => runDelegate({ task, cwd: args.cwd, model: args.model, fallbackModels: args.fallback_models, checks: args.checks, signal, progress: (step, text) => progress(step, `Task ${i + 1}: ${text}`) })); + return { status: results.every(r => r.status === "completed") ? "completed" : "partial", tasks: results }; } if (name === "web_search") return await browser.search(args.query); if (name === "browse_page") return await browser.open(args.url); @@ -309,8 +227,10 @@ function sendMsg(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } function reply(id, result) { sendMsg({ jsonrpc: "2.0", id, result }); } function replyErr(id, message) { sendMsg({ jsonrpc: "2.0", id, error: { code: -32000, message } }); } +const activeRequests = new Map(); async function handle(msg) { const { id, method, params } = msg; + if (method === "notifications/cancelled") { activeRequests.get(params?.requestId)?.abort(); return; } if (method === "initialize") { // Boot OmniRoute now, in the background. It used to start on the first // delegate call, so the caller paid the whole cold-start (tens of seconds, @@ -330,12 +250,18 @@ async function handle(msg) { if (token === undefined || token === null) return; sendMsg({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: n, ...(total ? { total } : {}), message } }); }; + const controller = new AbortController(); activeRequests.set(id, controller); try { - const text = await callTool(params.name, params.arguments || {}, progress); - reply(id, { content: [{ type: "text", text }] }); + const result = await callTool(params.name, params.arguments || {}, progress, controller.signal); + if (jobTools.TOOLS.some(t => t.name === params.name)) reply(id, { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result }); + else if (typeof result === "string") reply(id, { content: [{ type: "text", text: result }] }); + else { + const text = result.tasks ? result.tasks.map((r,i) => `## task ${i + 1}\n${formatResult(r)}`).join("\n\n") : formatResult(result); + reply(id, { content: [{ type: "text", text }], structuredContent: result, isError: result.status !== "completed" }); + } } catch (e) { reply(id, { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }); - } + } finally { activeRequests.delete(id); } } else if (id != null) { replyErr(id, `unknown method: ${method}`); } @@ -354,5 +280,5 @@ process.stdin.on("data", (chunk) => { handle(msg).catch((e) => log("handler error", e.message)); } }); -process.stdin.on("end", () => process.exit(0)); +process.stdin.on("end", () => { for (const ctl of activeRequests.values()) ctl.abort(); setTimeout(() => process.exit(0), 600).unref(); }); log("OmniWork MCP server ready (stdio)"); diff --git a/electron/model-catalog.js b/electron/model-catalog.js new file mode 100644 index 0000000..aba4de2 --- /dev/null +++ b/electron/model-catalog.js @@ -0,0 +1,93 @@ +"use strict"; +const opencode = require("./opencode-engine"); +const { LOCAL } = require("./providers"); +const health = new Map(); + +function gatewayModel(m) { + const id = m.id; + const provider = id.split("/")[0]; + const price = m.pricing || m.cost; + const input = price?.prompt ?? price?.input; + const output = price?.completion ?? price?.output; + const zero = input != null && output != null && Number(input) === 0 && Number(output) === 0; + const local = LOCAL.some(p => p.alias === provider); + const free = local || zero || /:free$|\/free$|-free$/.test(id); + return { + id, name: m.name || id, provider, source: "gateway", free, + pricing: free ? "free" : price ? "paid" : "unknown", + access: local ? "local" : "gateway account or public route", + tools: m.supported_parameters ? m.supported_parameters.includes("tools") : m.tool_call ?? m.supports_tools ?? null, + context: m.context_length || m.max_input_tokens || m.limit?.context || null, + health: "untested", // A catalog entry is not a successful completion. + }; +} + +async function catalog(gw, { force = false } = {}) { + const errors = [], models = []; + await Promise.all([ + (async () => { + if (!gw) return; + try { + const res = await fetch(`${gw.baseUrl}/models`, { headers: { Authorization: `Bearer ${gw.apiKey || "omniwork"}` }, signal: AbortSignal.timeout(5000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + for (const m of (await res.json()).data || []) if (typeof m.id === "string") models.push(gatewayModel(m)); + } catch (e) { errors.push({ source: "gateway", message: e.message }); } + })(), + (async () => { + if (!opencode.available()) return; + try { + for (const m of await opencode.getEngine().models({ force })) models.push({ ...m, provider: m.providerID, source: "OpenCode", pricing: m.free ? "free" : m.cost ? "paid" : "unknown", health: "untested" }); + } catch (e) { errors.push({ source: "OpenCode", message: e.message }); } + })(), + ]); + return { models: [...new Map(models.map(m => [m.id, { ...m, ...(health.get(m.id) || {}) }])).values()].sort((a,b) => Number(b.free) - Number(a.free) || a.id.localeCompare(b.id)), errors }; +} + +// Explicit user action only. Sends a small synthetic prompt, never workspace +// content. An advertised model is not marked healthy until it actually answers. +async function probe(gw, id, { timeoutMs = 20_000 } = {}) { + if (typeof id !== "string" || !id.trim()) throw new Error("model is required"); + const started = Date.now(); + let result; + try { + let text; + if (opencode.isEngineModel(id)) { + const engine = opencode.getEngine(); + const models = await engine.models(); + const model = models.find(m => m.id === id); + if (!model) throw new Error("Model is not in the connected engine catalog"); + const directory = require("node:os").tmpdir(); + const sid = await engine.createSession({ directory, title: "OmniWork model check", permission: [{ permission: "*", pattern: "*", action: "deny" }] }); + try { + const response = await engine.prompt(sid, { directory, providerID: model.providerID, modelID: model.modelID, text: "Reply with exactly READY. Do not use tools.", signal: AbortSignal.timeout(timeoutMs) }); + if (response?.info?.error) { + const error = new Error(response.info.error.data?.message || response.info.error.name); + error.status = response.info.error.data?.statusCode; + throw error; + } + text = (response?.parts || []).filter(p => p.type === "text").map(p => p.text).join(""); + } finally { await engine.abort(sid, directory); } + } else { + if (!gw) throw new Error("Gateway is unavailable"); + const response = await fetch(`${gw.baseUrl}/chat/completions`, { + method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${gw.apiKey || "omniwork"}` }, + body: JSON.stringify({ model: id, messages: [{ role: "user", content: "Reply with exactly READY." }], max_tokens: 16, stream: false }), signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { const e = new Error(`HTTP ${response.status}`); e.status = response.status; throw e; } + text = (await response.json()).choices?.[0]?.message?.content; + } + if (!String(text || "").trim()) throw new Error("Empty response"); + result = { health: "responding", checkedAt: new Date().toISOString(), latencyMs: Date.now() - started, detail: "Answered a text-only readiness request; tool execution is not tested." }; + } catch (e) { + result = { health: e.status === 429 ? "rate limited" : /timeout|abort/i.test(e.name + e.message) ? "timed out" : "check failed", checkedAt: new Date().toISOString(), latencyMs: Date.now() - started, detail: e.message + (opencode.isEngineModel(id) && e.status === 403 ? " (This restricted readiness check was rejected; a normal engine task may behave differently.)" : "") }; + } + health.set(id, result); + return { model: id, ...result }; +} + +function filterModels(models, { query = "", free_only = false, tools_only = false } = {}) { + const q = String(query).toLowerCase(); + return models.filter(m => (!free_only || m.free) && (!tools_only || m.tools === true) && `${m.id} ${m.name} ${m.provider}`.toLowerCase().includes(q)); +} + +module.exports = { catalog, gatewayModel, filterModels, probe }; diff --git a/electron/opencode-engine.js b/electron/opencode-engine.js index b685d85..363481f 100644 --- a/electron/opencode-engine.js +++ b/electron/opencode-engine.js @@ -23,6 +23,7 @@ const path = require("node:path"); const PROVIDER_IDS = ["opencode", "opencode-go"]; const PREFIX = "opencode/"; +const workerProfile = require("./engine-profile"); // ── locating / getting the binary ──────────────────────────────── // Nobody should have to touch their PATH. The binary is looked for where each @@ -196,6 +197,7 @@ class Engine { // any config the user already passes by env. let extra = { snapshot: false }; if (process.env.OPENCODE_CONFIG_CONTENT) { try { extra = { ...JSON.parse(process.env.OPENCODE_CONFIG_CONTENT), ...extra }; } catch {} } + extra.agent = { ...(extra.agent || {}), [workerProfile.NAME]: workerProfile.config(), [workerProfile.SCOPED_NAME]: workerProfile.config({ compact: false }) }; const env = { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: "1", @@ -286,7 +288,7 @@ class Engine { for (const line of chunk.split("\n")) { if (!line.startsWith("data:")) continue; let ev; try { ev = JSON.parse(line.slice(5).trim()); } catch { continue; } - const sid = ev && ev.properties && ev.properties.sessionID; + const sid = ev?.properties?.sessionID || ev?.properties?.part?.sessionID || ev?.properties?.info?.sessionID; const set = sid && this.listeners.get(sid); if (set) for (const fn of set) { try { fn(ev); } catch {} } } @@ -330,17 +332,21 @@ class Engine { if (!force && Date.now() - this.modelsCache.at < 300_000) return this.modelsCache.list; const data = await this.#req("GET", "/provider", { timeoutMs: 20_000 }); const list = []; + const connected = new Set(data?.connected || []); for (const prov of (data && data.all) || []) { - if (!PROVIDER_IDS.includes(prov.id)) continue; + const builtIn = PROVIDER_IDS.includes(prov.id); + if (!builtIn && !connected.has(prov.id)) continue; for (const [id, m] of Object.entries(prov.models || {})) { const cost = m && m.cost; - const free = cost && Number(cost.input) === 0 && Number(cost.output) === 0; - if (!free) continue; - list.push({ id: `${PREFIX}${id}`, providerID: prov.id, modelID: id, name: m.name || id, tools: m.tool_call !== false, context: m.limit && m.limit.context }); + const free = cost && cost.input != null && cost.output != null && Number(cost.input) === 0 && Number(cost.output) === 0; + if (!free && !connected.has(prov.id)) continue; + if (m.status === "deprecated") continue; + const route = builtIn && free ? `${PREFIX}${id}` : `${PREFIX}${prov.id}/${id}`; + list.push({ id: route, providerID: prov.id, modelID: id, name: m.name || id, tools: typeof m.tool_call === "boolean" ? m.tool_call : null, context: m.limit && m.limit.context, free: Boolean(free), cost: cost || null, access: free && builtIn ? "no account" : "connected account", source: "OpenCode" }); } } // Strongest coders first: Nemotron Ultra, then the rest in catalog order. - list.sort((a, b) => rank(a.modelID) - rank(b.modelID)); + list.sort((a, b) => Number(b.free) - Number(a.free) || rank(a.modelID) - rank(b.modelID)); this.modelsCache = { at: Date.now(), list }; return list; } @@ -351,9 +357,9 @@ class Engine { const s = await this.#req("POST", "/session", { body, directory }); return s.id; } - async prompt(sessionID, { directory, providerID, modelID, text, signal }) { + async prompt(sessionID, { directory, providerID, modelID, text, signal, agent }) { return await this.#req("POST", `/session/${sessionID}/message`, { - body: { model: { providerID, modelID }, parts: [{ type: "text", text }] }, + body: { model: { providerID, modelID }, ...(agent ? { agent } : {}), parts: [{ type: "text", text }] }, directory, signal, timeoutMs: 0, }); } @@ -398,8 +404,10 @@ function getEngine(opts) { // → error. What OmniWork can't offer here is its own skills and memory — // OpenCode runs its own tools — which is the honest price of the free tier. class OpenCodeAgent { - constructor({ model, workspace, emit, approvalMode = "auto", approver = null, engine = getEngine(), fallbackModels = [], messages = null, sessionID = null }) { + constructor({ model, workspace, emit, approvalMode = "auto", approver = null, engine = getEngine(), fallbackModels = [], messages = null, sessionID = null, engineProfile = "standard" }) { this.engine = engine; + if (!["standard", "focused", "scoped"].includes(engineProfile)) throw new Error("Unknown engine profile"); + this.engineProfile = engineProfile; this.model = model || `${PREFIX}nemotron-3.5-lightning-free`; this.fallbackModels = fallbackModels; // accepted for interface parity; the engine picks its own this.workspace = workspace; @@ -422,7 +430,8 @@ class OpenCodeAgent { get isEngine() { return true; } setWorkspace(dir) { this.workspace = dir; this.sessionID = null; } - abort() { this.aborted = true; if (this.sessionID) this.engine.abort(this.sessionID, this.workspace); } + abort() { this.aborted = true; this.abortCtl?.abort(); if (this.sessionID) this.engine.abort(this.sessionID, this.workspace); } + setModel(model) { this.model = model; this.contextTokens = this.engine.modelsCache.list.find(m => m.id === model)?.context || 200_000; } async compactNow() { return null; } async oneShot(prompt) { const { providerID, modelID } = this.#target(); @@ -435,13 +444,16 @@ class OpenCodeAgent { #target() { const id = this.model.startsWith(PREFIX) ? this.model.slice(PREFIX.length) : this.model; // opencode-go models are namespaced by the engine's provider list; default provider is Zen. - const cached = this.engine.modelsCache.list.find((m) => m.modelID === id); - return { providerID: cached ? cached.providerID : "opencode", modelID: id }; + const cached = this.engine.modelsCache.list.find((m) => m.id === this.model); + if (cached) return { providerID: cached.providerID, modelID: cached.modelID }; + const slash = id.indexOf("/"); + return slash > 0 ? { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) } : { providerID: "opencode", modelID: id }; } // OpenCode's per-session ruleset: an ordered list of {permission, pattern, // action}, mirroring OmniWork's approval modes. #permission() { + if (this.engineProfile !== "standard") return workerProfile.permissions(this.approvalMode); const rule = (permission, action) => ({ permission, pattern: "*", action }); if (this.approvalMode === "auto") return [rule("*", "allow")]; if (this.approvalMode === "plan") return [rule("*", "ask"), rule("read", "allow"), rule("edit", "deny"), rule("write", "deny"), rule("bash", "deny")]; @@ -451,6 +463,8 @@ class OpenCodeAgent { async send(userText, images) { this.aborted = false; + this.lastText = ""; + this.abortCtl = new AbortController(); this.turnStats = { startedAt: Date.now(), inTokens: 0, outTokens: 0, estimated: true }; let text = images && images.length ? `${userText}\n\n(${images.length} image(s) attached — not forwarded to the OpenCode engine)` : userText; if (this.carryOver) { text = carriedContext(this.messages) + text; this.carryOver = false; } @@ -465,6 +479,19 @@ class OpenCodeAgent { const partType = new Map(); // partID -> "text" | "reasoning" | … (deltas don't say) const held = new Map(); // partID -> delta text that arrived before the part's type const seen = new Map(); // callID -> "called" | "done" + const usageByMessage = new Map(); + const recordUsage = (id, tokens) => { + if (!id || !Number.isFinite(tokens?.input) || !Number.isFinite(tokens?.output)) return; + usageByMessage.set(id, tokens); + let input = 0, output = 0, uncached = 0, cacheRead = 0, cacheWrite = 0, reasoning = 0; + for (const t of usageByMessage.values()) { + uncached += t.input; cacheRead += t.cache?.read || 0; cacheWrite += t.cache?.write || 0; reasoning += t.reasoning || 0; + input += t.input + (t.cache?.read || 0) + (t.cache?.write || 0); + output += t.output + (t.reasoning || 0); + } + this.turnStats = { ...this.turnStats, inTokens: input, outTokens: output, uncachedInTokens: uncached, cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite, reasoningTokens: reasoning, modelRequests: usageByMessage.size, estimated: false }; + this.emit("stats", this.#stats()); + }; const flush = (partID, type, delta) => { if (type === "text") { streamedText += delta; this.emit("assistant_delta", { chunk: delta }); } else if (type === "reasoning") this.emit("reasoning_delta", { chunk: delta }); @@ -476,6 +503,9 @@ class OpenCodeAgent { unsubscribe = this.engine.subscribe(sid, (ev) => { const p = ev.properties || {}; switch (ev.type) { + case "message.updated": + if (p.info?.role === "assistant") recordUsage(p.info.id, p.info.tokens); + break; // A delta only names its part; the part's type came (or comes) with a // message.part.updated. Reasoning streams on field "text" too, so a // delta is held until its part is known and never mistaken for the reply. @@ -488,6 +518,7 @@ class OpenCodeAgent { } case "message.part.updated": { const part = p.part || {}; + if (part.type === "step-finish") recordUsage(part.messageID, part.tokens); if (part.id && part.type && !partType.has(part.id)) { partType.set(part.id, part.type); if (held.has(part.id)) { flush(part.id, part.type, held.get(part.id)); held.delete(part.id); } @@ -504,7 +535,7 @@ class OpenCodeAgent { } if (seen.get(id) === "called" && (st.status === "completed" || st.status === "error")) { seen.set(id, "done"); - this.emit("tool_result", { id, result: st.status === "error" ? `Error in ${part.tool}: ${st.error || "failed"}` : String(st.output ?? st.title ?? "") }); + this.emit("tool_result", { id, name: part.tool, ok: st.status === "completed", result: st.status === "error" ? `Error in ${part.tool}: ${st.error || "failed"}` : String(st.output ?? st.title ?? "") }); } break; } @@ -517,7 +548,8 @@ class OpenCodeAgent { } }, this.workspace); - const res = await this.engine.prompt(sid, { directory: this.workspace, providerID, modelID, text }); + const res = await this.engine.prompt(sid, { directory: this.workspace, providerID, modelID, text, signal: this.abortCtl.signal, agent: this.engineProfile === "scoped" ? workerProfile.SCOPED_NAME : this.engineProfile === "focused" ? workerProfile.NAME : undefined }); + recordUsage(res?.info?.id, res?.info?.tokens); if (this.aborted) { this.emit("aborted", this.#stats()); return; } const err = res && res.info && res.info.error; if (err) { @@ -555,7 +587,7 @@ class OpenCodeAgent { await this.engine.replyPermission(sid, p.id, response, this.workspace).catch(() => {}); } - #stats() { const s = this.turnStats; return s ? { elapsedMs: Date.now() - s.startedAt, inTokens: 0, outTokens: 0, estimated: true } : {}; } + #stats() { const s = this.turnStats; return s ? { elapsedMs: Date.now() - s.startedAt, inTokens: s.estimated ? null : s.inTokens, outTokens: s.estimated ? null : s.outTokens, ...(s.estimated ? {} : Object.fromEntries(["uncachedInTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens", "modelRequests"].map(k => [k, s[k]]))), estimated: s.estimated } : {}; } async runSubagents() { return "Subagents aren't available on the OpenCode engine — delegate the pieces separately."; } loadProjectMemory() { return ""; } diff --git a/electron/preload.js b/electron/preload.js index 8f13eb7..0e3a2eb 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -49,6 +49,8 @@ contextBridge.exposeInMainWorld("omniwork", { // app getState: () => ipcRenderer.invoke("app:state"), + probeModel: (model) => ipcRenderer.invoke("models:probe", model), + modelCatalog: (options) => ipcRenderer.invoke("models:catalog", options), listModels: () => ipcRenderer.invoke("models:list"), setModel: (model) => ipcRenderer.invoke("app:setModel", model), openDashboard: () => ipcRenderer.invoke("gateway:openDashboard"), diff --git a/electron/providers.js b/electron/providers.js index 173f78a..6607981 100644 --- a/electron/providers.js +++ b/electron/providers.js @@ -25,7 +25,7 @@ const CATALOG = [ { id: "openrouter", alias: "openrouter", name: "OpenRouter", connect: "pkce", keyUrl: "https://openrouter.ai/settings/keys", - free: "20 free models (18 with tool calling) — 20 req/min, 50 req/day; 1,000/day after a one-time $10 top-up", + free: "Browser sign-in. Free and paid catalogs; free models are selected separately. Account limits apply.", prefer: ["cohere/north-mini-code:free", "nvidia/nemotron-3-super-120b-a12b:free", "google/gemma-4-31b-it:free", "nvidia/nemotron-3.5-lightning:free"], preferPattern: /:free$/, }, @@ -39,6 +39,7 @@ const CATALOG = [ id: "kilo-gateway", alias: "kg", name: "Kilo Gateway", connect: "key", keyUrl: "https://app.kilo.ai", free: "kilo-auto/free router plus Nemotron 3 Super and MiniMax M2.5 :free (free account)", + preferPattern: /:free$|\/free$/, prefer: ["kilo-auto/free", "nvidia/nemotron-3-super-120b-a12b:free", "minimax/minimax-m2.5:free"], }, { @@ -181,7 +182,8 @@ function suggestChain(ids, { max = MAX_CHAIN } = {}) { if (!have.length) continue; const pick = (p.prefer || []).find((m) => have.includes(m)) || (p.preferPattern ? have.find((m) => p.preferPattern.test(m)) : null) - || have[0]; + || (LOCAL.includes(p) ? have[0] : null); + if (!pick) continue; chain.push(`${p.alias}/${pick}`); if (chain.length >= max) break; } @@ -214,7 +216,7 @@ async function status(gw, { detectLocal = true, candidates = LOCAL } = {}) { const oc = { installed: opencode.available(), version: null, models: [], installCommand: opencode.INSTALL_COMMAND, installScript: opencode.INSTALL_SCRIPT }; if (oc.installed) { oc.version = opencode.version(); - try { oc.models = (await opencode.getEngine().models()).map((m) => m.id); } catch (e) { oc.error = e.message; } + try { oc.catalog = await opencode.getEngine().models(); oc.models = oc.catalog.map((m) => m.id); oc.freeModels = oc.catalog.filter(m => m.free).length; } catch (e) { oc.error = e.message; } } return { providers, local, opencode: oc, @@ -404,7 +406,7 @@ function describe(st) { if (oc) { lines.push(""); lines.push(oc.installed - ? `OpenCode engine: installed (${oc.version || "?"}) — ${oc.models.length ? `free models, no account: ${oc.models.join(", ")}` : (oc.error ? `could not list models: ${oc.error}` : "no free models listed")}. Runs OpenCode's own server; the automatic fallback when nothing else answers.` + ? `OpenCode engine: installed (${oc.version || "?"}) — ${oc.models.length ? `${oc.freeModels ?? "?"} free models plus connected accounts: ${oc.models.join(", ")}` : (oc.error ? `could not list models: ${oc.error}` : "no free models listed")}. Runs OpenCode's own server; the automatic fallback when nothing else answers.` : `OpenCode engine: not present yet. ${oc.installCommand} downloads OpenCode's official release (~45 MB, no npm, no PATH) and adds Zen's free models (Nemotron 3.5 Lightning, MiMo V2.5, Big Pickle, Ling 3.0 Flash, Nemotron 3 Ultra) with no account at all.`); } const running = st.local.filter((l) => l.running); diff --git a/electron/sessions.js b/electron/sessions.js index d2a4688..69a84ce 100644 --- a/electron/sessions.js +++ b/electron/sessions.js @@ -359,6 +359,7 @@ class SessionManager { setModel(model) { this.model = model; + this._ctxTokens = null; for (const s of this.sessions.values()) { if (!s.agent) continue; // Crossing between the gateway loop and the OpenCode engine is a new @@ -367,8 +368,11 @@ class SessionManager { // Never leave a running agent behind: it would keep emitting into a // session whose Stop now points at the new one. if (s.status === "running") s.agent.abort(); + const messages = s.agent.messages.slice(); this.#buildAgent(s); - } else s.agent.model = model; + s.agent.messages = messages; + if (s.agent.isEngine) s.agent.carryOver = messages.some(m => m.role === "user"); + } else s.agent.setModel(model); } this.refreshContextLimit(); } @@ -377,11 +381,18 @@ class SessionManager { // agents keep their conservative default when it's unknown. async refreshContextLimit() { try { + if (opencode.isEngineModel(this.model)) { + const m = (await opencode.getEngine().models()).find(m => m.id === this.model); + if (m?.context) for (const s of this.sessions.values()) if (s.agent?.model === m.id) s.agent.contextTokens = m.context; + return; + } const res = await fetch(`${this.gateway.baseUrl}/models`, { signal: AbortSignal.timeout(4000) }); const data = await res.json(); + const contexts = Object.fromEntries((data.data || []).filter(m => m.context_length || m.max_input_tokens).map(m => [m.id, m.context_length || m.max_input_tokens])); + for (const s of this.sessions.values()) if (s.agent && !s.agent.isEngine) s.agent.modelContexts = contexts; const m = (data.data || []).find((x) => x.id === this.model); const ctx = m && (m.context_length || m.max_input_tokens); - if (!ctx || ctx < 16_000) return; + if (!ctx || ctx < 1024) return; this._ctxTokens = ctx; for (const s of this.sessions.values()) if (s.agent) s.agent.contextTokens = ctx; } catch {} diff --git a/electron/sidecar.js b/electron/sidecar.js index d15c3b8..ae7d743 100644 --- a/electron/sidecar.js +++ b/electron/sidecar.js @@ -14,7 +14,6 @@ const net = require("node:net"); const path = require("node:path"); const fs = require("node:fs"); const crypto = require("node:crypto"); -const tuning = require("./tuning"); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -153,13 +152,6 @@ class Gateway { return false; } - // Turn on gateway-side token savings once it is healthy (compression). A PUT, - // idempotent and best-effort — never blocks or fails a boot. - #applyTuning() { - if (this._tuned) return; this._tuned = true; - tuning.enableCompression(this.baseUrl, this.apiKey, (...a) => { if (process.env.OMNIWORK_DEV) process.stderr.write("[sidecar] " + a.join(" ") + "\n"); }).catch(() => {}); - } - async start() { this.status("boot", "Launching OmniRoute…"); this._stopped = false; @@ -168,7 +160,7 @@ class Gateway { this.ready = true; this.adopted = true; // not ours to kill on shutdown this.status("ready", "OmniRoute · free models"); - this.#applyTuning(); + this.#watchdog(); // adopted gateways can die under us (their owner exits) return this.baseUrl; } @@ -212,7 +204,7 @@ class Gateway { this.ready = true; this.adopted = true; // not ours to kill on shutdown this.status("ready", "OmniRoute · free models"); - this.#applyTuning(); + this.#watchdog(); return this.baseUrl; } @@ -229,8 +221,8 @@ class Gateway { PORT: String(this.port), // may differ from PORT if the well-known one was blocked HOSTNAME: HOST, // bind loopback only DATA_DIR: gwDataDir, // OmniRoute reads DATA_DIR for its storage + .env - // Let the free "auto" pool fall back to the full pool if a sub-combo is empty. - OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL: "true", + // Keep free auto routes from widening to paid candidates when empty. + OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL: "false", // Skip outbound startup syncs — faster, quieter, works offline. ARENA_ELO_SYNC_ENABLED: "false", PRICING_SYNC_ENABLED: "false", @@ -250,7 +242,7 @@ class Gateway { try { await this.#waitHealthy(); if (this._stopped) { this.#killProc(); throw new Error("gateway start aborted by shutdown"); } - this.#applyTuning(); + this.#watchdog(); return this.baseUrl; } catch (e) { diff --git a/electron/tool-output.js b/electron/tool-output.js new file mode 100644 index 0000000..4b89426 --- /dev/null +++ b/electron/tool-output.js @@ -0,0 +1,29 @@ +"use strict"; +const crypto = require("node:crypto"); + +// Retain raw output for the life of the task. The model sees an explicit page, +// never a lossy rewrite of source or evidence. References are session-local. +class ToolOutputStore { + constructor() { this.entries = new Map(); this.bytes = 0; } + capture(text, limit = 12000) { + text = String(text); + if (text.length <= limit) return text; + const id = crypto.randomUUID(); + this.entries.set(id, text); this.bytes += text.length; + while (this.bytes > 8_000_000 && this.entries.size > 1) { + const first = this.entries.keys().next().value; + this.bytes -= this.entries.get(first).length; this.entries.delete(first); + } + const tail = Math.min(2000, Math.floor(limit / 4)); + return `${text.slice(0, limit - tail)}\n[Output paged: ${text.length} characters. read_output id=${id}, offset=0, limit=${limit} retrieves the original; use subsequent offsets for more.]\n${text.slice(-tail)}`; + } + read(id, offset = 0, limit = 12000) { + const text = this.entries.get(id); + if (text == null) return "Error in read_output: output expired or belongs to another task; rerun the tool."; + const start = Math.max(0, Math.floor(Number(offset) || 0)); + const count = Math.min(24000, Math.max(1, Math.floor(Number(limit) || 12000))); + return `${text.slice(start, start + count)}\n[characters ${start}-${Math.min(start + count, text.length)} of ${text.length}${start + count < text.length ? `; next offset=${start + count}` : "; end"}]`; + } +} +const OUTPUT_TOOL = { type: "function", function: { name: "read_output", description: "Retrieve original tool output by its session-local id and character offset when a result was paged. No compression or omitted middle within the requested page.", parameters: { type: "object", properties: { id: { type: "string" }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 24000 } }, required: ["id"] } } }; +module.exports = { ToolOutputStore, OUTPUT_TOOL }; diff --git a/electron/tools.js b/electron/tools.js index 24ef1ab..f640ff9 100644 --- a/electron/tools.js +++ b/electron/tools.js @@ -36,10 +36,10 @@ const TOOL_SCHEMA = [ type: "function", function: { name: "read_file", - description: "Read a UTF-8 text file's contents.", + description: "Read a UTF-8 text file. Defaults to the first 200 lines; use start_line/end_line for targeted reads (up to 400 lines).", parameters: { type: "object", - properties: { path: { type: "string" } }, + properties: { path: { type: "string" }, start_line: { type: "integer", minimum: 1 }, end_line: { type: "integer", minimum: 1 } }, required: ["path"], }, }, @@ -133,32 +133,47 @@ function userShell() { return fs.existsSync("/bin/zsh") ? "/bin/zsh" : "/bin/bash"; } -async function runCommand(workspace, command, onChunk) { +async function runCommand(workspace, command, onChunk, signal) { return new Promise((resolve) => { const isWin = process.platform === "win32"; const shell = userShell(); const args = isWin ? ["-NoProfile", "-Command", command] : ["-lc", command]; - const child = spawn(shell, args, { cwd: workspace, env: process.env }); + if (signal?.aborted) return resolve({ text: "Command cancelled", ok: false, exitCode: null }); + const child = spawn(shell, args, { cwd: workspace, env: process.env, detached: !isWin }); + let killTimer; + const kill = (hard = false) => { + try { + if (isWin) spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => {}); + else process.kill(-child.pid, hard ? "SIGKILL" : "SIGTERM"); + } catch {} + }; + const abort = () => { kill(); killTimer = setTimeout(() => kill(true), 500); killTimer.unref(); }; + signal?.addEventListener("abort", abort, { once: true }); let out = ""; const push = (d) => { const t = d.toString(); out += t; + if (out.length > 2_000_000) { out = out.slice(0, 1_000_000) + "\n[command output exceeded 2MB; command stopped]\n" + out.slice(-100_000); kill(); } if (onChunk) onChunk(t); }; child.stdout.on("data", push); child.stderr.on("data", push); - child.on("error", (e) => resolve(`Failed to start command: ${e.message}`)); + child.on("error", (e) => { signal?.removeEventListener("abort", abort); resolve({ text: `Failed to start command: ${e.message}`, ok: false, exitCode: null }); }); child.on("close", (code) => { - resolve(truncate(out.trim() + `\n\n[exit code ${code}]`)); + signal?.removeEventListener("abort", abort); + // The shell can exit before descendants which ignored SIGTERM. + // Kill the remaining process group before cancelling the escalation. + if (killTimer) { kill(true); clearTimeout(killTimer); } + resolve({ text: out.trim() + `\n\n[exit code ${code}]`, ok: code === 0 && !signal?.aborted, exitCode: code }); }); }); } -async function webFetch(url) { +async function webFetch(url, signal) { let u = String(url || ""); if (!/^https?:\/\//i.test(u)) u = "https://" + u; try { - const res = await fetch(u, { redirect: "follow", headers: { "User-Agent": "OmniWork/0.2" } }); + const res = await fetch(u, { signal, redirect: "follow", headers: { "User-Agent": "OmniWork/0.2" } }); const type = res.headers.get("content-type") || ""; let body = await res.text(); if (type.includes("html")) { @@ -200,7 +215,7 @@ async function openExternal(url) { } // ---- Dispatcher. Returns a string result for the given tool call. ---- -async function executeTool(name, args, ctx) { +async function executeRaw(name, args, ctx) { const { workspace, onChunk } = ctx; try { switch (name) { @@ -217,9 +232,12 @@ async function executeTool(name, args, ctx) { const file = confine(workspace, args.path); const stat = fs.statSync(file); if (stat.size > MAX_READ_BYTES) { - return `File too large (${stat.size} bytes). Read a smaller file or use run_command with head/Select-Object.`; + return `Error in read_file: file too large (${stat.size} bytes). Read a smaller file or use run_command with head/Select-Object.`; } - return fs.readFileSync(file, "utf8"); + const lines = fs.readFileSync(file, "utf8").split("\n"); + const start = Math.max(1, Math.floor(Number(args.start_line) || 1)); + const end = Math.min(lines.length, start + 399, Math.max(start, Math.floor(Number(args.end_line) || start + 199))); + return lines.slice(start - 1, end).join("\n") + (start > 1 || end < lines.length ? `\n[lines ${start}-${end} of ${lines.length}${end < lines.length ? `; next start_line=${end + 1}` : ""}]` : ""); } case "write_file": { const file = confine(workspace, args.path); @@ -232,7 +250,7 @@ async function executeTool(name, args, ctx) { const file = confine(workspace, args.path); const cur = fs.readFileSync(file, "utf8"); if (!cur.includes(args.old_string)) { - return `old_string not found in ${args.path}. Read the file first to copy exact text.`; + return `Error in edit_file: old_string not found in ${args.path}. Read the file first to copy exact text.`; } if (ctx.recordUndo) ctx.recordUndo(args.path, cur); const next = cur.replace(args.old_string, args.new_string); @@ -240,10 +258,10 @@ async function executeTool(name, args, ctx) { return `Edited ${args.path}`; } case "run_command": { - return await runCommand(workspace, args.command, onChunk); + return await runCommand(workspace, args.command, onChunk, ctx.signal); } case "web_fetch": { - return await webFetch(args.url); + return await webFetch(args.url, ctx.signal); } case "open_url": { let u = String(args.url || ""); @@ -259,4 +277,14 @@ async function executeTool(name, args, ctx) { } } -module.exports = { TOOL_SCHEMA, executeTool }; +async function executeToolResult(name, args, ctx) { + const raw = await executeRaw(name, args, ctx); + if (raw && typeof raw === "object") return raw; + const text = String(raw); + const ok = !/^(Error in |Failed to |Unknown tool:|File too large)/.test(text); + return { text, ok, changed: ok && (name === "write_file" || name === "edit_file") }; +} +async function executeTool(name, args, ctx) { + return truncate((await executeToolResult(name, args, ctx)).text); +} +module.exports = { TOOL_SCHEMA, executeTool, executeToolResult }; diff --git a/electron/tuning.js b/electron/tuning.js index d5c5e71..406a0ce 100644 --- a/electron/tuning.js +++ b/electron/tuning.js @@ -1,17 +1,6 @@ "use strict"; -// Token economy — how OmniWork finishes a task on fewer tokens than a -// competitor that only cheats its way to a free tier. Every lever here is -// opt-outable by env, and none of it changes *which* provider answers, only -// how little we spend getting the answer. -// -// 1. gateway-side compression (RTK) rewrites tool-result text — the bulk of -// an agent's context — with cheap heuristics, no model call. -// 2. housekeeping (titles, memory, compaction summaries) runs on the fast -// free pool, never the session's chosen model. -// 3. mechanical steps run on the fast pool and escalate to the coding pool -// only when the fast model gets stuck. -// 6. a stable per-session id lets the gateway keep prompt-cache affinity, so -// the unchanged system+history prefix is cached upstream. +// Routing and request policy. Tool output is paged locally with retrievable raw +// text; lossy gateway compression is an explicit opt-in. const crypto = require("node:crypto"); @@ -21,8 +10,9 @@ const STRONG = "auto/best-coding"; // free "coding" routing combo — real reaso const off = (v) => /^(0|off|false|no)$/i.test(String(v ?? "")); -// ── 1: compression, enabled once per gateway when it turns healthy ── -const compressionOn = () => !off(process.env.OMNIWORK_COMPRESSION); +// Compression policy is sent per request; gateway settings are not mutated. +const compressionOn = () => /^(rtk|on|true|1)$/i.test(String(process.env.OMNIWORK_COMPRESSION || "")); +const requestHeaders = () => ({ "x-omniroute-compression": compressionOn() ? "rtk" : "off" }); // The richest payload we're confident the strict settings schema accepts, // then a proven-minimal fallback so a schema drift never leaves compression off. @@ -32,11 +22,11 @@ const COMPRESSION_RICH = { rtkConfig: { intensity: "standard", applyToToolResults: true, // tool output is the bulk of an agent's context - applyToCodeBlocks: true, - rawOutputRetention: "failures", // keep failed-command output verbatim for debugging + applyToCodeBlocks: false, + rawOutputRetention: "always", // retained by upstream; OmniWork pages raw output locally deduplicateThreshold: 3, // collapse blocks that repeat 3+ times - enableGrouping: true, // fold near-identical lines (grep/log spew) - stripCodeComments: true, // comments rarely change the model's decision + enableGrouping: false, // preserve individual search matches + stripCodeComments: false, // comments can carry requirements preserveDocstrings: true, // …but docstrings often carry the contract }, preserveSystemPromptMode: "whenNoCache", mcpDescriptionCompressionEnabled: true, @@ -78,7 +68,7 @@ function tiersFor(primary, opt) { // A tool result that means the step failed — the signal to escalate. function toolFailed(result) { const s = String(result || ""); - if (/^(Error in |Failed to |Failed to start|Search failed|Browse failed|Install failed|❌|⏸)/.test(s)) return true; + if (/^(Error in |old_string not found|Unknown tool:|Failed to |Failed to start|Search failed|Browse failed|Install failed|❌|⏸)/.test(s)) return true; // A non-zero exit code, wherever it lands (head+tail truncation keeps the tail). if (/\[exit code ([1-9]\d*)\]/.test(s)) return true; // Shell error lines, anchored so the words don't false-positive inside output. @@ -97,4 +87,4 @@ function shouldVerify(task, changed) { const newSessionId = () => crypto.randomUUID(); -module.exports = { AUTO, FAST, STRONG, compressionOn, enableCompression, utilityModel, tiersFor, toolFailed, shouldVerify, newSessionId }; +module.exports = { AUTO, FAST, STRONG, compressionOn, requestHeaders, enableCompression, utilityModel, tiersFor, toolFailed, shouldVerify, newSessionId }; diff --git a/package-lock.json b/package-lock.json index c003539..442a2bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "bin": { "omniwork-acp": "electron/acp-server.js", "omniwork-mcp": "electron/mcp-server.js", - "omniwork-providers": "scripts/providers.js" + "omniwork-providers": "scripts/providers.js", + "omniwork-jobs": "scripts/jobs.js" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/package.json b/package.json index fc211ed..9150072 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniwork", "version": "0.12.0", - "description": "Open-source Claude Code / Cowork-style desktop agent with OmniRoute baked in. Download, open, and code with free AI models — zero setup.", + "description": "Open-source Claude Code / Cowork-style desktop agent with OmniRoute baked in. Download, open, and code with free AI models \u2014 zero setup.", "author": "OmniWork contributors", "license": "MIT", "main": "electron/main.js", @@ -41,12 +41,19 @@ "test:tuning": "node test/tuning.js", "test:sidecar": "node test/sidecar.js", "mcp": "node electron/mcp-server.js", - "acp": "node electron/acp-server.js" + "acp": "node electron/acp-server.js", + "test:regression": "node scripts/test-regression.js", + "test:reliability": "node test/reliability.js", + "jobs": "node scripts/jobs.js", + "test:jobs": "node test/jobs.js", + "benchmark:coding": "node scripts/benchmark-coding.js", + "benchmark:engine-prompt": "node scripts/measure-engine-prompt.js" }, "bin": { "omniwork-mcp": "electron/mcp-server.js", "omniwork-acp": "electron/acp-server.js", - "omniwork-providers": "scripts/providers.js" + "omniwork-providers": "scripts/providers.js", + "omniwork-jobs": "scripts/jobs.js" }, "dependencies": { "omniroute": "^3.8.50", diff --git a/renderer/app.js b/renderer/app.js index c851e1d..041a8d0 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -14,6 +14,8 @@ const stub = { listDir: async () => ({ root: null, entries: [] }), readFile: async () => ({ content: "" }), getState: async () => ({ model: "auto", sessions: [], activeId: null, mcp: [] }), setModel: async () => {}, openDashboard: async () => {}, on: () => () => {}, + probeModel: async () => ({ health: "unavailable", detail: "Desktop only" }), + modelCatalog: async () => ({ models: [], errors: [] }), providersStatus: async () => null, providersConnect: async () => ({}), providersRemove: async () => 0, openUrl: async () => false, copyText: async () => {}, setCopyOnSelect: async () => {}, }; @@ -1020,6 +1022,7 @@ $("add-mcp").addEventListener("click", openModal); // ── Free providers ─────────────────────────────────────────────── async function renderProviders() { + loadModelCatalog(); const st = await api.providersStatus(); const list = $("prov-list"); const local = $("prov-local"); const status = $("prov-status"); list.innerHTML = ""; local.innerHTML = ""; @@ -1027,7 +1030,7 @@ async function renderProviders() { const on = st.providers.filter((p) => p.connected); status.textContent = on.length ? `Connected: ${on.map((p) => p.name).join(", ")} · fallback chain: ${st.chain.join(" → ") || "(empty)"}` - : "Nothing connected yet — pick one below (all free, no card)."; + : "Connect an account below, or use an installed OpenCode engine or local server."; st.providers.forEach((p) => { const row = document.createElement("div"); row.className = "prow"; const act = p.connected @@ -1042,7 +1045,7 @@ async function renderProviders() { const oc = st.opencode; const row = document.createElement("div"); row.className = "prow"; const act = oc.installed - ? `${oc.models.length} free models · engine` + ? `${oc.models.length} models · ${oc.freeModels ?? "?"} free` : ``; row.innerHTML = `
${oc.installed ? '✓' : ""}OpenCode engine
${oc.installed ? esc(oc.models.join(", ") || "no free models listed") : "Zen's free models with no account: Nemotron 3 Ultra, Nemotron 3.5 Lightning, MiMo V2.5, Ling 3.0 Flash, Big Pickle. Runs OpenCode's own server as an engine."}
${act}
`; local.appendChild(row); @@ -1057,6 +1060,52 @@ async function renderProviders() { local.appendChild(row); }); } +let catalogModels = []; +async function loadModelCatalog(refresh = false) { + const status = $("model-catalog-status"); status.textContent = "Loading models…"; + try { + const data = await api.modelCatalog({ refresh }); + catalogModels = data.models || []; renderModelCatalog(); + if (data.errors?.length) status.textContent += " · " + data.errors.map(e => `${e.source}: ${e.message}`).join("; "); + } catch (e) { status.textContent = "Could not load models: " + e.message; } +} +function renderModelCatalog() { + const query = $("model-search").value.toLowerCase(); + const rows = catalogModels.filter(m => (!$("model-free-only").checked || m.free) && (!$("model-tools-only").checked || m.tools === true) && `${m.id} ${m.name} ${m.provider}`.toLowerCase().includes(query)); + $("model-catalog-status").textContent = `${rows.length} matching models · Test checks whether a model answers; availability can change`; + const list = $("model-catalog-list"); list.innerHTML = ""; + rows.slice(0, 100).forEach(m => { + const row = document.createElement("div"); row.className = "prow"; + row.innerHTML = `
${esc(m.name)}
${esc(m.id)} · ${esc(m.pricing)} · ${esc(m.access)} · tools: ${m.tools == null ? "unknown" : m.tools ? "yes" : "no"} · context: ${m.context || "unknown"} · ${esc(m.health || "untested")}${m.latencyMs ? ` (${(m.latencyMs/1000).toFixed(1)}s)` : ""}
`; + list.appendChild(row); + }); + if (!rows.length) list.textContent = "No matching models. Change filters or connect a provider."; + if (rows.length > 100) list.insertAdjacentHTML("beforeend", '
Showing the first 100. Narrow your search to find another model.
'); +} +$("model-search").addEventListener("input", renderModelCatalog); +$("model-free-only").addEventListener("change", renderModelCatalog); +$("model-tools-only").addEventListener("change", renderModelCatalog); +$("model-refresh").addEventListener("click", () => loadModelCatalog(true)); +$("model-catalog-list").addEventListener("click", async (e) => { + const probe = e.target.dataset.probeModel; + if (probe) { + e.target.disabled = true; e.target.textContent = "Testing…"; + try { + const result = await api.probeModel(probe); + const row = catalogModels.find(m => m.id === probe); if (row) Object.assign(row, result); + renderModelCatalog(); $("model-catalog-status").textContent = `${probe}: ${result.health} · ${result.detail}`; + } catch (error) { e.target.disabled = false; e.target.textContent = "Test"; $("model-catalog-status").textContent = error.message; } + return; + } + const id = e.target.dataset.pickModel; if (!id) return; + try { + await api.setModel(id); + const picker = $("model"); + if (![...picker.options].some(o => o.value === id)) picker.add(new Option(id, id)); + picker.value = id; + addSystem("Model selected: " + id); $("providers-modal").classList.add("hidden"); + } catch (error) { $("model-catalog-status").textContent = "Could not select model: " + error.message; } +}); async function connectProvider(id, key) { const modal = $("providers-modal"); modal.classList.add("prow-busy"); try { diff --git a/renderer/index.html b/renderer/index.html index 68a47a7..a3e075f 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -26,7 +26,7 @@
- +
@@ -116,10 +116,20 @@ -