dsh-opencode-go-x-session 0.0.0-stage → 1.2.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/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,218 @@
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
+ [![npm version](https://img.shields.io/npm/v/dsh-opencode-go-x-session.svg)](https://www.npmjs.com/package/dsh-opencode-go-x-session)
4
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-opencode-go-x-session.svg)](https://www.npmjs.com/package/dsh-opencode-go-x-session)
5
+ [![license](https://img.shields.io/npm/l/dsh-opencode-go-x-session.svg)](./LICENSE)
6
+
7
+ [中文文档](./README.zh.md) · English
8
+
9
+ A host-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that adds
10
+ OpenCode Go's required `x-opencode-session` request header to model requests on
11
+ the configured OpenCode provider routes — including the requests the official
12
+ auto-review feature builds by hand — and to no other route or host.
13
+
14
+ ## Do you need it?
15
+
16
+ OpenCode Go / Zen requires every inference request to carry
17
+ `x-opencode-session`: a stable identifier per conversation, used for routing and
18
+ prompt-cache affinity. Since 2026-09-05 requests without it are rejected:
19
+
20
+ ```
21
+ 400 {"type":"MissingSessionID","message":"... Request is missing x-opencode-session ..."}
22
+ ```
23
+
24
+ Install this plugin if you see either of these inside DSH:
25
+
26
+ - `400 MissingSessionID` on an `opencode-go` / `opencode-go-*` route, or
27
+ - `Auto review of tool "..." failed` when the reviewed session uses an OpenCode Go
28
+ route (the reviewer call carries no `sessionId`, so pi-ai's own OpenCode
29
+ wrapper has nothing to write).
30
+
31
+ DSH sends the header natively only on pi-ai catalog requests that carry a
32
+ `sessionId`. Auto-review calls and hand-declared routes
33
+ (`opencode-go-free-none`, `opencode-go-audit-free-none`, custom keys) lack it.
34
+ This plugin fills that gap at the transport layer both paths actually use.
35
+
36
+ ## Requirements
37
+
38
+ | Requirement | Value |
39
+ |---|---|
40
+ | DSH | `>= 0.2.1-rc.2` (declared as `dsh.engines.dsh`) |
41
+ | Node.js | `>= 22.19.0` |
42
+ | Route | an OpenCode Go provider route (`opencode-go` or `opencode-go-*`) configured with its own `apiKeyEnv` / `baseURL` |
43
+ | Host | requests go to `opencode.ai` (subdomains included) |
44
+
45
+ ## Install
46
+
47
+ ### Option A — DSH Web UI (recommended)
48
+
49
+ 1. Open DSH → **Plugins** (插件) → **Add plugin** (添加插件).
50
+ 2. Enter the package name:
51
+
52
+ ```
53
+ dsh-opencode-go-x-session
54
+ ```
55
+
56
+ 3. Install, then **fully restart DSH**. Bundle layers are composed at startup, so
57
+ the plugin does not load until DSH restarts.
58
+
59
+ ### Option B — CLI
60
+
61
+ ```sh
62
+ dsh plugin --profile desktop add dsh-opencode-go-x-session
63
+ ```
64
+
65
+ Use your own profile name instead of `desktop` if you boot another one. Then
66
+ fully restart DSH.
67
+
68
+ ### Option C — from this repository (development)
69
+
70
+ ```sh
71
+ git clone https://github.com/pengpenglele/dsh-opencode-go-x-session-code.git
72
+ ```
73
+
74
+ Then install the cloned directory as a bundle (Web: Plugins → Install bundle;
75
+ agent tool: `install_bundle` with the directory's absolute path), or install it
76
+ as a local link:
77
+
78
+ ```sh
79
+ dsh plugin --profile desktop add link:/absolute/path/to/dsh-opencode-go-x-session-code
80
+ ```
81
+
82
+ A `link:` installation resolves the plugin's dependencies from its real path, so
83
+ run `pnpm install --ignore-workspace` in that directory first (it needs
84
+ `@deepseek-ai/schemastery`).
85
+
86
+ Either way the bundle patch inserts one host row, `opencode-session-header`,
87
+ into the profile. It applies to every session in that profile.
88
+
89
+ ## Usage
90
+
91
+ ### It works with no configuration
92
+
93
+ After installation and a restart, requests on `opencode-go` and every
94
+ `opencode-go-*` route to `opencode.ai` receive the header automatically. The
95
+ value is the DSH session id, so it is stable across the turns of one conversation
96
+ and distinct between concurrent conversations.
97
+
98
+ For auto-review, the reviewer request runs inside the reviewed agent's driver
99
+ chain, so the plugin resolves that conversation's id through
100
+ `ctx.agents.currentInitiator()` — the review keeps its prompt-cache affinity
101
+ instead of falling back to a shared id.
102
+
103
+ ### Settings
104
+
105
+ Settings → Plugins → **OpenCode Session Header**. All fields are optional and
106
+ every field is live (`volatile()`): a settings save applies to the next request
107
+ without a restart.
108
+
109
+ | Field | Default | Meaning |
110
+ |---|---|---|
111
+ | `enabled` | `true` | Master switch; `false` removes the fetch patch immediately and every request passes through untouched |
112
+ | `providers` | `['opencode-go*']` | Provider routes served by the plugin; entries may be exact ids or trailing-`*` prefix patterns. The default covers the `opencode-go` catalog route and every `opencode-go-*` variant. An empty list disables injection |
113
+ | `hosts` | `['opencode.ai']` | Host allowlist; each entry also covers its subdomains. An empty list removes the host check (for a proxy endpoint) |
114
+ | `fallbackSessionId` | `dsh-auto-review` | Stable id used only when neither the request, the enclosing scope, nor an initiator session id exists |
115
+ | `debug` | `false` | Log every injection through the host logger |
116
+
117
+ ### Custom routes and proxy endpoints
118
+
119
+ - A new `opencode-go-something` route is covered automatically — no config change.
120
+ - The plain `opencode` (non-Go) route is **not** covered by the default pattern;
121
+ add `opencode` to `providers` if you use it.
122
+ - If your OpenCode endpoint is a proxy with another hostname, either add that
123
+ hostname to `hosts` or set `hosts` to `[]` to disable the host check. The
124
+ provider list still scopes injection.
125
+
126
+ ### Check that it is working
127
+
128
+ 1. The DSH startup log shows:
129
+
130
+ ```
131
+ opencode-session-header: active (providers opencode-go*, hosts opencode.ai)
132
+ ```
133
+
134
+ 2. Turn on `debug` and start a conversation on an OpenCode Go route; every
135
+ injected request logs:
136
+
137
+ ```
138
+ [opencode-session-header] x-opencode-session: <session-id> -> <url>
139
+ ```
140
+
141
+ 3. The original symptom disappears: no more `400 MissingSessionID`, and
142
+ auto-review on an OpenCode Go model no longer fails with
143
+ `Auto review of tool "..." failed`.
144
+
145
+ ## Disable / uninstall
146
+
147
+ - **Settings switch** — set `enabled: false`; the fetch patch is removed and
148
+ requests pass through untouched. No restart.
149
+ - **Plugin row** — disable the `opencode-session-header` row in the plugin
150
+ manager; the whole plugin unloads and the original `fetch` is restored.
151
+ - **Profile patch** — add `- id: opencode-session-header` with
152
+ `disabled: true` under your profile patch layer.
153
+ - **Uninstall** — remove it in the plugin manager, or run:
154
+
155
+ ```sh
156
+ dsh plugin --profile desktop remove dsh-opencode-go-x-session
157
+ ```
158
+
159
+ Then restart DSH.
160
+
161
+ ## Troubleshooting
162
+
163
+ | Symptom | Cause / fix |
164
+ |---|---|
165
+ | Still `400 MissingSessionID` | Route not covered: add the exact route id (or a matching `something*` pattern) to `providers` |
166
+ | Still `400` on a custom endpoint | Host not allowlisted: add the real hostname to `hosts`, or set `hosts: []` |
167
+ | Settings page missing the plugin | The plugin is not loaded: restart DSH and check the startup log line |
168
+ | Plugin refuses to load | DSH is older than `0.2.1-rc.2`; upgrade DSH |
169
+ | Header never injected although enabled | Another plugin wraps `globalThis.fetch` after this one and bypasses the chain; or the route already carries a static `x-opencode-session` header (existing headers always win — remove the static one) |
170
+ | Auto-review still fails | The reviewer uses the session's model; make sure that route is in `providers`, and that the plugin is enabled |
171
+
172
+ ## How it works
173
+
174
+ 1. It listens on the supported `llm/stream` waterfall. For a request whose
175
+ provider matches `providers`, it resolves the conversation id in this order:
176
+ the request's own `sessionId`; the enclosing model-call scope (nested calls);
177
+ the initiator agent's session through `ctx.agents.currentInitiator()` (this is
178
+ what gives an auto-review request the conversation it is reviewing); finally
179
+ the configured `fallbackSessionId`. The downstream chunk stream is re-emitted
180
+ so every pull runs inside an `AsyncLocalStorage` scope carrying that id.
181
+ 2. It wraps `globalThis.fetch` (the `openai` SDK's default transport). Inside a
182
+ captured scope it adds the header when it is absent and the URL host is
183
+ allowlisted; every other request keeps the original arguments. The wrapper is
184
+ reference-counted, so HMR reloads and concurrent activations share one patch,
185
+ and it is removed when the last activation unloads.
186
+
187
+ The plugin changes no model-visible content: no prompt text, no session event,
188
+ no transcript entry.
189
+
190
+ ## Notes and limits
191
+
192
+ - It relies on the `openai` SDK resolving `globalThis.fetch` per request (the
193
+ installed SDK constructs one client per model call). If a future version pins a
194
+ transport instance at import time, the header would not be added and the route
195
+ would fail as before.
196
+ - Model discovery (`llm.discoverModels`) is a plain fetch outside a model call
197
+ and is left untouched.
198
+ - Routes still need their own credential (`apiKeyEnv`) and `baseURL`; this plugin
199
+ only adds the header.
200
+ - Calls with no session context share the stable `fallbackSessionId`. That keeps
201
+ them working, but those calls share one affinity id rather than one per
202
+ conversation.
203
+
204
+ ## Development
205
+
206
+ ```sh
207
+ npm test # 22 host-free tests against a mocked fetch
208
+ ```
209
+
210
+ The tests cover injection for the default `opencode-go*` pattern, the
211
+ auto-review shape (no `sessionId`, resolved through the initiator), the stable
212
+ fallback, existing headers, other providers (including the sibling `opencode`),
213
+ other hosts, concurrent session isolation, live switching, and fetch restoration
214
+ on dispose.
215
+
216
+ ## License
217
+
218
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,189 @@
1
+ # dsh-opencode-go-x-session(OpenCode 会话头注入)
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dsh-opencode-go-x-session.svg)](https://www.npmjs.com/package/dsh-opencode-go-x-session)
4
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-opencode-go-x-session.svg)](https://www.npmjs.com/package/dsh-opencode-go-x-session)
5
+ [![license](https://img.shields.io/npm/l/dsh-opencode-go-x-session.svg)](./LICENSE)
6
+
7
+ 中文 · [English](./README.md)
8
+
9
+ Host-only 的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:为配置的
10
+ OpenCode provider 路由上的模型请求补上 OpenCode Go 必需的 `x-opencode-session` 头——包括官方
11
+ **自动审查**功能手写的审查请求——其他路由、其他主机、其他 provider 一概不碰。
12
+
13
+ ## 你是否需要它
14
+
15
+ OpenCode Go / Zen 要求每个推理请求携带 `x-opencode-session`(一条会话一个稳定标识,用于路由
16
+ 与提示词缓存亲和)。自 2026-09-05 起,缺这个头的请求会被拒绝:
17
+
18
+ ```
19
+ 400 {"type":"MissingSessionID","message":"... Request is missing x-opencode-session ..."}
20
+ ```
21
+
22
+ 在 DSH 里遇到下面任一情况,就该装这个插件:
23
+
24
+ - 走 `opencode-go` / `opencode-go-*` 路由时报 `400 MissingSessionID`;
25
+ - 被审查的会话用的是 OpenCode Go 路由时,工具调用报
26
+ `Auto review of tool "..." failed`(审查器调用不带 `sessionId`,pi-ai 自带的 OpenCode 包装
27
+ 没有值可写)。
28
+
29
+ DSH 只会在**带 `sessionId` 的 pi-ai 目录路由请求**上原生发送这个头。自动审查的调用和手写的
30
+ 路由(`opencode-go-free-none`、`opencode-go-audit-free-none`、自定义 key)都没有它。本插件在
31
+ 两条路径实际都经过的传输层补上这个缺口。
32
+
33
+ ## 前置条件
34
+
35
+ | 要求 | 值 |
36
+ |---|---|
37
+ | DSH | `>= 0.2.1-rc.2`(由 `dsh.engines.dsh` 声明) |
38
+ | Node.js | `>= 22.19.0` |
39
+ | 路由 | 已配置一条 OpenCode Go provider 路由(`opencode-go` 或 `opencode-go-*`),带自己的 `apiKeyEnv` / `baseURL` |
40
+ | 主机 | 请求目标是 `opencode.ai`(含子域) |
41
+
42
+ ## 安装
43
+
44
+ ### 方式 A — DSH Web 界面(推荐)
45
+
46
+ 1. 打开 DSH → 左侧 **插件** → **添加插件**
47
+ 2. 输入包名:
48
+
49
+ ```
50
+ dsh-opencode-go-x-session
51
+ ```
52
+
53
+ 3. 安装后**完全重启 DSH**。bundle 层是在启动时组合的,不重启插件不会加载。
54
+
55
+ ### 方式 B — 命令行
56
+
57
+ ```sh
58
+ dsh plugin --profile desktop add dsh-opencode-go-x-session
59
+ ```
60
+
61
+ 如果你启动的不是 `desktop` profile,把 `desktop` 换成你的 profile 名。装完**完全重启 DSH**。
62
+
63
+ ### 方式 C — 从本仓库安装(开发)
64
+
65
+ ```sh
66
+ git clone https://github.com/pengpenglele/dsh-opencode-go-x-session-code.git
67
+ ```
68
+
69
+ 把 clone 下来的目录作为 bundle 安装(Web:插件 → 安装 bundle;agent 工具:`install_bundle`,
70
+ target 为该目录绝对路径),或者按本地 link 安装:
71
+
72
+ ```sh
73
+ dsh plugin --profile desktop add link:/absolute/path/to/dsh-opencode-go-x-session-code
74
+ ```
75
+
76
+ 按 `link:` 安装时,插件依赖从本目录真实路径解析,所以要先在该目录执行
77
+ `pnpm install --ignore-workspace`(需要 `@deepseek-ai/schemastery`)。
78
+
79
+ 三种方式都会由 bundle patch 向 profile 插入一行 Host 插件 `opencode-session-header`,对该
80
+ profile 下所有会话生效。
81
+
82
+ ## 使用
83
+
84
+ ### 装完即用,无需配置
85
+
86
+ 安装并重启后,`opencode-go` 及所有 `opencode-go-*` 路由发往 `opencode.ai` 的请求会自动带上
87
+ 这个头。头的值是 DSH 会话 id:同一会话跨轮次稳定,并发会话之间互不相同。
88
+
89
+ 自动审查的审查请求跑在被审查 Agent 的驱动链内,插件通过 `ctx.agents.currentInitiator()`
90
+ 拿到该会话的 id,所以审查请求也能保持自己的缓存亲和,而不是退化成共享的固定值。
91
+
92
+ ### 设置项
93
+
94
+ 设置 → 插件 → **OpenCode Session Header**。所有字段均可选,且都是实时的(`volatile()`):
95
+ 保存后下一次请求就生效,无需重启。
96
+
97
+ | 字段 | 默认值 | 含义 |
98
+ |---|---|---|
99
+ | `enabled` | `true` | 总开关;`false` 立即移除 fetch 补丁,所有请求原样放行 |
100
+ | `providers` | `['opencode-go*']` | 需要补头的 provider 路由;条目可写精确 id,也可用尾部 `*` 前缀匹配。默认覆盖目录路由 `opencode-go` 与所有 `opencode-go-*` 变体;空数组 = 不生效 |
101
+ | `hosts` | `['opencode.ai']` | 主机白名单(各条目含其子域);空数组 = 不检查主机(路由指向代理端点时用) |
102
+ | `fallbackSessionId` | `dsh-auto-review` | 请求、外层作用域、发起会话都拿不到会话 id 时使用的稳定兜底 id |
103
+ | `debug` | `false` | 通过 Host logger 记录每次注入 |
104
+
105
+ ### 自定义路由与代理端点
106
+
107
+ - 新增 `opencode-go-xxx` 路由会被默认通配自动覆盖,不用改配置。
108
+ - 裸 `opencode`(非 Go)路由**不在**默认范围内;需要的话把 `opencode` 加进 `providers`。
109
+ - 如果 OpenCode 端点走代理、域名不同:把该域名加到 `hosts`,或把 `hosts` 设为 `[]` 关闭主机
110
+ 检查。provider 列表仍然限制注入范围。
111
+
112
+ ### 确认是否生效
113
+
114
+ 1. DSH 启动日志出现:
115
+
116
+ ```
117
+ opencode-session-header: active (providers opencode-go*, hosts opencode.ai)
118
+ ```
119
+
120
+ 2. 打开 `debug`,在 OpenCode Go 路由上发一条消息,每次注入都会记录:
121
+
122
+ ```
123
+ [opencode-session-header] x-opencode-session: <session-id> -> <url>
124
+ ```
125
+
126
+ 3. 原来的症状消失:不再有 `400 MissingSessionID`;自动审查工具调用不再报
127
+ `Auto review of tool "..." failed`。
128
+
129
+ ## 关闭 / 卸载
130
+
131
+ - **设置页开关**:把 `enabled` 设为 `false`,fetch 补丁被移除,请求原样放行,无需重启。
132
+ - **插件行开关**:在插件管理器里禁用 `opencode-session-header` 行,整个插件卸载并恢复原始
133
+ `fetch`。
134
+ - **profile patch**:在 profile 补丁层给 `- id: opencode-session-header` 加
135
+ `disabled: true`。
136
+ - **卸载**:在插件管理器里移除,或执行:
137
+
138
+ ```sh
139
+ dsh plugin --profile desktop remove dsh-opencode-go-x-session
140
+ ```
141
+
142
+ 然后重启 DSH。
143
+
144
+ ## 故障排查
145
+
146
+ | 现象 | 原因 / 处理 |
147
+ |---|---|
148
+ | 仍然 `400 MissingSessionID` | 路由没命中:把确切的 route id(或匹配的 `something*` 模式)加进 `providers` |
149
+ | 自定义端点仍然 400 | 主机没命中:把真实域名加进 `hosts`,或设 `hosts: []` |
150
+ | 设置页看不到插件 | 插件没加载:重启 DSH 并检查启动日志里有没有那行 `active` |
151
+ | 插件加载被拒 | DSH 低于 `0.2.1-rc.2`,升级 DSH |
152
+ | 已启用但始终没注入 | 有别的插件在本插件之后再包 `globalThis.fetch`、绕过了链路;或者路由里写死了静态 `x-opencode-session` 头(已有头优先,请删掉静态头) |
153
+ | 自动审查仍失败 | reviewer 用的是会话当前模型:确认该路由在 `providers` 内,且插件处于启用状态 |
154
+
155
+ ## 工作原理
156
+
157
+ 1. 监听官方支持的 `llm/stream` 瀑布。请求的 provider 命中 `providers` 时,按以下顺序确定会话
158
+ id:请求自身的 `sessionId`;外层模型调用作用域(嵌套调用);通过
159
+ `ctx.agents.currentInitiator()` 取发起 Agent 的会话 id(自动审查正是靠这一步拿到被审查
160
+ 会话);最后用配置的 `fallbackSessionId`。下游 chunk 流的每次拉取都放进携带该 id 的
161
+ `AsyncLocalStorage` 作用域。
162
+ 2. 包装 `globalThis.fetch`(`openai` SDK 的默认传输)。只在上述作用域生效、URL 主机命中
163
+ `hosts`、且请求尚无该头时加头;其余请求原参数放行。包装器按引用计数共享,HMR 重载与并发
164
+ 激活不会重复包装,最后一个激活卸载时恢复原始 `fetch`。
165
+
166
+ 插件不改任何模型可见内容:不增提示词、不写会话事件、不改对话记录。
167
+
168
+ ## 说明与限制
169
+
170
+ - 依赖 `openai` SDK 每次请求解析 `globalThis.fetch`(当前安装版本每次模型调用都会新建
171
+ client)。若未来版本在导入时固定传输实例,头就不会被补上,症状与修复前相同。
172
+ - 模型发现(`llm.discoverModels`)是模型调用之外的普通 fetch,不在本插件作用范围。
173
+ - 路由自身的凭据(`apiKeyEnv`)与 `baseURL` 仍由路由配置负责;本插件只补头。
174
+ - 完全拿不到会话上下文的调用会共用 `fallbackSessionId`:能正常发出去,但这些调用共享一个
175
+ 亲和 id,而不是各自一条会话。
176
+
177
+ ## 开发
178
+
179
+ ```sh
180
+ npm test # 22 条无需 Host 的测试,针对 mock 的 fetch
181
+ ```
182
+
183
+ 测试覆盖:默认 `opencode-go*` 通配注入、自动审查形态(无 `sessionId`,经 initiator 解析)、
184
+ 稳定兜底 id、已有头不覆盖、其他 provider(含裸 `opencode`)、其他主机、并发会话隔离、实时
185
+ 开关、卸载后恢复 `fetch`。
186
+
187
+ ## 许可证
188
+
189
+ MIT
@@ -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.1",
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
+ }