@bojackduy/opencode-system-override 0.1.0 → 0.1.1

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
@@ -77,6 +77,31 @@ RAW_SYSTEM_OVERRIDE_END_9F3A
77
77
  Select the `Raw` agent and chat — the model sees only your text as its
78
78
  system prompt. Leave the space empty for a truly empty system (pure LLM).
79
79
 
80
+ ## Custom agents (any name works)
81
+
82
+ The hook never checks the agent's name — it only looks for the marker pair
83
+ in the assembled system text. So you can give any agent its own override by
84
+ putting the markers in its file:
85
+
86
+ ```md
87
+ ---
88
+ description: Code reviewer with no default baggage
89
+ mode: primary
90
+ permission:
91
+ "*": deny
92
+ ---
93
+
94
+ RAW_SYSTEM_OVERRIDE_START_9F3A
95
+
96
+ You are a senior reviewer. Be terse. Flag only real bugs.
97
+
98
+ RAW_SYSTEM_OVERRIDE_END_9F3A
99
+ ```
100
+
101
+ Each agent carries its own text between the markers; agents without markers
102
+ are completely untouched. The shipped `agents/Raw.md` is just a blank
103
+ starter — copy it to `Pirate.md`, `Reviewer.md`, whatever you like.
104
+
80
105
  ## How it works
81
106
 
82
107
  OpenCode assembles one system array per request. The
@@ -90,6 +115,16 @@ and mutates `output.system` **in place**:
90
115
  | Text between markers | system = that text |
91
116
  | Nothing/whitespace between markers | system = `[]` (pure LLM) |
92
117
 
118
+ A second hook, `chat.headers`, keeps OpenCode Zen free-tier models working.
119
+ Zen only serves anonymous free models to requests carrying the opencode
120
+ client `User-Agent`, and now also requires a stable per-conversation
121
+ `x-opencode-session` header — without them you get `must using opencode
122
+ for opencode free endpoint` / `429 FreeUsageLimitError`. For `opencode`
123
+ provider requests only, the plugin injects `User-Agent: opencode/<version>`
124
+ (when absent or not already `opencode/*`) and `x-opencode-session:
125
+ <sessionID>`, merging in place so other plugins' headers are preserved.
126
+ Override the UA with `OPENCODE_USER_AGENT=opencode/x.y.z` if needed.
127
+
93
128
  ## Dev
94
129
 
