dsh-opencode-go-x-session 0.0.0-stage → 1.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pengpenglele
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # dsh-opencode-go-x-session
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Host-only DeepSeek Harness bundle that adds OpenCode Go's required
4
+ `x-opencode-session` request header to the model requests of the configured
5
+ OpenCode provider routes, and nowhere else.
6
+
7
+ ## Why
8
+
9
+ OpenCode Go / Zen requires `x-opencode-session`: a stable identifier for one
10
+ conversation, used for routing and prompt-cache affinity. Since 2026-09-05 the
11
+ gateway rejects inference requests without it:
12
+
13
+ ```
14
+ 400 {"type":"MissingSessionID","message":"... Request is missing x-opencode-session ..."}
15
+ ```
16
+
17
+ Inside DSH two request shapes reach OpenCode without the header:
18
+
19
+ 1. **Auto-review (and other hand-built calls).** pi-ai's catalog provider
20
+ `opencode-go` adds the header from `GenerateOptions.sessionId`, and
21
+ `dsh-llm-pi-ai` forwards that field — so ordinary main-model requests are
22
+ fine. The auto-review reviewer calls `ctx.llm.stream()` with **no**
23
+ `sessionId`, so the catalog provider has nothing to write and the review
24
+ fails closed (`Auto review of tool "..." failed`).
25
+ 2. **Hand-declared OpenCode routes.** A provider key pi-ai's installed catalog
26
+ does not ship, or one that spells out `api`/`baseURL` (such as
27
+ `opencode-go-free-none` / `opencode-go-audit-free-none`), is built by
28
+ `dsh-llm-pi-ai` itself over the plain protocol implementation. That path
29
+ never applies pi-ai's OpenCode session-header wrapper, so even ordinary
30
+ main-model requests lack the header.
31
+
32
+ `llm-pi-ai` has no per-request header hook: its per-route `headers` field is a
33
+ static `Record<string, string>` from config, and the adapter rebuilds pi-ai's
34
+ header object on every call. This plugin bridges the gap at the transport both
35
+ paths actually use, scoped to the configured routes and hosts.
36
+
37
+ It changes no model-visible content: no prompt text, no session event, no
38
+ transcript entry.
39
+
40
+ ## How it works
41
+
42
+ 1. It listens on the supported `llm/stream` waterfall. When the request's
43
+ provider is one of the configured routes, it resolves the conversation id in
44
+ this order: the request's own `sessionId`; the enclosing model-call scope
45
+ (nested calls); the initiator Agent's session through
46
+ `ctx.agents.currentInitiator()` — this is what gives an auto-review request,
47
+ which runs inside the reviewed Agent's driver chain, the conversation id it
48
+ is reviewing; finally the configured stable `fallbackSessionId`. The
49
+ downstream chunk stream is wrapped so every pull runs inside an
50
+ `AsyncLocalStorage` scope carrying that id.
51
+ 2. It wraps `globalThis.fetch` (the `openai` SDK's default transport, resolved
52
+ per request when `options.fetch` is not set). Inside a captured scope the
53
+ wrapper adds the header when it is absent and the URL host is allowlisted;
54
+ every other request gets the original arguments untouched, and the wrapper is
55
+ removed when the plugin unloads or is switched off.
56
+
57
+ The value is the Harness session id (`GenerateOptions.sessionId`), so it is
58
+ stable across the turns of one session and distinct between concurrent
59
+ sessions. Requests for any other provider never enter the scope, so built-in
60
+ routes and every other model configuration behave exactly as before.
61
+
62
+ ## Config
63
+
64
+ All fields are optional; every field is `volatile()`, so this table is also the
65
+ plugin's settings page.
66
+
67
+ | Field | Default | Meaning |
68
+ |---|---|---|
69
+ | `enabled` | `true` | Master switch (the unified plugin switch); `false` removes the fetch patch immediately and leaves every request untouched |
70
+ | `providers` | `['opencode-go*']` | Provider routes that receive the header; an entry may be a trailing-`*` prefix pattern. The default pattern covers the `opencode-go` catalog route and every `opencode-go-*` variant, so a new variant needs no config change; an empty list disables injection |
71
+ | `hosts` | `['opencode.ai']` | Host allowlist, each entry also covering its subdomains; an empty list removes the host check (for a proxy endpoint) |
72
+ | `fallbackSessionId` | `dsh-auto-review` | Stable id used only when no request, scope, or initiator session id is available |
73
+ | `debug` | `false` | Log each injection through the Host logger |
74
+
75
+ Edit them in the plugin's settings page (Settings → Plugins), or override the
76
+ inserted row in the profile's `cordis.patch.yml`:
77
+
78
+ ```yaml
79
+ - id: opencode-session-header
80
+ config:
81
+ enabled: true
82
+ providers:
83
+ - 'opencode-go*'
84
+ hosts:
85
+ - opencode.ai
86
+ fallbackSessionId: dsh-auto-review
87
+ debug: false
88
+ ```
89
+
90
+ ## Install
91
+
92
+ From npm (recommended):
93
+
94
+ ```sh
95
+ dsh plugin --profile desktop add dsh-opencode-go-x-session
96
+ ```
97
+
98
+ From this repository — clone it, then install the directory as a bundle through
99
+ the Harness plugin manager (Web: Plugins → Install bundle; tool:
100
+ `install_bundle` with this directory's absolute path as the target):
101
+
102
+ ```sh
103
+ git clone https://github.com/pengpenglele/dsh-opencode-go-x-session-code.git
104
+ ```
105
+
106
+ Either way the bundle patch inserts one Host row, `opencode-session-header`; the
107
+ change applies to every session in the profile.
108
+
109
+ For a `link:`/local installation the package directory needs its own
110
+ `node_modules` for the `schemastery` dependency (`pnpm install
111
+ --ignore-workspace` in this directory), because a `link:` installation resolves
112
+ the plugin's dependencies from its real path.
113
+
114
+ ## Turning it off (three equivalent ways)
115
+
116
+ - **Settings switch** — set `enabled: false` on the plugin's settings page; the
117
+ fetch patch is removed at the next settings commit and every request passes
118
+ through untouched. No restart.
119
+ - **Plugin row** — disable the `opencode-session-header` row in the Plugin
120
+ Manager; the whole fiber unloads and the original `fetch` is restored.
121
+ - **Profile patch** — add `- id: opencode-session-header` with
122
+ `disabled: true` under your patch layer.
123
+
124
+ ## Verify
125
+
126
+ - Plugin inventory shows **OpenCode Session Header / OpenCode 会话头注入** with
127
+ this icon and the description above (en/zh).
128
+ - The plugin settings page shows the five fields above with their defaults.
129
+ - `npm test` (plain Node, no Host) asserts against a mocked `fetch`:
130
+ injection for the default `opencode-go*` pattern (the catalog route and every
131
+ declared variant) with the request's own session id; the
132
+ auto-review shape (no `sessionId`) resolving through `currentInitiator`; the
133
+ stable fallback when no context exists; existing headers, other providers
134
+ (including the sibling `opencode` route), other hosts, and the disabled
135
+ switch all passing through untouched; concurrent session isolation; live
136
+ switching; fetch restoration on dispose.
137
+ - In the Host, activate the row and confirm the log line
138
+ `opencode-session-header: active (providers ..., hosts ...)`; with
139
+ `debug: true` each injected request logs
140
+ `[opencode-session-header] x-opencode-session: <id> -> <url>`. A live
141
+ provider round-trip cannot be proven from inside the plugin's own tests.
142
+
143
+ ## Notes and limits
144
+
145
+ - The wrapper is installed process-wide but inert outside a captured stream
146
+ scope; it is reference-counted so HMR reloads and concurrent activations share
147
+ one patch, and it is removed when the last activation unloads.
148
+ - It relies on the `openai` SDK defaulting to `globalThis.fetch` per request,
149
+ which the installed SDK does. If a future SDK pins a transport instance at
150
+ import time, the header would not be added and the route would fail as before.
151
+ - Model discovery (`llm.discoverModels`) is a plain fetch outside a model call
152
+ and is left untouched.
153
+ - The routes still need their own credential (`apiKeyEnv`) and `baseURL`; this
154
+ plugin only adds the header.
package/README.zh.md ADDED
@@ -0,0 +1,124 @@
1
+ # dsh-opencode-go-x-session(OpenCode 会话头注入)
2
+
3
+ Host-only 的 DeepSeek Harness 插件:为配置的 OpenCode provider 路由上的模型请求补上
4
+ OpenCode Go 必需的 `x-opencode-session` 头,其他路由、其他主机、其他 provider 一概不碰。
5
+
6
+ ## 为什么需要
7
+
8
+ OpenCode Go / Zen 要求每个推理请求携带 `x-opencode-session`(一条会话一个稳定标识,
9
+ 用于路由与提示词缓存亲和)。自 2026-09-05 起,缺这个头的请求会被拒绝:
10
+
11
+ ```
12
+ 400 {"type":"MissingSessionID","message":"... Request is missing x-opencode-session ..."}
13
+ ```
14
+
15
+ 在 DSH 里,有两类请求到达 OpenCode 时没有这个头:
16
+
17
+ 1. **自动审查(auto-review)及其他手写调用。** pi-ai 的目录 provider `opencode-go` 会从
18
+ `GenerateOptions.sessionId` 生成该头,`dsh-llm-pi-ai` 也会转发该字段,所以普通主模型
19
+ 请求没问题。但自动审查的审查器调用 `ctx.llm.stream()` 时**不传** `sessionId`,目录
20
+ provider 没有值可写,于是审查请求 400、自动审查失败(`Auto review of tool "..." failed`)。
21
+ 2. **手写的 OpenCode 路由。** pi-ai 目录里没有的 provider key,或者写死 `api`/`baseURL`
22
+ 的路由(例如 `opencode-go-free-none` / `opencode-go-audit-free-none`),由
23
+ `dsh-llm-pi-ai` 自己基于裸协议实现构建,不会套用 pi-ai 的 OpenCode 会话头包装,因此
24
+ 连普通主模型请求也缺头。
25
+
26
+ `llm-pi-ai` 没有按请求改头的入口:provider 的 `headers` 字段是配置里的静态字符串表,
27
+ 且适配器每次请求都会重建 pi-ai 的头对象。本插件在两条路径实际都经过的传输层补上这个头,
28
+ 并把作用范围限制在配置的路由与主机内。
29
+
30
+ 插件不改任何模型可见内容:不增提示词、不写会话事件、不改对话记录。
31
+
32
+ ## 工作原理
33
+
34
+ 1. 监听官方支持的 `llm/stream` 瀑布。请求的 provider 命中 `providers` 时,按以下顺序确定
35
+ 会话 id:请求自身的 `sessionId`;外层模型调用作用域(嵌套调用);通过
36
+ `ctx.agents.currentInitiator()` 取发起 Agent 的会话 id —— 自动审查的请求跑在被审查
37
+ Agent 的驱动链内,正是靠这一步拿到被审查会话的 id;最后才用配置的稳定
38
+ `fallbackSessionId`。随后把下游 chunk 流的每次拉取都放进携带该 id 的
39
+ `AsyncLocalStorage` 作用域。
40
+ 2. 包装 `globalThis.fetch`(`openai` SDK 在未传 `options.fetch` 时按请求解析的默认传输)。
41
+ 只有当上面那种作用域生效、且 URL 主机命中 `hosts`、且请求尚无该头时才加头;其余请求
42
+ 原参数放行。插件卸载或开关关闭时恢复原始 `fetch`。
43
+
44
+ 头里的值是 Harness 会话 id(`GenerateOptions.sessionId`):同一会话跨轮次稳定、并发
45
+ 会话之间互不相同。其他 provider 的请求根本不进入作用域,因此内置路由和所有其他模型配置
46
+ 行为完全不变。
47
+
48
+ ## 配置项
49
+
50
+ 全部可选;每个字段都是 `volatile()`,这张表同时就是插件设置页。
51
+
52
+ | 字段 | 默认值 | 含义 |
53
+ |---|---|---|
54
+ | `enabled` | `true` | 总开关(统一插件开关);`false` 立即移除 fetch 补丁,所有请求原样放行 |
55
+ | `providers` | `['opencode-go*']` | 需要补头的 provider 路由;条目支持尾部 `*` 前缀匹配。默认一条 `opencode-go*`,覆盖目录路由 `opencode-go` 与所有 `opencode-go-*` 变体(新增变体无需改配置);空数组 = 不生效 |
56
+ | `hosts` | `['opencode.ai']` | 主机白名单(各条目含其子域);空数组 = 不检查主机(路由指向代理端点时用) |
57
+ | `fallbackSessionId` | `dsh-auto-review` | 请求、作用域、发起会话都拿不到会话 id 时使用的稳定兜底 id |
58
+ | `debug` | `false` | 通过 Host logger 记录每次注入 |
59
+
60
+ 可以在插件设置页(设置 → 插件)里改,也可以覆盖 profile `cordis.patch.yml` 中插入的行:
61
+
62
+ ```yaml
63
+ - id: opencode-session-header
64
+ config:
65
+ enabled: true
66
+ providers:
67
+ - 'opencode-go*'
68
+ hosts:
69
+ - opencode.ai
70
+ fallbackSessionId: dsh-auto-review
71
+ debug: false
72
+ ```
73
+
74
+ ## 安装
75
+
76
+ 从 npm 安装(推荐):
77
+
78
+ ```sh
79
+ dsh plugin --profile desktop add dsh-opencode-go-x-session
80
+ ```
81
+
82
+ 或从本仓库安装:先 clone,再用 Harness 插件管理器安装本目录(Web:插件 → 安装 bundle;
83
+ 工具:`install_bundle`,target 为本目录绝对路径)。
84
+
85
+ ```sh
86
+ git clone https://github.com/pengpenglele/dsh-opencode-go-x-session-code.git
87
+ ```
88
+
89
+ 两种方式都会由 bundle patch 插入一行 Host 插件 `opencode-session-header`,对 profile
90
+ 下所有会话生效。
91
+
92
+ 按 `link:` / 本地目录安装时,插件依赖从本目录真实路径解析;先在本目录执行
93
+ `pnpm install --ignore-workspace` 安装 `schemastery` 依赖。
94
+
95
+ ## 关闭插件(三种等价方式)
96
+
97
+ - **设置页开关**:把 `enabled` 设为 `false`,下一次设置提交即移除 fetch 补丁,所有请求
98
+ 原样放行,无需重启。
99
+ - **插件行开关**:在插件管理器里禁用 `opencode-session-header` 行,整个插件卸载并恢复
100
+ 原始 `fetch`。
101
+ - **profile patch**:在补丁层给 `- id: opencode-session-header` 加 `disabled: true`。
102
+
103
+ ## 验证
104
+
105
+ - 插件清单显示 **OpenCode Session Header / OpenCode 会话头注入** 及本图标与描述(en/zh)。
106
+ - 插件设置页显示上表五个字段及默认值。
107
+ - `npm test`(纯 Node,不需要 Host)用 mock 的 `fetch` 断言:默认 `opencode-go*` 覆盖
108
+ 目录路由与所有 `opencode-go-*` 变体(各按请求自身会话 id 注入);自动审查形态(无
109
+ `sessionId`)经 `currentInitiator` 取到会话 id;无上下文时用稳定兜底 id;已有头、
110
+ 其他 provider(含同前缀以外的 `opencode`)、其他主机、关闭开关时全部原样放行;并发
111
+ 会话隔离;实时开关;卸载后恢复 `fetch`。
112
+ - Host 内启用后确认日志
113
+ `opencode-session-header: active (providers ..., hosts ...)`;`debug: true` 时每次注入
114
+ 记录 `[opencode-session-header] x-opencode-session: <id> -> <url>`。插件自身测试无法
115
+ 证明真实 provider 往返。
116
+
117
+ ## 说明与限制
118
+
119
+ - 包装器安装在进程级,但在作用域外完全惰性;按引用计数共享一份补丁,HMR 重载与并发激活
120
+ 不会重复包装,最后一个激活卸载时恢复原始 `fetch`。
121
+ - 依赖 `openai` SDK 每次请求解析 `globalThis.fetch`(当前安装版本如此)。若未来 SDK 在
122
+ 导入时固定传输实例,头就不会被补上,症状与修复前相同。
123
+ - 模型发现(`llm.discoverModels`)是模型调用之外的普通 fetch,不在本插件作用范围。
124
+ - 路由自身的凭据(`apiKeyEnv`)与 `baseURL` 仍由路由配置负责;本插件只补头。
@@ -0,0 +1,21 @@
1
+ # Mount the OpenCode session-header plugin on the Host plane.
2
+ #
3
+ # This row is the plugin's identity and its default settings; the same fields
4
+ # are served as the plugin's settings page (`enabled` is the unified switch).
5
+ # Only the routes named in `providers` are ever touched, and only requests to
6
+ # the hosts named in `hosts` can receive the header.
7
+ #
8
+ # `opencode-go*` is one trailing-`*` prefix pattern: it covers the `opencode-go`
9
+ # catalog route and every hand-declared `opencode-go-*` variant, so a newly
10
+ # added variant needs no config change here.
11
+ - insert:
12
+ - id: opencode-session-header
13
+ name: 'dsh-opencode-go-x-session'
14
+ config:
15
+ enabled: true
16
+ providers:
17
+ - 'opencode-go*'
18
+ hosts:
19
+ - opencode.ai
20
+ fallbackSessionId: dsh-auto-review
21
+ debug: false
package/icon.svg ADDED
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="OpenCode session header">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#1b2430"/>
5
+ <stop offset="1" stop-color="#0d1117"/>
6
+ </linearGradient>
7
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
8
+ <stop offset="0" stop-color="#38bdf8"/>
9
+ <stop offset="1" stop-color="#22c55e"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="url(#bg)"/>
13
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="none" stroke="#2b3a4b" stroke-width="2"/>
14
+ <g stroke-linecap="round">
15
+ <line x1="14" y1="20" x2="34" y2="20" stroke="#3f4f63" stroke-width="4"/>
16
+ <line x1="14" y1="32" x2="50" y2="32" stroke="url(#accent)" stroke-width="4"/>
17
+ <line x1="14" y1="44" x2="26" y2="44" stroke="#3f4f63" stroke-width="4"/>
18
+ </g>
19
+ <circle cx="44" cy="44" r="7" fill="none" stroke="url(#accent)" stroke-width="3"/>
20
+ <circle cx="44" cy="44" r="2.4" fill="#22c55e"/>
21
+ </svg>
package/index.js ADDED
@@ -0,0 +1,468 @@
1
+ /**
2
+ * dsh-opencode-go-x-session — Host-only Harness plugin.
3
+ *
4
+ * OpenCode Go / Zen requires the `x-opencode-session` request header: a stable
5
+ * identifier for one conversation, used for routing and prompt-cache affinity.
6
+ * Since 2026-09-05 the gateway answers inference requests that lack it with
7
+ * `400 {"type":"MissingSessionID"}`.
8
+ *
9
+ * Inside DSH two request shapes reach OpenCode without the header:
10
+ *
11
+ * 1. **Auto-review / other hand-built calls.** pi-ai's catalog provider
12
+ * (`opencode-go`) adds the header from `GenerateOptions.sessionId`, and
13
+ * `dsh-llm-pi-ai` forwards that field. The auto-review reviewer calls
14
+ * `ctx.llm.stream()` with no `sessionId`, so the header is missing and the
15
+ * review fails closed (`Auto review of tool "..." failed`).
16
+ * 2. **Hand-declared OpenCode routes.** A route such as
17
+ * `opencode-go-audit-free-none` — a provider key pi-ai's installed catalog
18
+ * does not ship, or one that spells out `api`/`baseURL` — is built by
19
+ * `dsh-llm-pi-ai` itself over the plain protocol implementation, which never
20
+ * applies pi-ai's OpenCode session-header wrapper. Even its ordinary
21
+ * main-model requests lack the header.
22
+ *
23
+ * This plugin adds that missing header, and only for the provider routes named
24
+ * in its config and the request hosts named in its config. It does not touch
25
+ * pi-ai, the adapter configuration, any other provider's requests, or any
26
+ * model-visible content (no prompt text, no session event).
27
+ *
28
+ * How it works, through the weakest extension points that suffice:
29
+ *
30
+ * 1. The supported `llm/stream` waterfall captures the conversation id of a
31
+ * request whose provider is configured. Resolution order: the request's own
32
+ * `sessionId`; the enclosing scope (nested model calls); the initiator
33
+ * Agent's session through `ctx.agents.currentInitiator()` — this is what
34
+ * gives an auto-review call, which runs inside the reviewed Agent's driver
35
+ * chain, its conversation's id; finally the configured stable fallback.
36
+ * The downstream chunk stream is wrapped so every pull runs inside an
37
+ * `AsyncLocalStorage` scope carrying that id.
38
+ * 2. One transparent `globalThis.fetch` wrapper (the openai SDK's default
39
+ * transport, resolved per request when `options.fetch` is not set) adds the
40
+ * header while such a scope is active. Outside a scope, on another host, or
41
+ * when the header is already present, the request is forwarded unchanged.
42
+ * The wrapper is reference-counted, so HMR reloads and concurrent
43
+ * activations share exactly one patch and the original transport returns
44
+ * once the last activation unloads.
45
+ *
46
+ * Config fields (all optional, `enabled` is the live switch):
47
+ * enabled boolean master switch (default true)
48
+ * providers string[] provider routes to serve; entries may be exact
49
+ * ids or trailing-`*` prefix patterns
50
+ * (default: opencode-go*, the catalog route plus
51
+ * every opencode-go-* variant)
52
+ * hosts string[] host allowlist, each also covering subdomains
53
+ * (default: opencode.ai); empty = no host check
54
+ * fallbackSessionId string stable id for calls that carry no session
55
+ * anywhere (default: dsh-auto-review)
56
+ * debug boolean log every injection (default: false)
57
+ *
58
+ * @module dsh-opencode-go-x-session
59
+ */
60
+
61
+ import { AsyncLocalStorage } from 'node:async_hooks'
62
+ import z from '@deepseek-ai/schemastery'
63
+
64
+ /** Stable Cordis plugin name used by Loader diagnostics. */
65
+ export const name = 'opencode-session-header'
66
+
67
+ /** The `llm` service must exist before the `llm/stream` waterfall can fire. */
68
+ export const inject = ['llm']
69
+
70
+ /** The request header OpenCode Go requires. */
71
+ export const SESSION_HEADER = 'x-opencode-session'
72
+
73
+ /** Global key so a reloaded module generation reuses one fetch patch and one scope store. */
74
+ const SHARED_STATE = Symbol.for('dsh.opencode-session-header.state')
75
+
76
+ /**
77
+ * Provider routes that speak the OpenCode protocol and therefore need the
78
+ * header. One trailing-`*` prefix pattern covers the catalog route and every
79
+ * hand-declared variant, so a new `opencode-go-*` route is served without
80
+ * touching this plugin. The dash before `*` is deliberately absent: the plain
81
+ * catalog route is `opencode-go`, which `opencode-go-*` would not match.
82
+ */
83
+ export const DEFAULT_PROVIDERS = Object.freeze(['opencode-go*'])
84
+
85
+ /** Hosts that may receive the header. An empty list means "no host check". */
86
+ export const DEFAULT_HOSTS = Object.freeze(['opencode.ai'])
87
+
88
+ /** Stable id for calls whose conversation cannot be identified. */
89
+ export const DEFAULT_FALLBACK_SESSION_ID = 'dsh-auto-review'
90
+
91
+ /**
92
+ * Plugin config. Every field is `volatile()`: the Host serves exactly these as
93
+ * this plugin's settings page (the unified plugin switch is `enabled`) and
94
+ * commits a settings write into the running activation without a remount.
95
+ */
96
+ export const Config = z.object({
97
+ /** Master switch: false removes the fetch patch and stops all injection. */
98
+ enabled: z.boolean().default(true).volatile(),
99
+ /**
100
+ * Provider routes eligible for the header. An entry is an exact route id, or
101
+ * a trailing-`*` pattern (`"opencode-go*"`) matching a route-id prefix. The
102
+ * default is one pattern — `opencode-go*` — so the catalog route and every
103
+ * hand-declared `opencode-go-*` variant are covered without a list to keep.
104
+ */
105
+ providers: z.array(z.string()).default([...DEFAULT_PROVIDERS]).volatile(),
106
+ /**
107
+ * Host allowlist (host itself or any subdomain). Defaults to `opencode.ai`;
108
+ * an empty array removes the check, for routes pointed at a proxy endpoint.
109
+ */
110
+ hosts: z.array(z.string()).default([...DEFAULT_HOSTS]).volatile(),
111
+ /** Session id used only when neither the request nor a context carries one. */
112
+ fallbackSessionId: z.string().default(DEFAULT_FALLBACK_SESSION_ID).volatile(),
113
+ /** Log every injected request URL and session id (diagnostics). */
114
+ debug: z.boolean().default(false).volatile(),
115
+ })
116
+
117
+ /** Schema defaults for a bare mount (profile patch or test context). */
118
+ export const DEFAULT_CONFIG = Object.freeze({
119
+ enabled: true,
120
+ providers: [...DEFAULT_PROVIDERS],
121
+ hosts: [...DEFAULT_HOSTS],
122
+ fallbackSessionId: DEFAULT_FALLBACK_SESSION_ID,
123
+ debug: false,
124
+ })
125
+
126
+ /**
127
+ * Read one activation field. The Loader hands volatile fields over as
128
+ * references, so a read goes through `get()`; a plain value (profile patch,
129
+ * test context) passes through, and an absent field falls back to its default.
130
+ * @param field - the config field as the Host handed it over.
131
+ * @param fallback - the schema default.
132
+ * @returns the effective value.
133
+ */
134
+ function readField(field, fallback) {
135
+ if (field === undefined || field === null) return fallback
136
+ if (typeof field === 'object' && typeof field.get === 'function') {
137
+ const value = field.get()
138
+ return value === undefined ? fallback : value
139
+ }
140
+ return field
141
+ }
142
+
143
+ /**
144
+ * Normalize a configured string list. A non-array (or absent) value falls back
145
+ * to the default; an explicitly empty array is kept, because it means
146
+ * "nothing"/"no host check" and must not silently restore a default.
147
+ * @param value - raw config value.
148
+ * @param fallback - schema default list.
149
+ * @returns a clean string list.
150
+ */
151
+ function readStringList(value, fallback) {
152
+ if (!Array.isArray(value)) return [...fallback]
153
+ return value
154
+ .filter((entry) => typeof entry === 'string')
155
+ .map((entry) => entry.trim())
156
+ .filter((entry) => entry.length > 0)
157
+ }
158
+
159
+ /**
160
+ * Resolve the effective settings of this activation. Called at every use site,
161
+ * so a settings write or a profile-patch edit applies to the next request.
162
+ * @param config - the config the Host passed to the activation.
163
+ * @returns every field resolved against the schema defaults.
164
+ */
165
+ export function resolveConfig(config) {
166
+ return {
167
+ enabled: readField(config?.enabled, DEFAULT_CONFIG.enabled) !== false,
168
+ providers: readStringList(readField(config?.providers, DEFAULT_CONFIG.providers), DEFAULT_PROVIDERS),
169
+ hosts: readStringList(readField(config?.hosts, DEFAULT_CONFIG.hosts), DEFAULT_HOSTS).map((host) =>
170
+ host.toLowerCase().replace(/^\*?\./, ''),
171
+ ),
172
+ fallbackSessionId: (() => {
173
+ const value = readField(config?.fallbackSessionId, DEFAULT_CONFIG.fallbackSessionId)
174
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : DEFAULT_FALLBACK_SESSION_ID
175
+ })(),
176
+ debug: readField(config?.debug, DEFAULT_CONFIG.debug) === true,
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Whether one provider route is covered by the configured patterns.
182
+ * @param provider - the route the request selects.
183
+ * @param patterns - exact route ids and/or trailing-`*` prefix patterns.
184
+ * @returns true when the route is covered.
185
+ */
186
+ export function matchesProvider(provider, patterns) {
187
+ if (typeof provider !== 'string' || provider.length === 0) return false
188
+ return patterns.some(
189
+ (pattern) => pattern === provider || (pattern.endsWith('*') && provider.startsWith(pattern.slice(0, -1))),
190
+ )
191
+ }
192
+
193
+ /**
194
+ * Whether one request URL's host is on the allowlist. An empty list allows
195
+ * every host, because the provider match already scopes the injection.
196
+ * @param url - the request URL.
197
+ * @param hosts - lowercased host names; each also covers its subdomains.
198
+ * @returns true when the URL may receive the header.
199
+ */
200
+ export function matchesHost(url, hosts) {
201
+ if (hosts.length === 0) return true
202
+ let hostname
203
+ try {
204
+ hostname = new URL(url).hostname.toLowerCase()
205
+ } catch {
206
+ return false
207
+ }
208
+ return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`))
209
+ }
210
+
211
+ /**
212
+ * The process-wide transport state shared by every plugin generation.
213
+ * @returns the shared state, creating it on first use.
214
+ */
215
+ function sharedState() {
216
+ const holder = globalThis
217
+ if (holder[SHARED_STATE] === undefined) {
218
+ holder[SHARED_STATE] = {
219
+ /** Scope of the model request the current async chain belongs to. */
220
+ storage: new AsyncLocalStorage(),
221
+ /** Live activations that installed the middleware. */
222
+ refs: 0,
223
+ original: undefined,
224
+ patched: undefined,
225
+ }
226
+ }
227
+ return holder[SHARED_STATE]
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Session-id resolution
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * Read the session id of the Agent whose driver chain is running this call.
236
+ * Auto-review is exactly such a call: it is built inside the reviewed Agent's
237
+ * tool execution and carries no `sessionId` of its own.
238
+ * @param ctx - the plugin context.
239
+ * @returns the initiator session id, or `undefined` when none is available.
240
+ */
241
+ function initiatorSessionId(ctx) {
242
+ try {
243
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined
244
+ if (agents === undefined || agents === null) return undefined
245
+ const initiator = typeof agents.currentInitiator === 'function' ? agents.currentInitiator() : undefined
246
+ const id = initiator?.session?.id
247
+ if (typeof id === 'string' && id.length > 0) return id
248
+ const headerId = initiator?.session?.header?.id
249
+ return typeof headerId === 'string' && headerId.length > 0 ? headerId : undefined
250
+ } catch {
251
+ // No agents service, a disposed service, or no initiator boundary.
252
+ return undefined
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Pick the stable per-conversation id for one model call.
258
+ * @param ctx - the plugin context (initiator fallback).
259
+ * @param options - resolved plugin settings.
260
+ * @param request - the `llm/stream` request options.
261
+ * @param inherited - the enclosing scope, when one model call nests inside another.
262
+ * @returns the id to send, or `undefined` to leave the call untouched.
263
+ */
264
+ function resolveSessionId(ctx, options, request, inherited) {
265
+ const direct = request?.sessionId
266
+ if (direct !== undefined && direct !== null) {
267
+ const text = String(direct)
268
+ if (text.length > 0) return text
269
+ }
270
+ if (inherited !== undefined && typeof inherited.sessionId === 'string' && inherited.sessionId.length > 0) {
271
+ return inherited.sessionId
272
+ }
273
+ return initiatorSessionId(ctx) ?? options.fallbackSessionId
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Stream wrapping
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Re-enter the captured scope for every pull of the downstream chunk stream, so
282
+ * the fetch calls that produce those chunks see the session id. The inner
283
+ * iterator is created inside the scope as well, which covers transports that
284
+ * start their request at iteration time.
285
+ * @param produced - whatever the next waterfall listener returned.
286
+ * @param store - the scope value for this call.
287
+ * @param storage - the shared AsyncLocalStorage.
288
+ * @returns an iterable that behaves like `produced`.
289
+ */
290
+ function wrapIterable(produced, store, storage) {
291
+ if (produced === null || produced === undefined) return produced
292
+ if (typeof produced[Symbol.asyncIterator] !== 'function') return produced
293
+ const iterator = produced[Symbol.asyncIterator]()
294
+ const bind = (method) => {
295
+ const fn = iterator[method]
296
+ return typeof fn === 'function' ? (...args) => storage.run(store, () => fn.apply(iterator, args)) : undefined
297
+ }
298
+ const wrapped = {
299
+ [Symbol.asyncIterator]() {
300
+ return this
301
+ },
302
+ next: bind('next'),
303
+ }
304
+ const returnMethod = bind('return')
305
+ if (returnMethod !== undefined) wrapped.return = returnMethod
306
+ const throwMethod = bind('throw')
307
+ if (throwMethod !== undefined) wrapped.throw = throwMethod
308
+ return wrapped
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Fetch interception
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /** Extract the target URL of a `fetch` call without consuming the input. */
316
+ export function requestUrl(input) {
317
+ if (typeof input === 'string') return input
318
+ if (typeof URL !== 'undefined' && input instanceof URL) return input.href
319
+ if (input !== null && typeof input === 'object' && typeof input.url === 'string') return input.url
320
+ return undefined
321
+ }
322
+
323
+ /**
324
+ * Build the fetch call that carries the session header, or `undefined` when the
325
+ * request must be forwarded unchanged.
326
+ * @param input - fetch's first argument (URL string, URL, or Request).
327
+ * @param init - fetch's second argument.
328
+ * @param store - the active scope.
329
+ * @returns the arguments to pass to the real fetch, or undefined to forward.
330
+ */
331
+ export function addSessionHeader(input, init, store) {
332
+ const url = requestUrl(input)
333
+ if (url === undefined || !matchesHost(url, store.hosts)) return undefined
334
+ const isRequest = typeof Request !== 'undefined' && input instanceof Request
335
+ // Reproduce fetch's own precedence: init.headers replaces a Request's headers
336
+ // entirely; without init.headers the Request's headers are the base.
337
+ const headers =
338
+ init?.headers !== undefined
339
+ ? new Headers(init.headers)
340
+ : new Headers(isRequest ? input.headers : undefined)
341
+ if (headers.has(store.headerName)) return undefined
342
+ headers.set(store.headerName, store.sessionId)
343
+ if (isRequest) return [new Request(input, { ...(init ?? {}), headers })]
344
+ return [input, { ...(init ?? {}), headers }]
345
+ }
346
+
347
+ /**
348
+ * Install the transparent fetch wrapper once and count live owners.
349
+ * @param state - the shared state.
350
+ * @returns the release function for this owner.
351
+ */
352
+ function acquireFetchPatch(state) {
353
+ if (typeof globalThis.fetch !== 'function') {
354
+ throw new Error('dsh-opencode-go-x-session: globalThis.fetch is unavailable')
355
+ }
356
+ if (state.refs === 0) {
357
+ state.original = globalThis.fetch
358
+ state.patched = createPatchedFetch(state.original, state.storage)
359
+ globalThis.fetch = state.patched
360
+ }
361
+ state.refs += 1
362
+ let released = false
363
+ return () => {
364
+ if (released) return
365
+ released = true
366
+ state.refs -= 1
367
+ if (state.refs > 0) return
368
+ state.refs = 0
369
+ if (globalThis.fetch === state.patched) globalThis.fetch = state.original
370
+ state.patched = undefined
371
+ state.original = undefined
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Build the fetch wrapper. It only changes a request while a scope created by
377
+ * this plugin is active, so every other caller sees the original transport.
378
+ * @param originalFetch - the transport captured at install time.
379
+ * @param storage - the shared AsyncLocalStorage.
380
+ * @returns the wrapper.
381
+ */
382
+ function createPatchedFetch(originalFetch, storage) {
383
+ return function fetchWithOpenCodeSessionHeader(input, init) {
384
+ let store
385
+ try {
386
+ store = storage.getStore()
387
+ } catch {
388
+ store = undefined
389
+ }
390
+ if (store === undefined || store === null) return Reflect.apply(originalFetch, this, [input, init])
391
+ let rewritten
392
+ try {
393
+ rewritten = addSessionHeader(input, init, store)
394
+ } catch {
395
+ // Header bookkeeping must never break a model call.
396
+ return Reflect.apply(originalFetch, this, [input, init])
397
+ }
398
+ if (rewritten === undefined) return Reflect.apply(originalFetch, this, [input, init])
399
+ store.log?.(`${store.headerName}: ${store.sessionId} -> ${String(requestUrl(input) ?? '?')}`)
400
+ return Reflect.apply(originalFetch, this, rewritten)
401
+ }
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Mount
406
+ // ---------------------------------------------------------------------------
407
+
408
+ /**
409
+ * Mount the plugin: keep one fetch patch in step with the effective config and
410
+ * scope every covered model call.
411
+ * @param ctx - the plugin context (carries `llm`).
412
+ * @param config - the row config, with volatile fields as references.
413
+ */
414
+ export function apply(ctx, config) {
415
+ const state = sharedState()
416
+ const settings = () => resolveConfig(config)
417
+ let release
418
+
419
+ /** Install the fetch patch while enabled, remove it while disabled. */
420
+ const sync = () => {
421
+ const current = settings()
422
+ if (current.enabled && release === undefined) {
423
+ release = acquireFetchPatch(state)
424
+ ctx.logger?.info?.(
425
+ `opencode-session-header: active (providers ${current.providers.join(', ')}, hosts ${current.hosts.join(', ') || '*'})`,
426
+ )
427
+ } else if (!current.enabled && release !== undefined) {
428
+ release()
429
+ release = undefined
430
+ ctx.logger?.info?.('opencode-session-header: disabled by config; requests pass through')
431
+ }
432
+ }
433
+
434
+ ctx.effect(() => {
435
+ sync()
436
+ return () => {
437
+ release?.()
438
+ release = undefined
439
+ }
440
+ }, 'opencode-session-header: fetch transport')
441
+
442
+ // Volatile settings writes commit in place; re-evaluate the switch.
443
+ ctx.on('loader/volatile-update', () => sync())
444
+
445
+ ctx.on('llm/stream', (request, next) => {
446
+ if (request === null || typeof request !== 'object') return next()
447
+ const current = settings()
448
+ if (!current.enabled) return next()
449
+ if (!matchesProvider(request.provider, current.providers)) return next()
450
+ let inherited
451
+ try {
452
+ inherited = state.storage.getStore()
453
+ } catch {
454
+ inherited = undefined
455
+ }
456
+ const sessionId = resolveSessionId(ctx, current, request, inherited)
457
+ if (sessionId === undefined) return next()
458
+ const store = {
459
+ sessionId,
460
+ headerName: SESSION_HEADER,
461
+ hosts: current.hosts,
462
+ provider: request.provider,
463
+ log: current.debug ? (message) => ctx.logger?.debug?.('[opencode-session-header] ' + message) : undefined,
464
+ }
465
+ const produced = state.storage.run(store, () => next())
466
+ return wrapIterable(produced, store, state.storage)
467
+ })
468
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenCode Session Header",
4
+ "description": "Adds OpenCode Go's required x-opencode-session header to model requests on the configured OpenCode routes (including auto-review and hand-declared routes), with a live on/off switch."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenCode 会话头注入",
4
+ "description": "为配置的 OpenCode 路由上的模型请求补上 OpenCode Go 必需的 x-opencode-session 头(含自动审查与手写路由),并带实时开关。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "dsh-opencode-go-x-session",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "Adds OpenCode Go's required x-opencode-session header to model requests of the configured OpenCode routes (including auto-review and hand-declared routes), with a live on/off switch.",
5
+ "author": "pengpenglele",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pengpenglele/dsh-opencode-go-x-session-code.git"
9
+ },
10
+ "homepage": "https://github.com/pengpenglele/dsh-opencode-go-x-session-code#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/pengpenglele/dsh-opencode-go-x-session-code/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "index.js",
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./icon": "./icon.svg",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "index.js",
24
+ "cordis.patch.yml",
25
+ "locale/*.json",
26
+ "icon.svg",
27
+ "README.md",
28
+ "README.zh.md"
29
+ ],
30
+ "icon": "./icon.svg",
31
+ "keywords": [
32
+ "dsh",
33
+ "dsh-plugin",
34
+ "deepseek-harness",
35
+ "cordis",
36
+ "opencode",
37
+ "opencode-go",
38
+ "x-opencode-session",
39
+ "missing-session-id",
40
+ "session-affinity",
41
+ "prompt-cache"
42
+ ],
43
+ "dsh": {
44
+ "engines": {
45
+ "dsh": ">=0.2.1-rc.2"
46
+ },
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ }
50
+ },
51
+ "engines": {
52
+ "node": ">=22.19.0"
53
+ },
54
+ "dependencies": {
55
+ "@deepseek-ai/schemastery": "^3.18.5"
56
+ },
57
+ "scripts": {
58
+ "test": "node test/plugin.test.mjs",
59
+ "prepublishOnly": "node test/plugin.test.mjs"
60
+ },
61
+ "license": "MIT"
62
+ }