agy-mcp-bridge 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ agy-mcp-bridge # serves http://127.0.0.1:3
16
16
 
17
17
  Fastest first run: from inside the project you want to work on, `agy-mcp-bridge --workspace .` grants the current directory **for this run only** and does not touch config. Repeat the flag for several folders; a wrong path, or the bridge's own install directory, stops startup.
18
18
 
19
- `agy-mcp-bridge check | setup codex|claude|both | delegation on|off|status | skill install | config | update | help`. `--open` launches the web UI in your browser. Config and task history live in the user data directory (`%APPDATA%\agy-mcp-bridge`, `~/.config/agy-mcp-bridge`, `~/Library/Application Support/agy-mcp-bridge`; override with `BRIDGE_HOME`), so an upgrade never deletes them. `agy-mcp-bridge config` prints the real paths.
19
+ `agy-mcp-bridge check | setup codex|claude|freebuff|both | delegation on|off|status | skill install | config | update | help`. `--open` launches the web UI in your browser. Config and task history live in the user data directory (`%APPDATA%\agy-mcp-bridge`, `~/.config/agy-mcp-bridge`, `~/Library/Application Support/agy-mcp-bridge`; override with `BRIDGE_HOME`), so an upgrade never deletes them. `agy-mcp-bridge config` prints the real paths.
20
20
 
21
21
  At startup the bridge probes each CLI. An agent that is missing or unsupported is marked unavailable and takes no task; if `agy` is unavailable, the default agent silently becomes the next enabled one, so check `agents_list` after changing machines. Priority and per-agent switches live under `agents` in `config/workspaces.json`. Verified against real agent execution on `agy` 1.4.3, 1.4.7, 1.4.8 and 1.4.9; other 1.4.x patches run with a logged warning; other majors are refused.
22
22
 
@@ -41,6 +41,8 @@ Claude Code:
41
41
  claude mcp add --transport http --scope local antigravity 'http://127.0.0.1:3000/mcp'
