opencode-with-claude 1.10.1 → 1.10.4
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 +64 -11
- package/dist/headers.d.ts +54 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +8 -1
- package/dist/proxy.d.ts +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,11 +8,13 @@ Use [OpenCode](https://opencode.ai) with your [Claude Max](https://claude.ai) su
|
|
|
8
8
|
|
|
9
9
|
An [OpenCode](https://opencode.ai) plugin that runs [Meridian](https://github.com/rynfar/meridian) *(formerly opencode-claude-max-proxy)* for you: **start OpenCode once** and the proxy comes up with it; **quit OpenCode** and the proxy stops. No separate proxy CLI or Docker container to manage.
|
|
10
10
|
|
|
11
|
+
Works with both OpenCode generations: the 1.x line (`opencode`) and OpenCode 2 (`@opencode/cli`), from the same package.
|
|
12
|
+
|
|
11
13
|
**Compared to running the proxy yourself:**
|
|
12
14
|
|
|
13
15
|
- **One process to think about** — OpenCode owns the proxy lifecycle (start/stop) instead of you juggling two things.
|
|
14
|
-
- **Several OpenCode windows at once** — each
|
|
15
|
-
- **Explicit session headers** — the plugin adds session tracking on outgoing API calls, so the proxy does not have to infer sessions from fingerprints alone.
|
|
16
|
+
- **Several OpenCode windows at once** — each process gets its own proxy (port 3456 when available, otherwise an OS-assigned port). Project instances within the same process share that proxy, including concurrent plugin initialization.
|
|
17
|
+
- **Explicit session headers** — the plugin adds session tracking on outgoing API calls, so the proxy does not have to infer sessions from fingerprints alone. OpenCode's hidden title and summary requests are kept off the session's turn lease, so the first message of a fresh session does not race them.
|
|
16
18
|
|
|
17
19
|
## How It Works
|
|
18
20
|
|
|
@@ -52,7 +54,11 @@ claude auth login
|
|
|
52
54
|
|
|
53
55
|
**3. Add to your `opencode.json`**
|
|
54
56
|
|
|
55
|
-
Global (`~/.config/opencode/opencode.json`) or project-level
|
|
57
|
+
Global (`~/.config/opencode/opencode.json`) or project-level. The keys differ
|
|
58
|
+
between OpenCode generations; a block written for the other generation is
|
|
59
|
+
silently ignored, so make sure you use the right one.
|
|
60
|
+
|
|
61
|
+
OpenCode 1.x:
|
|
56
62
|
|
|
57
63
|
```json
|
|
58
64
|
{
|
|
@@ -69,7 +75,29 @@ Global (`~/.config/opencode/opencode.json`) or project-level:
|
|
|
69
75
|
}
|
|
70
76
|
```
|
|
71
77
|
|
|
72
|
-
|
|
78
|
+
OpenCode 2 (`plugins` and `providers.<id>.settings` instead of `plugin` and
|
|
79
|
+
`provider.<id>.options`):
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"$schema": "https://opencode.ai/config.json",
|
|
84
|
+
"plugins": ["opencode-with-claude"],
|
|
85
|
+
"providers": {
|
|
86
|
+
"anthropic": {
|
|
87
|
+
"settings": {
|
|
88
|
+
"baseURL": "http://127.0.0.1:3456/v1",
|
|
89
|
+
"apiKey": "dummy"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
In both cases the `baseURL` is only a placeholder: the plugin rewrites every
|
|
97
|
+
Anthropic request to whatever port its own proxy actually got, so several
|
|
98
|
+
OpenCode instances can run side by side.
|
|
99
|
+
|
|
100
|
+
If you installed with Homebrew, point the plugin entry at the installed
|
|
73
101
|
file instead of the package name (the path is stable across upgrades, and
|
|
74
102
|
`brew info opencode-with-claude` prints it):
|
|
75
103
|
|
|
@@ -77,8 +105,9 @@ file instead of the package name (the path is stable across upgrades, and
|
|
|
77
105
|
"plugin": ["file:///opt/homebrew/opt/opencode-with-claude/libexec/lib/node_modules/opencode-with-claude/dist/index.js"]
|
|
78
106
|
```
|
|
79
107
|
|
|
80
|
-
On Linux or Intel macOS replace
|
|
81
|
-
(`brew --prefix`, usually
|
|
108
|
+
(`"plugins": [...]` on OpenCode 2.) On Linux or Intel macOS replace
|
|
109
|
+
`/opt/homebrew` with your Homebrew prefix (`brew --prefix`, usually
|
|
110
|
+
`/home/linuxbrew/.linuxbrew` or `/usr/local`).
|
|
82
111
|
|
|
83
112
|
**4. Run OpenCode**
|
|
84
113
|
|
|
@@ -226,12 +255,15 @@ firewall rules or other access controls if you open it up.
|
|
|
226
255
|
```
|
|
227
256
|
opencode-with-claude/
|
|
228
257
|
├── src/
|
|
229
|
-
│ ├── index.ts # Plugin entry point
|
|
258
|
+
│ ├── index.ts # Plugin entry point: v1 server() + v2 setup()
|
|
259
|
+
│ ├── headers.ts # Meridian request-identity headers (shared)
|
|
230
260
|
│ ├── proxy.ts # Proxy lifecycle management
|
|
231
|
-
│
|
|
261
|
+
│ ├── meridian-config.ts # Reads Meridian's profiles/settings files
|
|
262
|
+
│ └── logger.ts # Plugin loggers
|
|
232
263
|
├── test/
|
|
233
|
-
│ ├── run.sh #
|
|
234
|
-
│
|
|
264
|
+
│ ├── run.sh # Launches OpenCode 1.x with the built plugin
|
|
265
|
+
│ ├── opencode.json # Test config
|
|
266
|
+
│ └── unit/ # node:test suites (npm run test:unit)
|
|
235
267
|
├── scripts/
|
|
236
268
|
│ └── update-homebrew-formula.sh # Bumps the Homebrew formula (in ianjwhite99/homebrew-tap) after an npm release
|
|
237
269
|
├── package.json
|
|
@@ -248,10 +280,24 @@ npm run build
|
|
|
248
280
|
### Test locally
|
|
249
281
|
|
|
250
282
|
```bash
|
|
251
|
-
|
|
283
|
+
npm run test:unit # Build, then run the unit suites (v1 hooks and v2 setup)
|
|
284
|
+
./test/run.sh # Build and launch OpenCode 1.x with the plugin
|
|
252
285
|
./test/run.sh --clean # Remove build artifacts
|
|
253
286
|
```
|
|
254
287
|
|
|
288
|
+
To try the build in OpenCode 2, point a `plugins` entry at the `dist`
|
|
289
|
+
directory, for example `"plugins": ["/path/to/opencode-with-claude/dist"]`.
|
|
290
|
+
|
|
291
|
+
### How the two OpenCode generations are served
|
|
292
|
+
|
|
293
|
+
`dist/index.js` has a single default export with `id`, `server()` and
|
|
294
|
+
`setup()`. OpenCode 1.x calls `server()` and uses the returned hooks
|
|
295
|
+
(`config`, `chat.headers`, ...). OpenCode 2 calls `setup(ctx)` and the plugin
|
|
296
|
+
registers `session.hook("model.request")` (base URL and Meridian headers) and
|
|
297
|
+
the system-prompt hooks on the context. The module deliberately has no other
|
|
298
|
+
exports: OpenCode 1.17 and 1.18 load every export as a plugin, so a second one
|
|
299
|
+
would start a second proxy.
|
|
300
|
+
|
|
255
301
|
## FAQ
|
|
256
302
|
|
|
257
303
|
**Do I need an Anthropic API key?**
|
|
@@ -262,6 +308,13 @@ No. Claude Max is not authenticated with API keys here. Run `claude login` once;
|
|
|
262
308
|
|
|
263
309
|
The proxy will fail to authenticate. Run `claude auth status`. You need an active Claude Max plan; see [claude.ai](https://claude.ai) for current options and pricing.
|
|
264
310
|
|
|
311
|
+
**Does this work with OpenCode 2?**
|
|
312
|
+
|
|
313
|
+
Yes. The same package loads on OpenCode 1.x and OpenCode 2; only the
|
|
314
|
+
`opencode.json` keys differ (see Quick Start). OpenCode 2 gives plugins no log
|
|
315
|
+
API, so on that generation the plugin's startup and health messages go to the
|
|
316
|
+
server's stderr instead of the OpenCode log (visible with `--print-logs`).
|
|
317
|
+
|
|
265
318
|
**Can I run several OpenCode instances at once?**
|
|
266
319
|
|
|
267
320
|
Yes. The first instance uses port **3456** by default; others get a free OS-assigned port, so nothing extra to configure.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request identity headers for Meridian.
|
|
3
|
+
*
|
|
4
|
+
* Meridian keys its per-session turn lease on `x-opencode-session` (falling
|
|
5
|
+
* back to `x-session-affinity`), picks the model tier from
|
|
6
|
+
* `x-opencode-agent-mode` (primary → 1M context, subagent → 200k), and treats
|
|
7
|
+
* subagent-mode requests as independent flows that may run concurrently with
|
|
8
|
+
* the session's primary turn.
|
|
9
|
+
*
|
|
10
|
+
* OpenCode runs a few hidden one-shot requests (title, summary) on the *same*
|
|
11
|
+
* session id as the user's prompt, concurrently with it. If they carry the
|
|
12
|
+
* session header they contend for the lease and whichever request waited is
|
|
13
|
+
* rejected with "This session advanced while the request was waiting" — about
|
|
14
|
+
* half the time that is the user's first message. Those requests are detached
|
|
15
|
+
* here: no session header, subagent mode, and an `x-meridian-source` marker so
|
|
16
|
+
* the proxy can still tell where they came from. This mirrors what Meridian's
|
|
17
|
+
* own OpenCode plugins do.
|
|
18
|
+
*
|
|
19
|
+
* Leaf module: no imports from index.ts or proxy.ts.
|
|
20
|
+
*/
|
|
21
|
+
export type AgentMode = "primary" | "subagent";
|
|
22
|
+
export interface RequestIdentity {
|
|
23
|
+
sessionID: string;
|
|
24
|
+
/** ASCII-only agent name, "unknown" when OpenCode did not pass one. */
|
|
25
|
+
agentName: string;
|
|
26
|
+
agentMode: AgentMode;
|
|
27
|
+
/** True for hidden one-shots that must not share the session's lease. */
|
|
28
|
+
detached: boolean;
|
|
29
|
+
/** Optional `x-meridian-source` value for attached requests (compaction). */
|
|
30
|
+
source?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Hidden one-shot agents OpenCode runs concurrently with the primary turn. */
|
|
33
|
+
export declare const DETACHED_AGENTS: ReadonlySet<string>;
|
|
34
|
+
/** Compaction runs in the session's lineage but on the subagent model tier. */
|
|
35
|
+
export declare const COMPACTION_AGENT = "compaction";
|
|
36
|
+
/** Strip non-ASCII (e.g. zero-width spaces) that make undici reject the header. */
|
|
37
|
+
export declare function safeAgentName(raw: unknown): string;
|
|
38
|
+
/** Meridian only understands primary|subagent; "all" agents act as primary. */
|
|
39
|
+
export declare function normalizeAgentMode(mode: unknown): AgentMode;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve an agent's mode from, in order: an explicit runtime mode, the modes
|
|
42
|
+
* captured from OpenCode's config, and the built-in table.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveAgentMode(agentName: string, explicit: unknown, configured: ReadonlyMap<string, string>): AgentMode;
|
|
45
|
+
export declare function deleteHeader(headers: Record<string, string>, name: string): void;
|
|
46
|
+
/**
|
|
47
|
+
* Rewrite the Meridian identity headers on an outgoing Anthropic request.
|
|
48
|
+
*
|
|
49
|
+
* Anything already present — from provider config, another plugin, or an
|
|
50
|
+
* earlier hook — is removed first, case-insensitively, so a stale session or
|
|
51
|
+
* mode header can never rebind the request. `anthropic-beta` is dropped
|
|
52
|
+
* because Meridian speaks to the Agent SDK, not the raw Messages API.
|
|
53
|
+
*/
|
|
54
|
+
export declare function applyMeridianHeaders(headers: Record<string, string>, identity: RequestIdentity): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { PluginModule } from "@opencode-ai/plugin";
|
|
2
|
+
import type { Plugin as OpenCodeV2 } from "@opencode/plugin";
|
|
3
|
+
/**
|
|
4
|
+
* One module serves both OpenCode generations:
|
|
5
|
+
*
|
|
6
|
+
* - v1 (1.17+) reads `default.server`. Older 1.x loaders iterate *every*
|
|
7
|
+
* export and accept an object with `server`, which is why this file has no
|
|
8
|
+
* named exports: a second export would load the plugin (and the proxy)
|
|
9
|
+
* twice.
|
|
10
|
+
* - v2 reads `default.id` and `default.setup` and ignores `server`.
|
|
11
|
+
*/
|
|
12
|
+
declare const plugin: PluginModule & OpenCodeV2.Plugin;
|
|
13
|
+
export default plugin;
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{scrubOpencodeFingerprints as ie}from"@rynfar/meridian-plugin-opencode-scrub";var H=/authenticat|credentials|expired|not logged in|exit(?:ed)? with code|crash|unhealthy|401|402|billing|subscription/i,j=/rate.limit|429|overloaded|503|stale.session|timeout|timed out/i;function x(e){return(n,r)=>e.app.log({body:{service:"opencode-with-claude",level:n,message:r}})}function S(e){return H.test(e)?"error":j.test(e)?"warn":"debug"}import{existsSync as q,readFileSync as W}from"fs";import{homedir as G}from"os";import{join as v}from"path";var b=()=>v(G(),".config","meridian"),k=()=>v(b(),"profiles.json"),L=()=>v(b(),"settings.json");function m(e,n){e?.("warn","[opencode-with-claude] ".concat(n))}function E(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function I(e,n,r){if(!Array.isArray(e))return m(r,"".concat(n," must be a JSON array of profile objects; got ").concat(typeof e,". Ignoring.")),[];let t=[];for(let o of e){if(!E(o)||typeof o.id!="string"||!o.id){m(r,"".concat(n,': dropping profile without a string "id" field.'));continue}let i={id:o.id};(o.type==="claude-max"||o.type==="api"||o.type==="oauth-token")&&(i.type=o.type),typeof o.claudeConfigDir=="string"&&(i.claudeConfigDir=o.claudeConfigDir),typeof o.apiKey=="string"&&(i.apiKey=o.apiKey),typeof o.baseUrl=="string"&&(i.baseUrl=o.baseUrl),typeof o.oauthToken=="string"&&(i.oauthToken=o.oauthToken),t.push(i)}return t}function A(e,n){if(q(e))try{return JSON.parse(W(e,"utf8"))}catch(r){let t=r instanceof Error?r.message:String(r);m(n,"failed to parse ".concat(e,": ").concat(t));return}}function J(e){let n=process.env.MERIDIAN_PROFILES;if(n)try{return I(JSON.parse(n),"MERIDIAN_PROFILES",e)}catch(r){let t=r instanceof Error?r.message:String(r);m(e,"failed to parse MERIDIAN_PROFILES env var: ".concat(t));return}}function K(e){let n=A(k(),e);return n===void 0?[]:I(n,k(),e)}function z(e){let n=A(L(),e);if(!E(n))return;let r=n.activeProfile;if(r!==void 0){if(typeof r!="string"||!r){m(e,"".concat(L(),': "activeProfile" must be a non-empty string; ignoring.'));return}return r}}function C(e){let n=[],r="none",t=J(e);if(t)n=t,r="env";else{let c=K(e);c.length>0&&(n=c,r="disk")}let o,i="none",l=process.env.MERIDIAN_DEFAULT_PROFILE?.trim();if(l)o=l,i="env";else{let c=z(e);c&&(o=c,i="disk")}return o&&n.length>0&&!n.some(c=>c.id===o)&&(m(e,'default profile "'.concat(o,'" (from ').concat(i,") not found among configured profiles; ignoring.")),o=void 0,i="none"),{profiles:n,defaultProfile:o,sources:{profiles:r,defaultProfile:i}}}function F(e){if(e.profiles.length===0)return;let n=e.profiles.map(i=>i.id).join(", "),r=e.defaultProfile??e.profiles[0]?.id,t=e.sources.profiles,o=e.sources.defaultProfile==="none"?"first":e.sources.defaultProfile;return"loaded ".concat(e.profiles.length," meridian profile(s) from ").concat(t,": ").concat(n," (active: ").concat(r," [").concat(o,"])")}import{existsSync as X,readFileSync as Y}from"fs";import{createRequire as B}from"module";import{dirname as $,join as V}from"path";import{startProxyServer as Q}from"@rynfar/meridian";process.env.MERIDIAN_PASSTHROUGH??="true";var Z=process.platform==="win32",D=3456,O="127.0.0.1",M="@rynfar/meridian";function ee(){try{let e=$(B(import.meta.url).resolve(M));for(let n=0;n<5;n++){let r=V(e,"package.json");if(X(r)){let o=JSON.parse(Y(r,"utf8"));if(o.name===M&&typeof o.version=="string")return o.version}let t=$(e);if(t===e)break;e=t}return}catch{return}}function ne(e){return e.includes(":")&&!e.startsWith("[")?"[".concat(e,"]"):e}function w(){let e=process.env.MERIDIAN_HOST?.trim()||process.env.CLAUDE_PROXY_HOST?.trim()||O;return e.startsWith("[")&&e.endsWith("]")?e.slice(1,-1):e}function re(e=w()){return e==="0.0.0.0"?O:e==="::"||e==="[::]"?"::1":e}function R(e,n=w()){return"http://".concat(ne(re(n)),":").concat(e)}async function T(e){let{port:n=D,log:r,profiles:t,defaultProfile:o}=e,i=w(),l=ee(),c=console.error;console.error=(...s)=>{let u=s.map(String).join(" ");if(u.startsWith("[PROXY]")){r?.(S(u),u);return}c.apply(console,s)};let p=s=>new Promise((u,y)=>{Q({port:s,host:i,silent:!0,profiles:t,defaultProfile:o,version:l}).then(g=>{let h=_=>{y(_)};g.server.once("error",h),g.server.listening?(g.server.removeListener("error",h),u(g)):g.server.once("listening",()=>{g.server.removeListener("error",h),u(g)})},y)}),P=async s=>{try{return await p(s)}catch(u){if(s!==0&&u instanceof Error&&"code"in u&&u.code==="EADDRINUSE")return r?.("info","Port ".concat(s," in use, starting on a random port instead...")),p(0);throw u}},a;try{a=await P(typeof n=="string"?parseInt(n,10):n)}catch(s){throw console.error=c,s}let d=a.server.address()?.port??a.config?.port??D;return r?.("info","Claude Max proxy running on port ".concat(d)),{port:d,close:async()=>{console.error=c,await a.close()}}}function oe(e){if(typeof e!="object"||e===null)return{};let n=e,r={};return typeof n.daysUntilRenewal=="number"&&Number.isFinite(n.daysUntilRenewal)&&(r.daysUntilRenewal=n.daysUntilRenewal),typeof n.renewalRequiredSoon=="boolean"&&(r.renewalRequiredSoon=n.renewalRequiredSoon),r}function te(e){return e===void 0?"soon":e<=0?"today":e===1?"in 1 day":"in ".concat(e," days")}async function N(e,n){try{let r=await fetch(R(e)+"/health",{signal:AbortSignal.timeout(5e3)}),t=await r.json(),o=t.version,i=typeof o=="string"&&o!=="unknown"?o:void 0,l=oe(t.auth);if(i&&n?.("info","[claude-max] meridian ".concat(i)),l.renewalRequiredSoon&&n?.("warn","[claude-max] Claude login expires ".concat(te(l.daysUntilRenewal),". Run 'claude login' to renew \u2014 the proxy cannot refresh it for you.")),t.status==="healthy")return{ok:!0,version:i,...l};if(t.status==="degraded"){let p=typeof t.error=="string"?t.error:"Could not verify auth status";return n?.("warn","[claude-max] ".concat(p,". Requests may still work \u2014 if they hang, try running 'claude login' in your terminal.")),{ok:!0,message:p,version:i,...l}}let c=typeof t.error=="string"?t.error:"Proxy health check returned status: ".concat(t.status??r.status);return n?.("error","[claude-max] ".concat(c)),{ok:!1,message:c,version:i,...l}}catch(r){let t=r instanceof Error?r.message:String(r);return n?.("error","[claude-max] Health check failed: ".concat(t)),{ok:!1,message:"Health check failed: ".concat(t)}}}function U(e){let n=!1,r=()=>{n||(n=!0,e.close())};process.on("exit",r),process.on("SIGINT",r),Z||process.on("SIGTERM",r)}var se=4096,xe=async({client:e})=>{let n=x(e),r=new Map,t=new Map,o=(a,f)=>"".concat(a,"\0").concat(f),i=C(n),l=F(i);l&&n("info",l);let c=process.env.CLAUDE_PROXY_PORT||3456,p=await T({port:c,log:n,profiles:i.profiles,defaultProfile:i.defaultProfile}),P=R(p.port);return n("info","proxy ready at ".concat(P)),U(p),N(p.port,n),{async config(a){for(let[d,s]of Object.entries(a.agent??{}))s?.mode&&r.set(d.toLowerCase(),s.mode);let f=a.provider?.anthropic;f&&(f.options||(f.options={}),f.options.baseURL=P)},async"chat.message"(a,f){let d=o(a.sessionID,f.message.id);if(t.delete(d),t.set(d,!0),t.size>se){let s=t.keys().next().value;s!==void 0&&t.delete(s)}},async"experimental.chat.system.transform"(a,f){if(a.model.providerID!=="anthropic")return;let d=f.system.join("\n\n"),s=ie(d);s!==d&&f.system.splice(0,f.system.length,s)},async"chat.headers"(a,f){if(a.model.providerID!=="anthropic")return;delete f.headers["anthropic-beta"];let d=a.agent,s=typeof d=="object"&&d!==null,u=s?d.name:d,y=String(u??"unknown").replace(/[^\x20-\x7E]/g,"").trim()||"unknown",g=s&&typeof d.mode=="string"?d.mode:r.get(y.toLowerCase())??"primary";f.headers["x-opencode-session"]=a.sessionID,f.headers["x-opencode-request"]=a.message.id,f.headers["x-opencode-request-kind"]=t.has(o(a.sessionID,a.message.id))?"human":"synthetic",f.headers["x-opencode-agent-mode"]=g,f.headers["x-opencode-agent-name"]=y}}};export{xe as ClaudeMaxPlugin};
|
|
1
|
+
import{scrubOpencodeFingerprints as xe}from"@rynfar/meridian-plugin-opencode-scrub";var h=new Set(["title","summary"]),C="compaction",ee={build:"primary",plan:"primary",general:"subagent",explore:"subagent",title:"primary",summary:"primary",compaction:"primary"},ne=["x-opencode-session","x-session-affinity","x-session-id","x-parent-session-id"],re=["x-meridian-source","x-opencode-agent-mode","x-opencode-agent-name"];function x(e){return String(e??"unknown").replace(/[^\x20-\x7E]/g,"").trim()||"unknown"}function E(e){return e==="subagent"?"subagent":"primary"}function v(e,n,r){if(typeof n=="string")return E(n);let o=e.toLowerCase(),t=r.get(o);return t!==void 0?E(t):ee[o]??"primary"}function w(e,n){let r=n.toLowerCase();for(let o of Object.keys(e))o.toLowerCase()===r&&delete e[o]}function S(e,n){w(e,"anthropic-beta");for(let r of ne)w(e,r);for(let r of re)w(e,r);n.detached?e["x-meridian-source"]="subagent-".concat(n.agentName):(e["x-opencode-session"]=n.sessionID,n.source&&(e["x-meridian-source"]=n.source)),e["x-opencode-agent-mode"]=n.detached?"subagent":n.agentMode,e["x-opencode-agent-name"]=n.agentName}var te=/authenticat|credentials|expired|not logged in|exit(?:ed)? with code|crash|unhealthy|401|402|billing|subscription/i,oe=/rate.limit|429|overloaded|503|stale.session|timeout|timed out/i;function M(e){return(n,r)=>e.app.log({body:{service:"opencode-with-claude",level:n,message:r}})}function D(){return async(e,n)=>{e!=="debug"&&console.error("[opencode-with-claude] ".concat(e,": ").concat(n))}}function N(e){return te.test(e)?"error":oe.test(e)?"warn":"debug"}import{existsSync as ie,readFileSync as se}from"fs";import{homedir as ae}from"os";import{join as L}from"path";var O=()=>L(ae(),".config","meridian"),F=()=>L(O(),"profiles.json"),T=()=>L(O(),"settings.json");function m(e,n){e?.("warn","[opencode-with-claude] ".concat(n))}function $(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function U(e,n,r){if(!Array.isArray(e))return m(r,"".concat(n," must be a JSON array of profile objects; got ").concat(typeof e,". Ignoring.")),[];let o=[];for(let t of e){if(!$(t)||typeof t.id!="string"||!t.id){m(r,"".concat(n,': dropping profile without a string "id" field.'));continue}let a={id:t.id};(t.type==="claude-max"||t.type==="api"||t.type==="oauth-token")&&(a.type=t.type),typeof t.claudeConfigDir=="string"&&(a.claudeConfigDir=t.claudeConfigDir),typeof t.apiKey=="string"&&(a.apiKey=t.apiKey),typeof t.baseUrl=="string"&&(a.baseUrl=t.baseUrl),typeof t.oauthToken=="string"&&(a.oauthToken=t.oauthToken),o.push(a)}return o}function _(e,n){if(ie(e))try{return JSON.parse(se(e,"utf8"))}catch(r){let o=r instanceof Error?r.message:String(r);m(n,"failed to parse ".concat(e,": ").concat(o));return}}function de(e){let n=process.env.MERIDIAN_PROFILES;if(n)try{return U(JSON.parse(n),"MERIDIAN_PROFILES",e)}catch(r){let o=r instanceof Error?r.message:String(r);m(e,"failed to parse MERIDIAN_PROFILES env var: ".concat(o));return}}function ce(e){let n=_(F(),e);return n===void 0?[]:U(n,F(),e)}function ue(e){let n=_(T(),e);if(!$(n))return;let r=n.activeProfile;if(r!==void 0){if(typeof r!="string"||!r){m(e,"".concat(T(),': "activeProfile" must be a non-empty string; ignoring.'));return}return r}}function H(e){let n=[],r="none",o=de(e);if(o)n=o,r="env";else{let i=ce(e);i.length>0&&(n=i,r="disk")}let t,a="none",d=process.env.MERIDIAN_DEFAULT_PROFILE?.trim();if(d)t=d,a="env";else{let i=ue(e);i&&(t=i,a="disk")}return t&&n.length>0&&!n.some(i=>i.id===t)&&(m(e,'default profile "'.concat(t,'" (from ').concat(a,") not found among configured profiles; ignoring.")),t=void 0,a="none"),{profiles:n,defaultProfile:t,sources:{profiles:r,defaultProfile:a}}}function j(e){if(e.profiles.length===0)return;let n=e.profiles.map(a=>a.id).join(", "),r=e.defaultProfile??e.profiles[0]?.id,o=e.sources.profiles,t=e.sources.defaultProfile==="none"?"first":e.sources.defaultProfile;return"loaded ".concat(e.profiles.length," meridian profile(s) from ").concat(o,": ").concat(n," (active: ").concat(r," [").concat(t,"])")}import{existsSync as fe,readFileSync as le}from"fs";import{createRequire as ge}from"module";import{dirname as q,join as pe}from"path";import{startProxyServer as me}from"@rynfar/meridian";process.env.MERIDIAN_PASSTHROUGH??="true";var G=process.platform==="win32",W=3456,K="127.0.0.1",J="@rynfar/meridian";function ye(){try{let e=q(ge(import.meta.url).resolve(J));for(let n=0;n<5;n++){let r=pe(e,"package.json");if(fe(r)){let t=JSON.parse(le(r,"utf8"));if(t.name===J&&typeof t.version=="string")return t.version}let o=q(e);if(o===e)break;e=o}return}catch{return}}function Re(e){return e.includes(":")&&!e.startsWith("[")?"[".concat(e,"]"):e}function b(){let e=process.env.MERIDIAN_HOST?.trim()||process.env.CLAUDE_PROXY_HOST?.trim()||K;return e.startsWith("[")&&e.endsWith("]")?e.slice(1,-1):e}function Pe(e=b()){return e==="0.0.0.0"?K:e==="::"||e==="[::]"?"::1":e}function I(e,n=b()){return"http://".concat(Re(Pe(n)),":").concat(e)}async function z(e){let{port:n=W,log:r,profiles:o,defaultProfile:t}=e,a=b(),d=ye(),i=console.error;console.error=(...l)=>{let f=l.map(String).join(" ");if(f.startsWith("[PROXY]")){r?.(N(f),f);return}i.apply(console,l)};let s=l=>new Promise((f,A)=>{me({port:l,host:a,silent:!0,profiles:o,defaultProfile:t,version:d}).then(g=>{let P=Z=>{A(Z)};g.server.once("error",P),g.server.listening?(g.server.removeListener("error",P),f(g)):g.server.once("listening",()=>{g.server.removeListener("error",P),f(g)})},A)}),c=async l=>{try{return await s(l)}catch(f){if(l!==0&&f instanceof Error&&"code"in f&&f.code==="EADDRINUSE")return r?.("info","Port ".concat(l," in use, starting on a random port instead...")),s(0);throw f}},u;try{u=await c(typeof n=="string"?parseInt(n,10):n)}catch(l){throw console.error=i,l}let k=u.server.address()?.port??u.config?.port??W;return r?.("info","Claude Max proxy running on port ".concat(k)),{port:k,close:async()=>{console.error=i,await u.close()}}}function we(e){if(typeof e!="object"||e===null)return{};let n=e,r={};return typeof n.daysUntilRenewal=="number"&&Number.isFinite(n.daysUntilRenewal)&&(r.daysUntilRenewal=n.daysUntilRenewal),typeof n.renewalRequiredSoon=="boolean"&&(r.renewalRequiredSoon=n.renewalRequiredSoon),r}function he(e){return e===void 0?"soon":e<=0?"today":e===1?"in 1 day":"in ".concat(e," days")}async function V(e,n){try{let r=await fetch(I(e)+"/health",{signal:AbortSignal.timeout(5e3)}),o=await r.json(),t=o.version,a=typeof t=="string"&&t!=="unknown"?t:void 0,d=we(o.auth);if(a&&n?.("info","[claude-max] meridian ".concat(a)),d.renewalRequiredSoon&&n?.("warn","[claude-max] Claude login expires ".concat(he(d.daysUntilRenewal),". Run 'claude login' to renew \u2014 the proxy cannot refresh it for you.")),o.status==="healthy")return{ok:!0,version:a,...d};if(o.status==="degraded"){let s=typeof o.error=="string"?o.error:"Could not verify auth status";return n?.("warn","[claude-max] ".concat(s,". Requests may still work \u2014 if they hang, try running 'claude login' in your terminal.")),{ok:!0,message:s,version:a,...d}}let i=typeof o.error=="string"?o.error:"Proxy health check returned status: ".concat(o.status??r.status);return n?.("error","[claude-max] ".concat(i)),{ok:!1,message:i,version:a,...d}}catch(r){let o=r instanceof Error?r.message:String(r);return n?.("error","[claude-max] Health check failed: ".concat(o)),{ok:!1,message:"Health check failed: ".concat(o)}}}function B(e){let n=!1,r=()=>{n||(n=!0,e.close())};return process.on("exit",r),process.on("SIGINT",r),G||process.on("SIGTERM",r),()=>{process.removeListener("exit",r),process.removeListener("SIGINT",r),G||process.removeListener("SIGTERM",r)}}var X="opencode-with-claude",R="anthropic",ve=4096,p;async function Y(e){for(;p?.closing;)await p.closing;let n=p??={ready:Se(e),users:0};n.users++;let r;try{r=await n.ready}catch(t){throw p===n&&(p=void 0),t}let o=!1;return{baseURL:r.baseURL,release:async()=>{o||(o=!0,!(--n.users>0)&&(n.closing=r.proxy.close().finally(()=>{p===n&&(p=void 0)}),await n.closing))}}}async function Se(e){let n=H(e),r=j(n);r&&e("info",r);let o=process.env.CLAUDE_PROXY_PORT||3456,t=await z({port:o,log:e,profiles:n.profiles,defaultProfile:n.defaultProfile}),a=I(t.port);e("info","proxy ready at ".concat(a));let d=B(t),i=t.close,s;return t.close=()=>(d(),s??=i()),V(t.port,e),{proxy:t,baseURL:a}}function Q(e){let n=e.join("\n\n"),r=xe(n);return r===n?void 0:r}var Le=async({client:e})=>{let n=M(e),r=new Map,o=new Map,t=(d,i)=>"".concat(d,"\0").concat(i),{baseURL:a}=await Y(n);return{async config(d){for(let[s,c]of Object.entries(d.agent??{}))c?.mode&&r.set(s.toLowerCase(),c.mode);let i=d.provider?.anthropic;i&&(i.options||(i.options={}),i.options.baseURL=a)},async"chat.message"(d,i){let s=t(d.sessionID,i.message.id);if(o.delete(s),o.set(s,!0),o.size>ve){let c=o.keys().next().value;c!==void 0&&o.delete(c)}},async"experimental.chat.system.transform"(d,i){if(d.model.providerID!==R)return;let s=Q(i.system);s!==void 0&&i.system.splice(0,i.system.length,s)},async"chat.headers"(d,i){if(d.model.providerID!==R)return;let s=d.agent,c=typeof s=="object"&&s!==null,u=x(c?s.name:s),y={sessionID:d.sessionID,agentName:u,agentMode:v(u,c?s.mode:void 0,r),detached:h.has(u.toLowerCase())};S(i.headers,y),i.headers["x-opencode-request"]=d.message.id,i.headers["x-opencode-request-kind"]=o.has(t(d.sessionID,d.message.id))?"human":"synthetic"}}};function be(e,n){let r=x(e.agent),o=r.toLowerCase(),t=e.kind==="title"||e.kind==="generate"||h.has(o),a=e.kind==="compaction"||o===C;return{sessionID:String(e.sessionID),agentName:r,agentMode:a?"primary":v(r,void 0,n),detached:t,source:a&&!t?"subagent-compaction":void 0}}var Ie=async e=>{let n=D(),r=new Map,o=[],{release:t,baseURL:a}=await Y(n),d="".concat(a,"/v1"),i=async()=>{let s=await Promise.allSettled(o.splice(0).map(u=>u.dispose()));await t();let c=s.flatMap(u=>u.status==="rejected"?[u.reason]:[]);if(c.length>0)throw new AggregateError(c,"".concat(X,": failed to dispose hooks"))};try{o.push(await e.agent.transform(s=>{r.clear();for(let c of s.list())r.set(String(c.id).toLowerCase(),c.mode)})),o.push(await e.session.hook("model.request",s=>{s.baseURL=d,S(s.headers,be(s,r))},{providerID:R}));for(let s of["context","compaction","title","generate"])o.push(await e.session.hook(s,c=>{let u=Q(c.system.map(y=>y.text));u!==void 0&&c.system.splice(0,c.system.length,{type:"text",text:u})},{providerID:R}))}catch(s){throw await i().catch(()=>{}),s}return i},ke={id:X,server:Le,setup:Ie},Je=ke;export{Je as default};
|
package/dist/logger.d.ts
CHANGED
|
@@ -2,9 +2,16 @@ import type { Plugin } from "@opencode-ai/plugin";
|
|
|
2
2
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
3
3
|
export type LogFn = (level: LogLevel, message: string) => Promise<unknown>;
|
|
4
4
|
/**
|
|
5
|
-
* Create a logger bound to the plugin's client.
|
|
5
|
+
* Create a logger bound to the plugin's client (OpenCode v1).
|
|
6
6
|
*/
|
|
7
7
|
export declare function createLogger(client: Parameters<Plugin>[0]["client"]): LogFn;
|
|
8
|
+
/**
|
|
9
|
+
* Create a logger for hosts without a log API (OpenCode v2 gives plugins no
|
|
10
|
+
* `client.app.log`). Lines go to stderr, which OpenCode v2 captures into its
|
|
11
|
+
* own log stream; debug lines are dropped so the proxy's per-request chatter
|
|
12
|
+
* does not flood it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createConsoleLogger(): LogFn;
|
|
8
15
|
/**
|
|
9
16
|
* Classify a proxy log message into a log level.
|
|
10
17
|
*/
|
package/dist/proxy.d.ts
CHANGED
|
@@ -43,4 +43,4 @@ export interface HealthResult {
|
|
|
43
43
|
renewalRequiredSoon?: boolean;
|
|
44
44
|
}
|
|
45
45
|
export declare function checkProxyHealth(port: string | number, log: LogFn | undefined): Promise<HealthResult>;
|
|
46
|
-
export declare function registerCleanup(proxy: ProxyHandle): void;
|
|
46
|
+
export declare function registerCleanup(proxy: ProxyHandle): () => void;
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-with-claude",
|
|
3
3
|
"description": "OpenCode plugin to use your Claude Max subscription via Meridian proxy",
|
|
4
|
-
"version": "1.10.
|
|
4
|
+
"version": "1.10.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@rynfar/meridian": "1.
|
|
9
|
+
"@rynfar/meridian": "1.76.5",
|
|
10
10
|
"@rynfar/meridian-plugin-opencode-scrub": "0.2.0"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@opencode-ai/plugin": "^1.18.3",
|
|
14
|
+
"@opencode/plugin": "2.0.3",
|
|
14
15
|
"@types/node": "^26.1.1",
|
|
15
16
|
"tsup": "^8.5.1",
|
|
16
17
|
"typescript": "^7.0.2"
|