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 +21 -0
- package/README.md +153 -2
- package/README.zh.md +124 -0
- package/cordis.patch.yml +21 -0
- package/icon.svg +21 -0
- package/index.js +468 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
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
|
-
#
|
|
1
|
+
# dsh-opencode-go-x-session
|
|
2
2
|
|
|
3
|
-
|
|
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` 仍由路由配置负责;本插件只补头。
|
package/cordis.patch.yml
ADDED
|
@@ -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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-opencode-go-x-session",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|