42
42
  ```
43
43
 
44
+ Freebuff CLI is a **client only**, never a delegated agent: its CLI is an interactive TUI with no headless mode, so no adapter can drive it. `agy-mcp-bridge setup freebuff` prints the stdio entry it reads (`.agents/mcp.json`); `setup freebuff --apply [--project <dir>]` merges that one entry into `~/.agents/mcp.json` after showing the change and asking, keeping every other server and taking a backup. Freebuff starts that bridge as a **child process**, so closing Freebuff cancels a task still running; for work that must outlive the client, run the HTTP server and point Freebuff at `/mcp` instead (only stdio is documented for Freebuff). [docs/FREEBUFF-INTEGRATION.md](docs/FREEBUFF-INTEGRATION.md)
45
+
44
46
  Then call `antigravity_environment` from a fresh session — that is the proof; `codex mcp list` and `/mcp` only show configuration. Closing or disconnecting a client does not stop the server or cancel an accepted task. Reconnect, read `recent_tasks`, and reuse the task id instead of delegating the same work twice.
45
47
 
46
48
  ## Delegate
@@ -88,18 +90,19 @@ Whether a profile really is a second account depends on the adapter. A `codex` p
88
90
 
89
91
  Four rules about these ids are easy to get wrong, all measured rather than inferred:
90
92
 
91
- - `agents.enabled` must list **every profile id** you declare, not just the adapters. A profile missing there is silently disabled, and a pool of such members then refuses with `Unknown agent "<pool id>"` — a message that names the pool while the broken line is `enabled`. Omitting `enabled` entirely enables everything; the example keeps the key so you can see it exists, which means a new profile has to be added to that list too.
93
+ - `agents.enabled` must list **every profile id** you declare, not just the adapters. A profile missing there is silently disabled, and a pool of such members then refuses with `Pool "<pool id>" has no healthy member right now` — a message that names the pool while the broken line is `enabled`. Omitting `enabled` entirely enables everything; the example keeps the key so you can see it exists, which means a new profile has to be added to that list too.
92
94
  - A `codex` profile also needs the base `codex` id in `enabled`, because that is what makes the bridge probe the codex CLI at all. Without it the profile is unavailable **and reports no warning**, so `agents_list` shows a dead agent with no reason.
93
95
  - `model` on a profile is read only for a `codex` profile; an `antigravity` profile runs with the model you choose in the web UI.
94
96
  - `priority` and `enabled` accept a pool id without complaining, but the default agent is matched against agent ids only — a pool there silently selects nothing. Name the pool in the `agent` argument of a task instead.
95
97
 
96
- `agents.pools.<id>` groups profiles for quota handling. Delegating to a pool id picks the first healthy member. When a member stops with something that reads like an exhausted quota or rate limit, the bridge marks it cooling for `quota_cooldown_seconds` and re-runs **the same task** on the next member — each member at most once per task, after which the task fails instead of looping. The replaced attempt is kept on the record as `attempts[]` (agent, error, exit, log tails) so a reviewer can still see why the first CLI stopped, and the retry keeps holding the execution lock rather than releasing and re-taking it, so no second ordinary task slips into the gap.
98
+ `agents.pools.<id>` groups profiles for quota handling. Delegating to a pool id picks the first healthy member. When a member stops with something that reads like an exhausted quota or rate limit, the bridge marks it cooling for `quota_cooldown_seconds` and re-runs **the same task** on the next member — each member at most once per task, after which the task fails instead of looping. The replaced attempt is kept on the record as `attempts[]` (agent, error, exit, log tails, and for a worktree task the `scope` it had reached) so a reviewer can still see why the first CLI stopped and which files it left behind — the retry reuses the same worktree, so the task's own `scope` is the sum of both attempts — and the retry keeps holding the execution lock rather than releasing and re-taking it, so no second ordinary task slips into the gap.
97
99
 
98
100
  The owner's Settings reach an `antigravity` **profile** exactly as they reach `antigravity`, because they key on the adapter behind the id, not on the id: the model, the permission level, the effort, and the soft deadline with its `--log-file` and its extended `--print-timeout` are all on the command line of a profile task. Read that as a widening, not a cosmetic fix — before, a profile was locked to the CLI's own defaults and could not be given `--dangerously-skip-permissions` at all; now your permission level applies to its tasks too, so a profile is no more restricted than the agent it comes from.
99
101
 
100
- Two knobs are opt-in because they widen what the bridge can reach. Neither is reachable from a task, and neither stays invisible: the codex pair is reported in `agents_list` warnings, and the XERJ pair in `/api/xerj/status` plus the refusal `code_search` gives while it is off.
102
+ Three knobs are opt-in because they widen what the bridge can reach. None is reachable from a task, and none stays invisible: the codex pair and `opencode_auto_approve` are reported in `agents_list` warnings, and the XERJ pair in `/api/xerj/status` plus the refusal `code_search` gives while it is off.
101
103
 
102
104
  - `codex_sandbox: "danger-full-access"` / `codex_ask_for_approval: "never"` — readable from the bridge's own config file only. No task, tool argument or MCP client can ask for them, and `--ask-for-approval` is passed where codex accepts it (before `exec`).
105
+ - `agents.opencode_auto_approve: true` — passes `opencode run --auto`, which approves every permission OpenCode's own config does not deny. OpenCode itself is opt-in (list it in `agents.enabled`). A task's `autonomy: "full"` never adds the flag, for the same reason it never changes the codex sandbox: `autonomy` is an instruction, not a permission grant.
103
106
  - `xerj.enabled` / `xerj.auto_reference` — both default to `false`. Turning them on sends task text to another local service and can place a retrieved passage inside the prompt an agent follows, which is a decision for the machine's owner, not for a delegating client.
104
107
 
105
108
  ## Missions
@@ -164,6 +167,7 @@ Pushing or merging to `main` runs CI and, when it passes, publishes to npm autom
164
167
  - [docs/VERIFICATION.md](docs/VERIFICATION.md) — client CLI versions and live connection results.
165
168
  - [docs/MULTI-AGENT-PLAN.md](docs/MULTI-AGENT-PLAN.md) — the multi-agent roadmap and per-phase status.
166
169
  - [docs/HANDOFF.md](docs/HANDOFF.md) — open work (Job Object, POSIX E2E, worktree TTL) with mutation-test evidence.
170
+ - [docs/FREEBUFF-INTEGRATION.md](docs/FREEBUFF-INTEGRATION.md) — why Freebuff is a client and not an agent, the measurement behind that, and what `setup freebuff` writes.
167
171
  - [docs/PROMPTS.md](docs/PROMPTS.md) — review and follow-up task prompts.
168
172
  - [skills/antigravity-bridge/SKILL.md](skills/antigravity-bridge/SKILL.md), [skills/mission-orchestrator/SKILL.md](skills/mission-orchestrator/SKILL.md) and [docs/SKILL-EVAL.md](docs/SKILL-EVAL.md) — the client-side contract and manual evaluation cases.
169
173
 
@@ -11,10 +11,12 @@
11
11
  "max_parallel_worktrees": 2,
12
12
  "default_autonomy": "auto",
13
13
  "agents": {
14
- "priority": ["antigravity", "codex"],
15
- "enabled": ["antigravity", "codex", "codex-work", "codex-backup"],
14
+ "priority": ["antigravity", "codex", "opencode"],
15
+ "enabled": ["antigravity", "codex", "opencode", "codex-work", "codex-backup", "opencode-work"],
16
16
  "codex_sandbox": "workspace-write",
17
17
  "codex_ask_for_approval": "on-request",
18
+ "opencode_executable": "opencode",
19
+ "opencode_model": "opencode/big-pickle",
18
20
  "profiles": {
19
21
  "codex-work": {
20
22
  "adapter": "codex",
@@ -29,11 +31,19 @@
29
31
  "env": {
30
32
  "CODEX_HOME": "C:/Users/name/.codex-backup"
31
33
  }
34
+ },
35
+ "opencode-work": {
36
+ "adapter": "opencode",
37
+ "label": "OpenCode (Work)",
38
+ "env": {
39
+ "XDG_DATA_HOME": "C:/Users/name/.opencode-work/data",
40
+ "XDG_CONFIG_HOME": "C:/Users/name/.opencode-work/config"
41
+ },
42
+ "model": "opencode/big-pickle"
32
43
  }
33
44
  },
34
45
  "pools": {
35
46
  "codex-smart-pool": {
36
- "strategy": "quota-adaptive",
37
47
  "members": ["codex-work", "codex-backup"],
38
48
  "hard_task_profile": "codex-work",
39
49
  "quota_cooldown_seconds": 900
@@ -1,6 +1,6 @@
1
1
  import { finalEnvelope, invocationFor } from '../antigravity/invocation.js';
2
2
  import { extractStats } from '../usage.js';
3
- import { isQuotaError } from './quota.js';
3
+ import { isQuotaError, isStrongQuotaError } from './quota.js';
4
4
  const CAPABILITIES = ['code', 'review', 'test', 'research', 'long-run'];
5
5
  export function antigravityAdapter(executable, defaultTransport, options = {}) {
6
6
  return {
@@ -51,7 +51,7 @@ export function antigravityAdapter(executable, defaultTransport, options = {}) {
51
51
  // A stream result that is not the expected envelope is reported by field name, because that is what tells the owner to turn step progress off.
52
52
  const fields = transport === 'argv' ? '' : ` (stream result fields: ${Object.keys(envelope).slice(0, 12).join(', ') || 'none'}; if step progress is on, turn it off in Settings to return to the JSON mode)`;
53
53
  const finalErr = typeof envelope.error === 'string' ? envelope.error : `CLI did not provide a complete successful response${fields}`;
54
- const quotaHit = isQuotaError(raw.stderr) || isQuotaError(finalErr) || isQuotaError(raw.stdout);
54
+ const quotaHit = isQuotaError(raw.stderr) || isQuotaError(finalErr) || isStrongQuotaError(raw.stdout);
55
55
  return { ...result, error: finalErr, isQuotaExceeded: quotaHit };
56
56
  }
57
57
  };
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { isQuotaError } from './quota.js';
3
+ import { isQuotaError, isStrongQuotaError } from './quota.js';
4
4
  import { startProcess, terminateOwned } from '../antigravity/process.js';
5
5
  const CAPABILITIES = ['code', 'review', 'test', 'research', 'long-run'];
6
6
  const REQUIRED_FLAGS = ['--json', '--sandbox', '--cd', '--output-last-message', '--ephemeral', '--skip-git-repo-check'];
@@ -78,7 +78,7 @@ export function codexAdapter(command, options = {}) {
78
78
  return { ...result, error: `Codex was terminated (${raw.signal})` };
79
79
  const stderrTail = raw.stderr.trim().split(/\r?\n/).filter(Boolean).at(-1);
80
80
  const finalErr = errors.at(-1) ?? stderrTail?.slice(0, 500) ?? 'Codex did not provide a final message';
81
- const quotaHit = isQuotaError(raw.stderr) || isQuotaError(finalErr) || isQuotaError(raw.stdout);
81
+ const quotaHit = isQuotaError(raw.stderr) || isQuotaError(finalErr) || isStrongQuotaError(raw.stdout);
82
82
  return { ...result, error: finalErr, isQuotaExceeded: quotaHit };
83
83
  }
84
84
  };
@@ -0,0 +1,161 @@
1
+ import { startProcess, terminateOwned } from '../antigravity/process.js';
2
+ import { isQuotaError } from './quota.js';
3
+ export const CAPABILITIES = ['code', 'review', 'test', 'research', 'long-run'];
4
+ // Verified against opencode v2.2.5: `opencode run` reads the message from stdin and, with `--format json`, writes one JSON event per line.
5
+ const REQUIRED_FLAGS = ['--format', '--model', '--auto', '--standalone'];
6
+ const MAX_RESPONSE_CHARS = 1024 * 1024;
7
+ export function opencodeAdapter(command, options = {}) {
8
+ return {
9
+ id: options.id ?? 'opencode',
10
+ label: options.label ?? 'OpenCode',
11
+ family: 'opencode',
12
+ capabilities: CAPABILITIES,
13
+ buildInvocation(task) {
14
+ // `run` is the subcommand, so its flags follow it. The message goes over stdin: argv is capped (about 32 KB on Windows),
15
+ // a prompt starting with "-" would be read as a flag, and splitting it into words would lose its line breaks.
16
+ // The working directory is the process cwd, set by the launcher.
17
+ // `--standalone` gives the run a private server. Without it `opencode run` hands the task to the shared OpenCode
18
+ // background service (the one the desktop app keeps running): measured on v2.2.5, cancelling the client then left the
19
+ // agent working, its shell was a child of the service and not of the run the bridge can reap, and the session showed up
20
+ // in the owner's OpenCode. With it, killing the run stopped the work and left no process behind.
21
+ const args = ['run', '--standalone', '--format', 'json'];
22
+ const model = task.model ?? options.model;
23
+ if (model)
24
+ args.push('--model', model);
25
+ // Without --auto opencode still edits files and runs shell commands in the workspace without asking (measured on v2.2.5);
26
+ // --auto also approves what its config would otherwise ask about, such as paths outside the workspace.
27
+ // Only the owner can turn it on (agents.opencode_auto_approve): the task's `autonomy` is chosen by the delegating client and is an
28
+ // instruction to the agent, never a permission grant, so it must not reach a flag that takes approval away.
29
+ if (options.autoApprove)
30
+ args.push('--auto');
31
+ return {
32
+ executable: command.executable,
33
+ args: [...command.prefixArgs, ...args],
34
+ stdin: `${task.prompt}\n`,
35
+ customEnv: options.env
36
+ };
37
+ },
38
+ compactLine(line) {
39
+ let event;
40
+ try {
41
+ event = JSON.parse(line);
42
+ }
43
+ catch {
44
+ return line; /* not an event: kept, it may be the only clue to a failure */
45
+ }
46
+ if (!event || typeof event !== 'object' || Array.isArray(event))
47
+ return line;
48
+ const { type, part } = event;
49
+ // parseResult reads text, error and step_finish events, so those stay whole.
50
+ if (type === 'text' || type === 'error' || type === 'step_finish')
51
+ return line;
52
+ if (type === 'step_start')
53
+ return null;
54
+ // A tool event carries the file or command output it produced: keep which tool ran and how it ended, not the payload.
55
+ if (type === 'tool_use')
56
+ return JSON.stringify({ type, part: { tool: part?.tool, state: { status: part?.state?.status } } });
57
+ return line.length > 2000 ? JSON.stringify({ type }) : line;
58
+ },
59
+ async parseResult(raw) {
60
+ const texts = [];
61
+ const errors = [];
62
+ let turns = 0;
63
+ let finishReason = null;
64
+ let inputTokens = 0;
65
+ let outputTokens = 0;
66
+ let sawTokens = false;
67
+ for (const line of raw.stdout.split(/\r?\n/)) {
68
+ if (!line.trim())
69
+ continue;
70
+ let event;
71
+ try {
72
+ const value = JSON.parse(line);
73
+ if (!value || typeof value !== 'object' || Array.isArray(value))
74
+ continue;
75
+ event = value;
76
+ }
77
+ catch {
78
+ continue; /* A non-JSON line stays in the raw log but cannot change the result. */
79
+ }
80
+ const part = event.part;
81
+ if (event.type === 'text' && typeof part?.text === 'string')
82
+ texts.push(part.text);
83
+ else if (event.type === 'error') {
84
+ const error = event.error;
85
+ errors.push(typeof error?.message === 'string' ? error.message : typeof error?.type === 'string' ? error.type : 'OpenCode reported an error');
86
+ }
87
+ else if (event.type === 'step_finish') {
88
+ turns++;
89
+ if (typeof part?.reason === 'string')
90
+ finishReason = part.reason;
91
+ const tokens = part?.tokens;
92
+ if (typeof tokens?.input === 'number') {
93
+ inputTokens += tokens.input;
94
+ sawTokens = true;
95
+ }
96
+ if (typeof tokens?.output === 'number') {
97
+ outputTokens += tokens.output;
98
+ sawTokens = true;
99
+ }
100
+ }
101
+ }
102
+ // The last text event is the final answer; earlier ones are narration between tool calls.
103
+ const response = texts.at(-1)?.slice(0, MAX_RESPONSE_CHARS).trim() || null;
104
+ const base = {
105
+ success: false, response, error: null, cliStatus: finishReason, numTurns: turns || null, deniedActions: [],
106
+ stats: sawTokens ? { usage: { input_tokens: inputTokens, output_tokens: outputTokens }, agy_duration_seconds: null, num_turns: turns || null, steps: turns || null } : undefined
107
+ };
108
+ if (raw.exitCode === 0 && !errors.length && response)
109
+ return { ...base, success: true, numTurns: turns || 1 };
110
+ if (raw.signal)
111
+ return { ...base, error: `OpenCode was terminated (${raw.signal})` };
112
+ const stderrTail = raw.stderr.trim().split(/\r?\n/).filter(Boolean).at(-1)?.slice(0, 500);
113
+ const finalErr = errors.at(-1) ?? stderrTail ?? (raw.exitCode === 0 ? 'OpenCode did not provide a final message' : `OpenCode exited ${raw.exitCode}`);
114
+ // Only a failed run can be out of quota: a successful answer that merely mentions "429" must not trigger a failover.
115
+ const quotaHit = isQuotaError(finalErr) || isQuotaError(raw.stderr) || errors.some(isQuotaError);
116
+ return { ...base, error: finalErr, cliStatus: quotaHit ? 'quota_exceeded' : base.cliStatus ?? `exit ${raw.exitCode}`, isQuotaExceeded: quotaHit };
117
+ }
118
+ };
119
+ }
120
+ export function opencodeAgent(command, status, options = {}) {
121
+ return { adapter: opencodeAdapter(command, options), status, enabled: true, prefixArgs: command.prefixArgs };
122
+ }
123
+ function capture(command, args, cwd, extraEnvPassthrough) {
124
+ return new Promise((resolve, reject) => {
125
+ const child = startProcess(command.executable, [...command.prefixArgs, ...args], cwd, { extraEnvPassthrough });
126
+ let text = '';
127
+ let exceeded = false;
128
+ const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
129
+ const append = (chunk) => { if (text.length < 32768)
130
+ text += chunk.toString().slice(0, 32768 - text.length); };
131
+ child.stdout.on('data', append);
132
+ child.stderr.on('data', append);
133
+ child.once('error', error => { clearTimeout(timer); reject(error); });
134
+ child.once('close', code => { clearTimeout(timer); exceeded ? reject(new Error('OpenCode inspection timed out')) : resolve({ code, text }); });
135
+ });
136
+ }
137
+ /** Non-interactive `opencode run --format json` is the only contract this adapter relies on. */
138
+ export async function detectOpenCode(command, opts = {}) {
139
+ const cwd = opts.cwd ?? process.cwd();
140
+ const extra = opts.extraEnvPassthrough ?? [];
141
+ const status = (available, version, warnings) => ({
142
+ id: 'opencode', label: 'OpenCode', available, version, executable: command.executable, capabilities: CAPABILITIES, warnings
143
+ });
144
+ try {
145
+ const version = await capture(command, ['--version'], cwd, extra);
146
+ const help = await capture(command, ['run', '--help'], cwd, extra);
147
+ const parsed = version.text.trim().split(/\r?\n/)[0]?.replace(/^opencode\s+/i, '').replace(/^v/i, '') || null;
148
+ if (version.code !== 0)
149
+ return status(false, null, ['opencode --version failed']);
150
+ if (help.code !== 0)
151
+ return status(false, parsed, ['opencode run --help failed']);
152
+ const missing = REQUIRED_FLAGS.filter(flag => !new RegExp(`(?:^|[\\s,])${flag}(?=[\\s,]|$)`).test(help.text));
153
+ if (missing.length)
154
+ return status(false, parsed, [`OpenCode CLI is missing required run flags: ${missing.join(', ')}`]);
155
+ return status(true, parsed, []);
156
+ }
157
+ catch (error) {
158
+ return status(false, null, [`OpenCode CLI could not be inspected: ${error instanceof Error ? error.message : 'unknown error'}`]);
159
+ }
160
+ }
161
+ //# sourceMappingURL=opencode.js.map
@@ -1,24 +1,41 @@
1
- export const QUOTA_ERROR_PATTERNS = [
1
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ /**
3
+ * Wording that is hard to meet in ordinary prose or code: a provider says it, an agent rarely writes it by accident.
4
+ * These are the only patterns trusted on stdout, where an agent's own message ("I fixed the rate limit handler") lives.
5
+ */
6
+ export const STRONG_QUOTA_PATTERNS = [
2
7
  /insufficient_quota/i,
3
- /rate[_\s-]?limit(?:ed|_exceeded)?/i,
4
8
  /quota[_\s-]?exceeded/i,
5
9
  /credits?[_\s-]?exhausted/i,
6
10
  /check your plan and billing/i,
11
+ /exceeded your current quota/i,
12
+ /resource_exhausted/i
13
+ ];
14
+ /** Everything above, plus wording that error text uses but prose and stack traces also do. Only for stderr and the CLI's own error field. */
15
+ export const QUOTA_ERROR_PATTERNS = [
16
+ ...STRONG_QUOTA_PATTERNS,
17
+ /rate[_\s-]?limit(?:ed|_exceeded)?/i,
7
18
  /billing details/i,
8
19
  /too many requests/i,
9
20
  /credit limit/i,
10
21
  /model[_\s-]?capacity/i,
11
22
  /\boverloaded\b/i,
12
- /exceeded your current quota/i,
13
- /resource_exhausted/i,
14
- /\b429\b/
23
+ // A bare 429 also matches line numbers (`parser.ts:429`) and counts, so it has to be named as a status.
24
+ /\b(?:http|status|code|error)\b[^\n]{0,20}\b429\b/i,
25
+ /\b429\s+(?:too many|rate|error|client error)/i
15
26
  ];
16
- /** Check if stdout/stderr/error text indicates an account quota exhaustion or rate limit. */
27
+ /** Check if stderr or a CLI error field indicates an account quota exhaustion or rate limit. */
17
28
  export function isQuotaError(text) {
18
29
  if (!text)
19
30
  return false;
20
31
  return QUOTA_ERROR_PATTERNS.some(pattern => pattern.test(text));
21
32
  }
33
+ /** The stricter check for stdout, which carries the agent's own words as well as the CLI's output. */
34
+ export function isStrongQuotaError(text) {
35
+ if (!text)
36
+ return false;
37
+ return STRONG_QUOTA_PATTERNS.some(pattern => pattern.test(text));
38
+ }
22
39
  const HARD_KEYWORDS = /(?:refactor|architecture|concurrency|tái cấu trúc|bảo mật|tối ưu|redesign|deadlock|race condition|migration|distributed)/i;
23
40
  /** Assess whether a task is considered 'hard' (high complexity, multi-file, or architectural). */
24
41
  export function isHardTask(input) {
@@ -31,18 +48,48 @@ export function isHardTask(input) {
31
48
  return true;
32
49
  if (HARD_KEYWORDS.test(fullText))
33
50
  return true;
51
+ // Reasoning effort is not a signal here: it is an owner setting for the whole bridge, not a property of one task.
34
52
  if ((input.timeout_seconds ?? 0) >= 600)
35
53
  return true;
36
- if (input.effort === 'high')
37
- return true;
38
54
  return false;
39
55
  }
56
+ const MAX_PERSISTED_COOLDOWNS = 200;
40
57
  export class ProfileHealthTracker {
41
58
  cooldowns = new Map();
59
+ file = null;
60
+ /**
61
+ * Keeps cooldowns across a restart: without it an account that has just run out of quota is picked again by the next
62
+ * task. Anything unreadable or already expired is dropped, which only ever means "available". Writing is best effort.
63
+ */
64
+ attach(file) {
65
+ this.file = file;
66
+ try {
67
+ const value = JSON.parse(readFileSync(file, 'utf8'));
68
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
69
+ return;
70
+ const now = Date.now();
71
+ for (const [id, expiresAt] of Object.entries(value).slice(0, MAX_PERSISTED_COOLDOWNS)) {
72
+ if (id.length <= 64 && typeof expiresAt === 'number' && Number.isFinite(expiresAt) && expiresAt > now)
73
+ this.cooldowns.set(id, expiresAt);
74
+ }
75
+ }
76
+ catch { /* no file, or unreadable: every profile starts available */ }
77
+ }
78
+ save() {
79
+ if (!this.file)
80
+ return;
81
+ try {
82
+ const temporary = `${this.file}.tmp`;
83
+ writeFileSync(temporary, JSON.stringify(Object.fromEntries(this.cooldowns)), { mode: 0o600 });
84
+ renameSync(temporary, this.file);
85
+ }
86
+ catch { /* the cooldown still holds in memory; only its survival of a restart is lost */ }
87
+ }
42
88
  /** Mark a profile as exhausted / in cooldown until now + durationSeconds. */
43
89
  markCooldown(profileId, durationSeconds = 900) {
44
90
  const expiresAt = Date.now() + durationSeconds * 1000;
45
91
  this.cooldowns.set(profileId, expiresAt);
92
+ this.save();
46
93
  }
47
94
  /** Check if a profile is currently available (not cooled down). */
48
95
  isAvailable(profileId) {
@@ -57,7 +104,8 @@ export class ProfileHealthTracker {
57
104
  }
58
105
  /** Clear cooldown for testing or explicit manual recovery. */
59
106
  clearCooldown(profileId) {
60
- this.cooldowns.delete(profileId);
107
+ if (this.cooldowns.delete(profileId))
108
+ this.save();
61
109
  }
62
110
  /**
63
111
  * Intelligently select the best profile from a pool based on task difficulty and health status.
@@ -74,7 +122,7 @@ export class ProfileHealthTracker {
74
122
  if (isHard && pool.hard_task_profile && healthyMembers.includes(pool.hard_task_profile)) {
75
123
  return pool.hard_task_profile;
76
124
  }
77
- // 'quota-adaptive', 'priority-failover', and the current round-robin stub: first healthy in pool order
125
+ // The one selection rule a pool has: the first healthy member, in the order of `members`.
78
126
  return healthyMembers[0];
79
127
  }
80
128
  }
@@ -1,8 +1,10 @@
1
1
  import { resolveCommand } from '../antigravity/process.js';
2
2
  import { antigravityAdapter } from './antigravity.js';
3
3
  import { codexAgent, detectCodex } from './codex.js';
4
- const KNOWN = ['antigravity', 'codex'];
5
- const DEFAULT_PRIORITY = ['antigravity', 'codex'];
4
+ import { opencodeAgent, opencodeAdapter, detectOpenCode, CAPABILITIES } from './opencode.js';
5
+ const KNOWN = ['antigravity', 'codex', 'opencode'];
6
+ const DEFAULT_PRIORITY = ['antigravity', 'codex', 'opencode'];
7
+ // OpenCode is opt-in: list it in agents.enabled. Probing it by default would add an extra CLI to every pool for owners who never asked for it.
6
8
  const DEFAULT_ENABLED = ['antigravity', 'codex'];
7
9
  export async function createAgentRegistry(config, antigravity) {
8
10
  const settings = config.agents ?? {};
@@ -51,6 +53,28 @@ export async function createAgentRegistry(config, antigravity) {
51
53
  sandbox: settings.codex_sandbox,
52
54
  askForApproval: settings.codex_ask_for_approval
53
55
  }));
56
+ const opencodeEnabled = enabled.includes('opencode');
57
+ let opencodeStatus = {
58
+ id: 'opencode', label: 'OpenCode', available: false, version: null, executable: null,
59
+ capabilities: CAPABILITIES, warnings: ['Disabled in the bridge config']
60
+ };
61
+ let opencodeCommand = { executable: 'opencode', prefixArgs: [], source: 'opencode' };
62
+ if (opencodeEnabled) {
63
+ try {
64
+ opencodeCommand = await resolveCommand(settings.opencode_executable ?? 'opencode');
65
+ opencodeStatus = await detectOpenCode(opencodeCommand, { cwd: process.cwd(), extraEnvPassthrough: config.extra_env_passthrough });
66
+ }
67
+ catch (error) {
68
+ opencodeStatus = { ...opencodeStatus, warnings: [`OpenCode CLI not found: ${error instanceof Error ? error.message : 'unknown error'}`] };
69
+ }
70
+ }
71
+ const opencodeAutoWarning = 'Agent "opencode" is configured to auto-approve permissions (opencode_auto_approve): tasks run with opencode --auto';
72
+ if (opencodeEnabled && settings.opencode_auto_approve)
73
+ opencodeStatus = { ...opencodeStatus, warnings: [...opencodeStatus.warnings, opencodeAutoWarning] };
74
+ agents.push(opencodeAgent(opencodeCommand, opencodeStatus, {
75
+ model: settings.opencode_model,
76
+ autoApprove: settings.opencode_auto_approve
77
+ }));
54
78
  if (settings.profiles) {
55
79
  for (const [profileId, profile] of Object.entries(settings.profiles)) {
56
80
  const isProfileEnabled = enabled.includes(profileId) || (!settings.enabled && true);
@@ -121,6 +145,39 @@ export async function createAgentRegistry(config, antigravity) {
121
145
  sandbox, askForApproval, env: profile.env
122
146
  }));
123
147
  }
148
+ else if (adapterType === 'opencode') {
149
+ let cmd = opencodeCommand.executable;
150
+ let prefixArgs = opencodeCommand.prefixArgs;
151
+ let available = opencodeStatus.available;
152
+ let version = opencodeStatus.version;
153
+ if (profile.executable) {
154
+ try {
155
+ const resolved = await resolveCommand(profile.executable);
156
+ cmd = resolved.executable;
157
+ prefixArgs = resolved.prefixArgs;
158
+ const detected = await detectOpenCode(resolved, { cwd: process.cwd(), extraEnvPassthrough: config.extra_env_passthrough });
159
+ available = detected.available;
160
+ version = detected.version;
161
+ profileWarnings.push(...detected.warnings);
162
+ }
163
+ catch (error) {
164
+ available = false;
165
+ profileWarnings.push(`OpenCode CLI not found: ${error instanceof Error ? error.message : 'unknown error'}`);
166
+ }
167
+ }
168
+ const status = {
169
+ id: profileId, label, available: isProfileEnabled && available,
170
+ version: version || null, executable: cmd || null,
171
+ capabilities: CAPABILITIES,
172
+ warnings: isProfileEnabled
173
+ ? [...opencodeStatus.warnings.filter(w => !w.includes('Disabled') && w !== opencodeAutoWarning), ...profileWarnings, ...(settings.opencode_auto_approve ? [opencodeAutoWarning.replace('"opencode"', `"${profileId}"`)] : [])]
174
+ : profileWarnings
175
+ };
176
+ agents.push({
177
+ adapter: opencodeAdapter({ executable: cmd, prefixArgs, source: 'opencode-profile' }, { id: profileId, label, model: profile.model ?? settings.opencode_model, env: profile.env, autoApprove: settings.opencode_auto_approve }),
178
+ status, enabled: isProfileEnabled, prefixArgs
179
+ });
180
+ }
124
181
  }
125
182
  }
126
183
  const usable = (agent) => agent.enabled && agent.status.available;
@@ -30,23 +30,31 @@ export async function resolveCommand(configured) {
30
30
  // A shim that exists but cannot be resolved safely is a definitive failure: silently falling through
31
31
  // to the next PATH entry would hide why the configured command cannot run.
32
32
  if (process.platform === 'win32' && (extension === '.cmd' || extension === '.bat')) {
33
- const script = await shimEntry(resolved);
34
- return { executable: process.execPath, prefixArgs: [script], source: resolved };
33
+ const entry = await shimEntry(resolved);
34
+ // A shim of a natively packaged CLI (OpenCode) points at its own .exe: run that directly, not through Node.
35
+ if (path.extname(entry).toLowerCase() === '.exe')
36
+ return { executable: entry, prefixArgs: [], source: resolved };
37
+ return { executable: process.execPath, prefixArgs: [entry], source: resolved };
35
38
  }
36
39
  if (process.platform === 'win32' && extension !== '.exe')
37
40
  continue;
38
41
  return { executable: resolved, prefixArgs: [], source: resolved };
39
42
  }
40
- throw new Error(`Command not found: ${configured}${process.platform === 'win32' ? ' (a native .exe or an npm .cmd shim pointing at a local .js entry is required)' : ''}`);
43
+ throw new Error(`Command not found: ${configured}${process.platform === 'win32' ? ' (a native .exe or an npm .cmd shim pointing at a local .js or .exe entry is required)' : ''}`);
41
44
  }
42
- const SHIM_ENTRY = /(?:%dp0%|%~dp0)[\\/]([^"\r\n]+?\.(?:js|mjs|cjs))/gi;
45
+ const SHIM_ENTRY = /(?:%dp0%|%~dp0)[\\/]([^"\r\n]+?\.(?:js|mjs|cjs|exe))/gi;
43
46
  async function shimEntry(shim) {
44
47
  const text = await readFile(shim, 'utf8');
45
48
  if (text.length > 65536)
46
49
  throw new Error('Unsupported .cmd shim: file is too large');
47
50
  const directory = path.dirname(shim);
48
- for (const match of text.matchAll(SHIM_ENTRY)) {
49
- const candidate = path.resolve(directory, match[1]);
51
+ // A script entry wins over an .exe one. npm's own shim for a Node CLI names `%dp0%\node.exe` (its "use the node beside me"
52
+ // line) before the script line, and where node.exe sits next to the shim that line would otherwise be taken as the entry
53
+ // and the script dropped. A shim of a natively packaged CLI has no script, so its .exe is then the entry.
54
+ const isExe = (entry) => path.extname(entry).toLowerCase() === '.exe';
55
+ const entries = [...text.matchAll(SHIM_ENTRY)].map(match => match[1]).sort((a, b) => Number(isExe(a)) - Number(isExe(b)));
56
+ for (const entry of entries) {
57
+ const candidate = path.resolve(directory, entry);
50
58
  if (!inside(directory, candidate))
51
59
  continue;
52
60
  try {
@@ -66,12 +74,22 @@ export async function findExecutable(configured) {
66
74
  }
67
75
  export function childEnvironment(parent, extra = []) {
68
76
  const explicit = new Set(extra.map(key => key.toUpperCase()));
69
- const base = /^(PATH|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|TEMP|TMP|TMPDIR|SYSTEMROOT|COMSPEC|LANG|LC_.*|XDG_.*|HTTP_PROXY|HTTPS_PROXY|NO_PROXY)$/i;
77
+ // PATHEXT is how PowerShell and cmd resolve `git` to git.exe; without it an agent's shell cannot find any program by its bare name.
78
+ const base = /^(PATH|PATHEXT|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|TEMP|TMP|TMPDIR|SYSTEMROOT|COMSPEC|LANG|LC_.*|XDG_.*|HTTP_PROXY|HTTPS_PROXY|NO_PROXY)$/i;
70
79
  return Object.fromEntries(Object.entries(parent).filter(([key, value]) => value !== undefined &&
71
80
  (explicit.has(key.toUpperCase()) || (base.test(key) && !/TOKEN|SECRET|PASSWORD|API_KEY/i.test(key)))));
72
81
  }
82
+ /**
83
+ * A parent that starts the bridge with a trimmed environment (an MCP client's stdio launcher keeps only a handful of variables)
84
+ * leaves PATHEXT unset, and then PowerShell cannot resolve `git` to git.exe. A value the parent did set wins over this default.
85
+ */
86
+ export function withWindowsDefaults(env, platform = process.platform) {
87
+ if (platform !== 'win32' || Object.keys(env).some(key => key.toUpperCase() === 'PATHEXT'))
88
+ return env;
89
+ return { ...env, PATHEXT: '.COM;.EXE;.BAT;.CMD' };
90
+ }
73
91
  export function startProcess(executable, args, cwd, options = {}) {
74
- const env = { ...childEnvironment(process.env, options.extraEnvPassthrough), ...(options.customEnv ?? {}) };
92
+ const env = withWindowsDefaults({ ...childEnvironment(process.env, options.extraEnvPassthrough), ...(options.customEnv ?? {}) });
75
93
  const child = spawn(executable, args, {
76
94
  cwd, env, shell: false, windowsHide: true, detached: process.platform !== 'win32',
77
95
  stdio: ['pipe', 'pipe', 'pipe']