95
130
  ```sh
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-system-override",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "description": "Overwrite the OpenCode system prompt per agent — a raw-system hook that replaces the assembled system with the prompt between markers in your agent file.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-or-later",
@@ -15,7 +15,7 @@ const fakeClient = {
15
15
 
16
16
  async function hooks() {
17
17
  logged.length = 0
18
- const server = (pluginModule as unknown as { server: (input: unknown) => Promise<Record<string, (input: unknown, output: { system: string[] }) => Promise<void>>> }).server
18
+ const server = (pluginModule as unknown as { server: (input: unknown) => Promise<Record<string, (input: unknown, output: never) => Promise<void>>> }).server
19
19
  return server({ client: fakeClient } as never)
20
20
  }
21
21
 
@@ -29,6 +29,20 @@ async function transform(system: string[]) {
29
29
  return output.system
30
30
  }
31
31
 
32
+ async function runHeaders(
33
+ input: { sessionID?: string; model?: { providerID: string }; provider?: { info: { id: string } } },
34
+ initial: Record<string, string> = {},
35
+ ) {
36
+ const h = await hooks()
37
+ const output = { headers: { ...initial } }
38
+ const ref = output.headers
39
+ await (
40
+ h["chat.headers"] as (input: unknown, output: { headers: Record<string, string> }) => Promise<void>
41
+ )(input, output as never)
42
+ expect(output.headers).toBe(ref)
43
+ return output.headers
44
+ }
45
+
32
46
  describe("system-override transform", () => {
33
47
  test("leaves non-raw agents untouched (no markers)", async () => {
34
48
  const system = ["default agent prompt", "environment info"]
@@ -58,3 +72,47 @@ describe("system-override transform", () => {
58
72
  expect(logged.join("\n")).toContain("END marker missing")
59
73
  })
60
74
  })
75
+
76
+ describe("chat.headers (Zen free-tier gating)", () => {
77
+ test("injects User-Agent + x-opencode-session for opencode provider", async () => {
78
+ const out = await runHeaders(
79
+ { sessionID: "ses_abc123", model: { providerID: "opencode" } },
80
+ {},
81
+ )
82
+ expect(out["User-Agent"]).toMatch(/^opencode\//)
83
+ expect(out["x-opencode-session"]).toBe("ses_abc123")
84
+ })
85
+
86
+ test("ignores non-opencode providers (no UA spoof, no session leak)", async () => {
87
+ const out = await runHeaders(
88
+ { sessionID: "ses_abc123", model: { providerID: "anthropic" } },
89
+ {},
90
+ )
91
+ expect(out).toEqual({})
92
+ })
93
+
94
+ test("never clobbers an existing opencode/* User-Agent", async () => {
95
+ const out = await runHeaders(
96
+ { sessionID: "ses_xyz", model: { providerID: "opencode" } },
97
+ { "User-Agent": "opencode/9.9.9" },
98
+ )
99
+ expect(out["User-Agent"]).toBe("opencode/9.9.9")
100
+ expect(out["x-opencode-session"]).toBe("ses_xyz")
101
+ })
102
+
103
+ test("repairs a non-opencode User-Agent", async () => {
104
+ const out = await runHeaders(
105
+ { sessionID: "ses_1", model: { providerID: "opencode" } },
106
+ { "User-Agent": "curl/8.0" },
107
+ )
108
+ expect(out["User-Agent"]).toMatch(/^opencode\//)
109
+ })
110
+
111
+ test("preserves an existing session header", async () => {
112
+ const out = await runHeaders(
113
+ { sessionID: "ses_new", model: { providerID: "opencode" } },
114
+ { "x-opencode-session": "ses_old" },
115
+ )
116
+ expect(out["x-opencode-session"]).toBe("ses_old")
117
+ })
118
+ })
package/src/server.ts CHANGED
@@ -24,6 +24,23 @@ const PLUGIN_ID = "opencode-system-override"
24
24
  const START = "RAW_SYSTEM_OVERRIDE_START_9F3A"
25
25
  const END = "RAW_SYSTEM_OVERRIDE_END_9F3A"
26
26
 
27
+ // Zen free-tier gating: anonymous free models at opencode.ai/zen only serve
28
+ // requests carrying the opencode client User-Agent, and the vendor now also
29
+ // requires a stable per-conversation `x-opencode-session` header (missing it
30
+ // surfaces as "must using opencode for opencode free endpoint" / 429
31
+ // FreeUsageLimitError). Inject both via the `chat.headers` hook so free
32
+ // models keep working regardless of which agent (Raw or not) is active.
33
+ //
34
+ // - User-Agent: only set when absent or not already `opencode/*`, so we never
35
+ // clobber the real client UA or another plugin's contribution.
36
+ // - x-opencode-session: stable per conversation → use the opencode sessionID
37
+ // directly (that is exactly the vendor's stated intent).
38
+ // - Scoped to the `opencode` provider only, so we never leak session IDs or
39
+ // spoof UA to third-party providers.
40
+ const OPENCODE_PROVIDER_ID = "opencode"
41
+ const FALLBACK_USER_AGENT = "opencode/1.18.34"
42
+ const SESSION_HEADER = "x-opencode-session"
43
+
27
44
  const server: Plugin = async ({ client }) => {
28
45
  const log = (message: string) => {
29
46
  try {
@@ -34,6 +51,24 @@ const server: Plugin = async ({ client }) => {
34
51
  }
35
52
 
36
53
  return {
54
+ "chat.headers": async (input, output) => {
55
+ // Only touch opencode-provider requests (Zen / Zen-Go free + paid).
56
+ const providerID =
57
+ input.model?.providerID ?? input.provider?.info?.id ?? input.provider?.info?.name
58
+ if (providerID !== OPENCODE_PROVIDER_ID) {
59
+ return
60
+ }
61
+ output.headers ??= {}
62
+ const existingUA =
63
+ output.headers["User-Agent"] ?? output.headers["user-agent"]
64
+ if (!existingUA || !existingUA.startsWith("opencode/")) {
65
+ output.headers["User-Agent"] = process.env.OPENCODE_USER_AGENT ?? FALLBACK_USER_AGENT
66
+ }
67
+ if (input.sessionID && !output.headers[SESSION_HEADER]) {
68
+ output.headers[SESSION_HEADER] = input.sessionID
69
+ }
70
+ },
71
+
37
72
  "experimental.chat.system.transform": async (_input, output) => {
38
73
  // Join defensively in case multiple system blocks exist.
39
74
  const assembled = output.system.join("\n")