@karoc/dsh-smoothly-opencode-session 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ > **Process rule:** every released version MUST have a matching entry in this
9
+ > file AND its user-visible behavior reflected in the README (both `README.md`
10
+ > and `README.zh.md`) — all in the same release. The automated gate
11
+ > `pnpm release:check` (also run by `prepublishOnly`) blocks publishing until
12
+ > every item passes. See [CONTRIBUTING.md](CONTRIBUTING.md).
13
+
14
+ ## [0.1.0] - 2026-09-14
15
+
16
+ ### Added
17
+
18
+ - **Initial release — OpenCode session header injection.** External DeepSeek
19
+ Harness host plugin that attaches the OpenCode-required `x-opencode-session`
20
+ header to model calls routed to OpenCode / OpenCode Go provider routes
21
+ (fixes `400 MissingSessionID`, in effect for OpenCode Go since 2026-09-05;
22
+ tracked in deepseek-harness discussion #5495).
23
+ - Observes the documented `llm/stream` waterfall for `provider` + `sessionId`
24
+ and injects the header via one patched `globalThis.fetch` inside an
25
+ `AsyncLocalStorage` store (options are deep-frozen, so mutation is off the
26
+ table for external plugins).
27
+ - Per-conversation stability: `mode: session-id` (default) reuses the DSH
28
+ session id — stable across turns, compaction, retries and restarts, unique
29
+ per conversation — which keeps OpenCode's backend affinity / prompt-cache
30
+ working; `mode: uuid` derives an opaque process-stable UUID per session.
31
+ - Config-driven, fully optional row config (`providers` / `mode` / `debug` /
32
+ `debugFile`); defaults cover the pi-ai catalog ids `opencode` and
33
+ `opencode-go`, custom route keys (e.g. `opencode-go-self`) are added through
34
+ a same-id profile patch entry.
35
+ - Requests that already carry the header, non-OpenCode providers, and
36
+ session-id-less auxiliary calls pass through untouched; discovery probes
37
+ are not affected.
38
+ - Fiber-scoped cleanup: unload / update / stop restores the original
39
+ `globalThis.fetch` and removes the listener.
40
+ - Bilingual README, CHANGELOG, CONTRIBUTING, release gate
41
+ (`release-check.mjs`) and post-publish verification
42
+ (`post-publish-check.mjs`), `node --test` unit tests for the pure helpers.
@@ -0,0 +1,74 @@
1
+ # Contributing
2
+
3
+ Thanks for contributing to `@karoc/dsh-smoothly-opencode-session`. This is a
4
+ small external host plugin, but it is **published to npm and distributed to
5
+ users** — so releases must be coherent: a version bump without its
6
+ documentation is a defect.
7
+
8
+ ## Development
9
+
10
+ Prerequisites: Node.js ≥ 18, [pnpm](https://pnpm.io).
11
+
12
+ ```sh
13
+ pnpm install # installs dev deps (tsdown, @deepseek-ai/cordis types, @types/node)
14
+ pnpm bundle # emits lib/index.js
15
+ npm test # node --test tests/*.test.ts (runs TypeScript directly)
16
+ ```
17
+
18
+ - `src/index.ts` is the host plugin: an `llm/stream` waterfall listener plus a
19
+ single patched `globalThis.fetch`. It reads `provider` + `sessionId` off the
20
+ waterfall options and injects `x-opencode-session` into the outbound request
21
+ via an `AsyncLocalStorage` store (options are deep-frozen for LOOP-built
22
+ requests; external plugins cannot mutate them).
23
+ - Keep the plugin **zero-dependency at runtime**: only Node built-ins may be
24
+ imported as values. `@deepseek-ai/cordis` is a devDependency for types only
25
+ (`import type`), erased from the build.
26
+ - The local `declare module '@deepseek-ai/cordis'` augmentation mirrors the
27
+ official `llm/stream` Events merge published by `@deepseek-ai/dsh-llm`; keep
28
+ its signature in sync with `packages/llm/llm/src/index.ts`.
29
+ - Copy is bilingual: any user-visible text appears in both `README.md` and
30
+ `README.zh.md` (same `##`/`###` section set — the release gate verifies the
31
+ counts match).
32
+
33
+ ## What every release must contain
34
+
35
+ A release is ONE coherent change — code and its documentation land together.
36
+ Splitting a feature's docs into a later "docs release" is a defect. Every
37
+ release MUST include, in the SAME release:
38
+
39
+ 1. **Code change** — implemented, and `pnpm bundle` succeeds (`lib/` produced).
40
+ 2. **README.md (English)** — describes the new user-visible behavior.
41
+ 3. **README.zh.md (Chinese)** — mirrors the same sections (same `##` section
42
+ set; the release gate verifies the section counts match).
43
+ 4. **CHANGELOG.md** — an entry under `## [<version>]` for this release, as the
44
+ latest released entry (Keep a Changelog + SemVer).
45
+ 5. **Version** — `package.json` version equals the CHANGELOG entry and the git
46
+ tag `v<version>` on HEAD.
47
+ 6. **Git** — working tree clean (everything committed), tag pushed.
48
+ 7. **Not re-published** — the version must not already exist on npm.
49
+
50
+ ## Verification — the release gate
51
+
52
+ `pnpm release:check` (also wired into `prepack` and `prepublishOnly`) verifies
53
+ every item above and **blocks** `npm publish` / `npm pack` until all pass.
54
+ `scripts/post-publish-check.mjs` runs via `postpublish` and confirms the
55
+ release actually landed on the registry (polling through index eventual
56
+ consistency) and that the published tarball contains every expected file.
57
+
58
+ Known bypasses, documented on purpose:
59
+
60
+ - `npm publish --ignore-scripts` skips all gates. This is an explicit user
61
+ override; do not use it.
62
+ - Tarballs packed outside this repo (`npm pack` elsewhere) skip `prepack`.
63
+
64
+ ## Publishing (maintainer, human step — 2FA)
65
+
66
+ The npm account has two-factor authentication, so publishing is a manual,
67
+ human step: `npm login` (2FA) → `npm publish`. Everything else must be
68
+ prepared to the point of "one command by the user": clean tree, version +
69
+ tag on HEAD, `release:check` passing, `npm pack --dry-run` contents reviewed.
70
+
71
+ After a successful publish, the `postpublish` script auto-verifies the
72
+ registry; if the index is still catching up it polls (up to ~60s) before
73
+ judging. Do **not** re-publish the same version without checking — it may
74
+ already be live.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 karoc
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 ADDED
@@ -0,0 +1,176 @@
1
+ # Smoothly OpenCode Session (Smoothly OCS)
2
+
3
+ **思磨力 OpenCode 会话头** — an external [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
4
+ (DSH) **host plugin** that attaches the OpenCode-required `x-opencode-session`
5
+ request header to model calls routed to **OpenCode / OpenCode Go** provider
6
+ routes, using a stable per-conversation id.
7
+
8
+ Since **2026-09-05** OpenCode's relay rejects any inference request that lacks
9
+ this header (`400 MissingSessionID`); the value is what pins a conversation to
10
+ one upstream backend and keeps OpenCode's prompt cache warm across its turns
11
+ (tracked upstream in [deepseek-harness discussion #5495](https://github.com/deepseek-ai/deepseek-harness/discussions/5495)).
12
+
13
+ ## What it does
14
+
15
+ - **Fixes the 400** by always attaching `x-opencode-session` to requests that
16
+ are routed to a configured OpenCode(Go) provider.
17
+ - **Keeps the cache/affinity benefit** by using a value that is unique **per
18
+ conversation** and stable across that conversation's turns, compaction,
19
+ retries and process restarts (by default the DSH session id itself — the
20
+ same identity the official DeepSeek adapter already sends as
21
+ `x-deepseek-harness-session-id`).
22
+ - **Leaves everything else untouched**: other providers, requests that already
23
+ carry the header, and requests without a session id pass through exactly as
24
+ before.
25
+
26
+ ## How it works
27
+
28
+ The plugin listens on the **`llm/stream` waterfall** — a documented DSH
29
+ extension seam ("Waterfall around every streaming model call"). LOOP-built
30
+ request options are deep-frozen (mutation throws), so the header cannot be
31
+ added by rewriting options; instead the plugin:
32
+
33
+ 1. reads `provider` + `sessionId` off the waterfall options for each call;
34
+ 2. drives the downstream stream's pulls inside an `AsyncLocalStorage` store;
35
+ 3. patches `globalThis.fetch` once, and while such a store is active merges
36
+ `x-opencode-session: <value>` onto the outgoing request (unless the request
37
+ already carries the header — an existing value always wins).
38
+
39
+ Both registrations are fiber-scoped (`ctx.on` listener + `ctx.effect`
40
+ disposer), so stopping / updating / unloading the plugin restores the original
41
+ `fetch` and removes the listener.
42
+
43
+ ### Why the fetch-level injection
44
+
45
+ There is no official seam for an external plugin to add per-request headers to
46
+ adapter requests (options are deep-frozen; provider `headers` config is static
47
+ and deployment-owned). The fetch shim is the only external-plugin mechanism
48
+ that can attach a **per-conversation** value. The correct long-term fix lives
49
+ in the provider adapter itself (pi-ai); this plugin is the stopgap until that
50
+ ships. See [Notes / limitations](#notes--limitations).
51
+
52
+ ## Configuration
53
+
54
+ The row's `config` (in the bundle's `cordis.patch.yml`, or overridden per
55
+ profile) is entirely optional — the code fills defaults for missing keys.
56
+
57
+ ```yaml
58
+ - insert:
59
+ - id: dsh-smoothly-opencode-session
60
+ name: '@karoc/dsh-smoothly-opencode-session'
61
+ config:
62
+ providers: [opencode, opencode-go]
63
+ mode: session-id
64
+ debug: false
65
+ ```
66
+
67
+ ### providers
68
+
69
+ Provider route keys whose requests receive the header. Defaults to the pi-ai
70
+ catalog ids `opencode` and `opencode-go`. If you serve OpenCode under a custom
71
+ provider route key (e.g. `opencode-go-self` in `llm-pi-ai`), add that key.
72
+
73
+ ### mode
74
+
75
+ - `session-id` (default) — header value = the DSH session id of the model
76
+ call. Unique per conversation, stable across turns, compaction, retries and
77
+ restarts.
78
+ - `uuid` — a process-stable random UUID derived once per DSH session id
79
+ (opaque; resets when the process restarts).
80
+
81
+ ### debug / debugFile
82
+
83
+ - `debug: true` — log every streamed call that receives the header via
84
+ `ctx.logger` (the dsh process console).
85
+ - `debugFile: <absolute path>` — append one JSON line
86
+ (`{"ts","provider","model","session","header","value"}`) per streamed call
87
+ that receives the header. Handy when the dsh console is not visible.
88
+
89
+ To override configuration in a profile without editing this package, add a
90
+ patch entry with the **same id** to the profile's own `cordis.patch.yml` (it
91
+ replaces the whole `config`, so restate every key you need). The patch entry
92
+ form is id-targeted, not `insert` — see [Install](#install).
93
+
94
+ ## Install
95
+
96
+ ### From npm (recommended)
97
+
98
+ ```sh
99
+ dsh plugin --profile web add @karoc/dsh-smoothly-opencode-session
100
+ ```
101
+
102
+ Then **fully restart** your dsh profile (bundle layers are read at startup).
103
+ The startup log shows:
104
+
105
+ ```
106
+ [dsh-smoothly-opencode-session] active for providers [opencode, opencode-go] with mode session-id
107
+ ```
108
+
109
+ If you run DSH from a source checkout instead, load it as an overlay:
110
+ `pnpm dsh web --patch ./cordis.patch.yml`.
111
+
112
+ ### From git
113
+
114
+ ```sh
115
+ dsh plugin --profile web add git+https://github.com/karoc/dsh-smoothly-opencode-session.git
116
+ ```
117
+
118
+ ### Updating
119
+
120
+ ```sh
121
+ dsh plugin --profile web update @karoc/dsh-smoothly-opencode-session
122
+ ```
123
+
124
+ Restart `dsh web` afterwards.
125
+
126
+ ### Removing
127
+
128
+ ```sh
129
+ dsh plugin --profile web remove @karoc/dsh-smoothly-opencode-session
130
+ ```
131
+
132
+ Restart `dsh web` afterwards. Removal is safe: the original `fetch` is
133
+ restored on unload, so no other provider is affected.
134
+
135
+ ## Layout
136
+
137
+ ```
138
+ src/index.ts host plugin: llm/stream listener + fetch shim
139
+ cordis.patch.yml bundle layer (inserts the plugin row with defaults)
140
+ scripts/ release gate + post-publish verification
141
+ tests/ node --test unit tests for the pure helpers
142
+ lib/ built output (npm package entry)
143
+ ```
144
+
145
+ ## Build & test
146
+
147
+ ```sh
148
+ pnpm install # installs dev deps (tsdown, @deepseek-ai/cordis types, @types/node)
149
+ pnpm bundle # emits lib/index.js
150
+ npm test # node --test tests/*.test.ts (runs TypeScript directly)
151
+ ```
152
+
153
+ ## Notes / limitations
154
+
155
+ - **Scope:** only chat/streaming requests inside an `llm/stream` call receive
156
+ the header. The one-shot model listing used by the Models page
157
+ (`GET <baseURL>/models`) is a separate flow and does not receive it; if your
158
+ OpenCode endpoint rejects that listing too, that is a separate issue.
159
+ - **Implementation dependency:** injection rides on Node's global `fetch`. If
160
+ a future DSH version swaps its network stack, the header silently stops
161
+ being sent (the 400 comes back) — uninstall then. This is an external-plugin
162
+ stopgap until the provider adapter itself (pi-ai) sends the header.
163
+ - **Not the official fix:** the DSH maintainers' position is that provider
164
+ particularities belong in the pi-ai package (see discussion #5495 and
165
+ earendil-works/pi #9326). Once that ships and DSH upgrades to it, this
166
+ plugin can be removed.
167
+ - **Discovery probes are untouched** (requests without a session id pass
168
+ through).
169
+
170
+ ## License
171
+
172
+ MIT
173
+
174
+ ## Contributing
175
+
176
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
package/README.zh.md ADDED
@@ -0,0 +1,154 @@
1
+ # 思磨力 OpenCode 会话头(Smoothly OCS)
2
+
3
+ **Smoothly OpenCode Session (Smoothly OCS)** — 一个外部 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
4
+ (DSH) **宿主插件**:为路由到 **OpenCode / OpenCode Go** 提供方的模型请求自动附加
5
+ OpenCode 要求的 `x-opencode-session` 请求头,取值是稳定的按会话 id。
6
+
7
+ 自 **2026-09-05** 起,OpenCode 的中继会拒绝任何缺少该头的推理请求
8
+ (`400 MissingSessionID`);该值同时把一段会话固定到同一上游后端,并让
9
+ OpenCode 的提示词缓存在该会话各轮之间保持命中(上游跟踪见
10
+ [deepseek-harness discussion #5495](https://github.com/deepseek-ai/deepseek-harness/discussions/5495))。
11
+
12
+ ## 它做什么
13
+
14
+ - **修掉 400**:凡是路由到已配置的 OpenCode(Go) 提供方的请求,一律带上
15
+ `x-opencode-session`。
16
+ - **保住缓存/亲和收益**:取值按**会话**唯一、且在该会话各轮、压缩、重试与进程重启
17
+ 之间保持稳定(默认直接复用 DSH 会话 id——官方 DeepSeek 适配器已作为
18
+ `x-deepseek-harness-session-id` 发送的同一个身份)。
19
+ - **其余一概不动**:其他提供方、已自带该头的请求、以及没有会话 id 的请求,原样放行。
20
+
21
+ ## 工作原理
22
+
23
+ 插件监听 **`llm/stream` 瀑布**——一个文档化的 DSH 扩展缝("围绕每次流式模型调用的
24
+ Waterfall")。LOOP 构建的请求 options 是深度冻结的(改写即抛错),所以不能靠改写
25
+ options 加头;插件改为:
26
+
27
+ 1. 从瀑布 options 上读取 `provider` + `sessionId`;
28
+ 2. 把下游流的每次 pull 放进一个 `AsyncLocalStorage` store 里执行;
29
+ 3. 一次性 patch `globalThis.fetch`,当有 store 处于激活时,把
30
+ `x-opencode-session: <值>` 合入出站请求(除非请求已自带该头——已有值永远优先)。
31
+
32
+ 两处注册都是 fiber 作用域的(`ctx.on` 监听 + `ctx.effect` 清理函数),因此
33
+ 停止 / 更新 / 卸载插件时会恢复原始 `fetch` 并移除监听。
34
+
35
+ ### 为什么在 fetch 层注入
36
+
37
+ 外部插件没有任何官方缝能给适配器请求逐条加请求头(options 深度冻结;provider 的
38
+ `headers` 配置是静态的、归属部署方)。fetch shim 是外部插件唯一能附加**按会话**值的
39
+ 机制。正确的长期修复在提供方适配器本身(pi-ai);本插件只是它上线前的过渡方案。见
40
+ [说明 / 限制](#说明--限制)。
41
+
42
+ ## 配置
43
+
44
+ 行的 `config`(写在插件的 `cordis.patch.yml`,或按 profile 覆盖)全部可选——
45
+ 缺失的键由代码填默认值。
46
+
47
+ ```yaml
48
+ - insert:
49
+ - id: dsh-smoothly-opencode-session
50
+ name: '@karoc/dsh-smoothly-opencode-session'
51
+ config:
52
+ providers: [opencode, opencode-go]
53
+ mode: session-id
54
+ debug: false
55
+ ```
56
+
57
+ ### providers
58
+
59
+ 需要带头请求的提供方路由键。默认为 pi-ai 目录 id `opencode` 和 `opencode-go`。
60
+ 如果你用自定义提供方路由键提供 OpenCode(例如 `llm-pi-ai` 里的
61
+ `opencode-go-self`),把该键加进来。
62
+
63
+ ### mode
64
+
65
+ - `session-id`(默认)——头值 = 该次模型调用的 DSH 会话 id。每会话唯一,
66
+ 跨轮次 / 压缩 / 重试 / 重启稳定。
67
+ - `uuid`——按 DSH 会话 id 派生的进程内稳定随机 UUID(不透明;进程重启后重置)。
68
+
69
+ ### debug / debugFile
70
+
71
+ - `debug: true`——把每个收到该头的流式调用经 `ctx.logger`(dsh 进程控制台)打日志。
72
+ - `debugFile: <绝对路径>`——每个收到该头的流式调用向该文件追加一行 JSON
73
+ (`{"ts","provider","model","session","header","value"}`)。dsh 控制台不可见时很有用。
74
+
75
+ 想在不改本包的情况下按 profile 覆盖配置:在 profile 自己的 `cordis.patch.yml` 里加一条
76
+ **同 id** 的 patch 条目(它整体替换 `config`,所以需要的键都要重写)。patch 条目用
77
+ id-targeted 形式,不是 `insert`——见 [安装](#安装)。
78
+
79
+ ## 安装
80
+
81
+ ### 从 npm 安装(推荐)
82
+
83
+ ```sh
84
+ dsh plugin --profile web add @karoc/dsh-smoothly-opencode-session
85
+ ```
86
+
87
+ 然后**完全重启** dsh profile(bundle 层启动时才读取)。启动日志应出现:
88
+
89
+ ```
90
+ [dsh-smoothly-opencode-session] active for providers [opencode, opencode-go] with mode session-id
91
+ ```
92
+
93
+ 如果你从源码 checkout 运行 dsh,也可以作为 overlay 加载:
94
+ `pnpm dsh web --patch ./cordis.patch.yml`。
95
+
96
+ ### 从 git 安装
97
+
98
+ ```sh
99
+ dsh plugin --profile web add git+https://github.com/karoc/dsh-smoothly-opencode-session.git
100
+ ```
101
+
102
+ ### 更新
103
+
104
+ ```sh
105
+ dsh plugin --profile web update @karoc/dsh-smoothly-opencode-session
106
+ ```
107
+
108
+ 之后重启 `dsh web`。
109
+
110
+ ### 卸载
111
+
112
+ ```sh
113
+ dsh plugin --profile web remove @karoc/dsh-smoothly-opencode-session
114
+ ```
115
+
116
+ 之后重启 `dsh web`。卸载是安全的:卸载时恢复原始 `fetch`,不影响其他提供方。
117
+
118
+ ## 目录结构
119
+
120
+ ```
121
+ src/index.ts 宿主插件:llm/stream 监听 + fetch shim
122
+ cordis.patch.yml bundle 层(插入带默认配置的插件行)
123
+ scripts/ 发布门禁 + 发布后校验
124
+ tests/ node --test 纯函数单元测试
125
+ lib/ 构建产物(npm 包入口)
126
+ ```
127
+
128
+ ## 构建与测试
129
+
130
+ ```sh
131
+ pnpm install # 安装开发依赖(tsdown、@deepseek-ai/cordis 类型、@types/node)
132
+ pnpm bundle # 产出 lib/index.js
133
+ npm test # node --test tests/*.test.ts(直接运行 TypeScript)
134
+ ```
135
+
136
+ ## 说明 / 限制
137
+
138
+ - **范围**:只有 `llm/stream` 调用内的聊天/流式请求会收到该头。Models 页使用的一次性
139
+ 模型列表(`GET <baseURL>/models`)是独立流程,不会收到该头;如果你的 OpenCode
140
+ 端点连那个列表也拒绝,那是另一个问题。
141
+ - **实现依赖**:注入依赖 Node 的全局 `fetch`。如果未来某个 DSH 版本换掉网络栈,
142
+ 该头会静默不再发送(400 复现)——届时卸载即可。这是提供方适配器(pi-ai)自身
143
+ 发送该头之前的过渡方案。
144
+ - **不是官方修复**:DSH 维护者的立场是提供方特殊性应归 pi-ai 包处理(见 discussion
145
+ #5495 与 earendil-works/pi #9326)。等它发版并被 DSH 升级后,本插件即可移除。
146
+ - **发现探测不受影响**(没有会话 id 的请求原样放行)。
147
+
148
+ ## 许可证
149
+
150
+ MIT
151
+
152
+ ## 参与贡献
153
+
154
+ 见 [CONTRIBUTING.md](CONTRIBUTING.md)。
@@ -0,0 +1,36 @@
1
+ # Bundle layer applied when a profile lists this package. The row names the
2
+ # package by name so Node resolution finds the installed code; this is a
3
+ # host-only plugin (no exports["./client"], no dsh.client manifest). The
4
+ # row's host apply listens on the llm/stream waterfall and injects the
5
+ # OpenCode-required x-opencode-session header onto provider requests.
6
+ #
7
+ # Configuration (row config, all optional — the code fills defaults for
8
+ # missing keys, so a profile override only needs the keys it changes):
9
+ # providers: [string] provider route keys whose requests receive the
10
+ # header. Defaults to the pi-ai catalog ids
11
+ # ['opencode', 'opencode-go']. Add your own route key
12
+ # when OpenCode is served under a custom provider name
13
+ # (e.g. opencode-go-self).
14
+ # mode: 'session-id' | 'uuid'
15
+ # 'session-id' (default): header value = the DSH
16
+ # session id that already travels with each model call
17
+ # — stable across turns, compaction, retries and
18
+ # restarts, unique per conversation.
19
+ # 'uuid': a process-stable random uuid derived per DSH
20
+ # session id (opaque; resets when the process
21
+ # restarts).
22
+ # debug: true|false log every streamed call that receives the header
23
+ # via ctx.logger (the dsh process console).
24
+ # debugFile: path optional absolute path; when set, every streamed
25
+ # call that receives the header appends one JSON line
26
+ # ({"ts","provider","model","session","header","value"})
27
+ # to that file — handy when the dsh console is hidden.
28
+ - insert:
29
+ - id: dsh-smoothly-opencode-session
30
+ name: '@karoc/dsh-smoothly-opencode-session'
31
+ config:
32
+ providers:
33
+ - opencode
34
+ - opencode-go
35
+ mode: session-id
36
+ debug: false
package/lib/index.js ADDED
@@ -0,0 +1,203 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { appendFile } from "node:fs/promises";
4
+ //#region src/index.ts
5
+ /**
6
+ * Smoothly OpenCode Session (Smoothly OCS) — 思磨力 OpenCode 会话头.
7
+ *
8
+ * External DeepSeek Harness HOST plugin: attaches the OpenCode-required
9
+ * `x-opencode-session` request header to model calls routed to OpenCode /
10
+ * OpenCode Go provider routes. Since 2026-09-05 OpenCode's relay rejects any
11
+ * inference request that lacks the header (400 MissingSessionID); the value
12
+ * is a stable per-conversation id, which is also what pins a conversation to
13
+ * one upstream backend and keeps the prompt cache warm across its turns.
14
+ *
15
+ * Mechanism (each piece verified against the official contracts):
16
+ * - `llm/stream` is a documented waterfall ("Waterfall around every
17
+ * streaming model call", api-catalog), signature
18
+ * `(options: GenerateOptions, next: () => AsyncIterable<StreamChunk>)`.
19
+ * LOOP-built options are deep-frozen: listeners read, never rewrite.
20
+ * - The header therefore cannot be added by mutating options. This plugin
21
+ * observes provider + sessionId at the waterfall, then drives the
22
+ * downstream stream's pulls inside an AsyncLocalStorage store while one
23
+ * patched `globalThis.fetch` merges the header onto the outbound request
24
+ * (unless it already carries one — an existing value always wins).
25
+ * - Registrations are fiber-scoped (ctx.on listener + ctx.effect disposer),
26
+ * so stopping / updating / unloading the plugin restores the original
27
+ * fetch and removes the listener.
28
+ *
29
+ * Scope notes / honest limitations:
30
+ * - Requests NOT routed to a configured OpenCode provider, or carrying no
31
+ * session id (some auxiliary hand-built calls), pass through untouched.
32
+ * - The header attaches to chat/streaming requests inside an `llm/stream`
33
+ * call. The one-shot model listing used by the Models page
34
+ * (`GET <baseURL>/models`) is a separate flow and does not receive the
35
+ * header.
36
+ * - Injection rides on Node's global `fetch`. If a future dsh version swaps
37
+ * its network stack, the header silently stops being sent (the 400 comes
38
+ * back) — uninstall then. This is an external-plugin stopgap until the
39
+ * provider adapter itself (pi-ai) sends the header.
40
+ */
41
+ /** Plugin display metadata (cordis diagnostics). */
42
+ const name = "dsh-smoothly-opencode-session";
43
+ /**
44
+ * The `llm/stream` waterfall lives on the abstract `llm` service (dsh-llm).
45
+ * Injecting it keeps this plugin PENDING until that service exists, so the
46
+ * waterfall is already registered by its provider when we listen.
47
+ */
48
+ const inject = ["llm"];
49
+ const SESSION_HEADER = "x-opencode-session";
50
+ /**
51
+ * Provider route keys whose requests receive the header. A pi-ai catalog
52
+ * provider keeps its id as the route key, so both built-in OpenCode ids are
53
+ * covered; deployments that serve OpenCode under a custom provider key (e.g.
54
+ * `opencode-go-self`) add it through config.
55
+ */
56
+ const DEFAULT_PROVIDERS = ["opencode", "opencode-go"];
57
+ /** Resolve row config against code defaults (missing keys are never required). */
58
+ function resolveConfig(config = {}) {
59
+ const providers = Array.isArray(config.providers) && config.providers.length > 0 ? config.providers.map((value) => String(value)) : [...DEFAULT_PROVIDERS];
60
+ return {
61
+ providers: new Set(providers),
62
+ mode: config.mode === "uuid" ? "uuid" : "session-id",
63
+ debug: config.debug === true,
64
+ ...typeof config.debugFile === "string" && config.debugFile.length > 0 ? { debugFile: config.debugFile } : {}
65
+ };
66
+ }
67
+ /**
68
+ * Derive the opaque header value for one DSH session id.
69
+ * `session-id` mode returns the raw id (unique per conversation, stable across
70
+ * turns, compaction, retries and restarts); `uuid` mode returns a process-
71
+ * stable random uuid derived once per session id (opaque, resets on restart).
72
+ */
73
+ function headerValueFor(sessionId, mode, table) {
74
+ const raw = String(sessionId);
75
+ if (raw.length === 0) return void 0;
76
+ if (mode !== "uuid") return raw;
77
+ let value = table.get(raw);
78
+ if (value === void 0) {
79
+ value = randomUUID();
80
+ table.set(raw, value);
81
+ }
82
+ return value;
83
+ }
84
+ /** True when an outgoing request already carries the session header. */
85
+ function hasSessionHeader(input, init) {
86
+ const source = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
87
+ if (source === void 0) return false;
88
+ try {
89
+ return new Headers(source).has(SESSION_HEADER);
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ /**
95
+ * Build a patched fetch that injects the header while a store is active.
96
+ * Header precedence mirrors native fetch: when `init.headers` is present it
97
+ * wins; otherwise a Request's own headers are the base. A request that already
98
+ * carries the header is never modified (an existing value always wins).
99
+ */
100
+ function patchFetch(original, als) {
101
+ return function patchedFetch(input, init) {
102
+ const state = als.getStore();
103
+ if (state !== void 0 && !hasSessionHeader(input, init)) {
104
+ const headers = new Headers(init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0));
105
+ headers.set(SESSION_HEADER, state.value);
106
+ return original.call(this, input, {
107
+ ...init,
108
+ headers
109
+ });
110
+ }
111
+ return original.apply(this, arguments);
112
+ };
113
+ }
114
+ /**
115
+ * Wrap a downstream async iterable so every pull executes inside an
116
+ * AsyncLocalStorage store. Async generators and the promises they create
117
+ * inherit the store as long as the generator body is driven from a pull made
118
+ * inside `als.run`, which is exactly what this wrapper does per `next()`.
119
+ */
120
+ function withStore(iterable, store, als) {
121
+ const iterator = typeof iterable[Symbol.asyncIterator] === "function" ? iterable[Symbol.asyncIterator]() : iterable;
122
+ return {
123
+ [Symbol.asyncIterator]() {
124
+ return this;
125
+ },
126
+ async next() {
127
+ return als.run(store, () => iterator.next());
128
+ },
129
+ async return(value) {
130
+ if (typeof iterator.return === "function") try {
131
+ return await iterator.return(value);
132
+ } catch {}
133
+ return {
134
+ done: true,
135
+ value
136
+ };
137
+ },
138
+ async throw(error) {
139
+ if (typeof iterator.throw === "function") return als.run(store, () => iterator.throw(error));
140
+ throw error;
141
+ }
142
+ };
143
+ }
144
+ /** Fire-and-forget append of one debug record; failures only log a warning. */
145
+ function recordDebug(ctx, file, entry) {
146
+ appendFile(file, `${JSON.stringify(entry)}\n`, "utf8").catch((error) => {
147
+ ctx.logger.warn("[dsh-smoothly-opencode-session] debugFile write failed: %s", error?.message ?? String(error));
148
+ });
149
+ }
150
+ function apply(ctx, config = {}) {
151
+ const { providers, mode, debug, debugFile } = resolveConfig(config);
152
+ const als = new AsyncLocalStorage();
153
+ const uuidBySession = /* @__PURE__ */ new Map();
154
+ const originalFetch = globalThis.fetch;
155
+ if (typeof originalFetch !== "function") {
156
+ ctx.logger.warn(`[${name}] globalThis.fetch is unavailable; cannot inject ${SESSION_HEADER}`);
157
+ return;
158
+ }
159
+ const patched = patchFetch(originalFetch, als);
160
+ ctx.effect(() => {
161
+ globalThis.fetch = patched;
162
+ ctx.logger.info("[%s] active for providers [%s] with mode %s", name, [...providers].join(", "), mode);
163
+ return () => {
164
+ if (globalThis.fetch === patched) globalThis.fetch = originalFetch;
165
+ };
166
+ }, `${name}.fetch-patch`);
167
+ ctx.on("llm/stream", (options, next) => {
168
+ if (options === void 0 || options === null || typeof options !== "object") return next();
169
+ if (!providers.has(String(options.provider))) return next();
170
+ const sessionId = options.sessionId;
171
+ if (sessionId === void 0 || sessionId === null) return next();
172
+ const value = headerValueFor(String(sessionId), mode, uuidBySession);
173
+ if (value === void 0) return next();
174
+ let downstream;
175
+ try {
176
+ downstream = next();
177
+ } catch (error) {
178
+ throw error;
179
+ }
180
+ if (downstream === void 0 || downstream === null) return downstream;
181
+ if (typeof downstream[Symbol.asyncIterator] !== "function") return downstream;
182
+ if (debug || debugFile !== void 0) {
183
+ const entry = {
184
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
185
+ provider: options.provider,
186
+ model: options.model,
187
+ session: String(sessionId),
188
+ header: SESSION_HEADER,
189
+ value
190
+ };
191
+ if (debugFile !== void 0) recordDebug(ctx, debugFile, entry);
192
+ if (debug) ctx.logger.info("[%s] streaming provider \"%s\" with %s=%s", name, options.provider, SESSION_HEADER, value);
193
+ }
194
+ return withStore(downstream, { value }, als);
195
+ }, { prepend: true });
196
+ }
197
+ var src_default = {
198
+ name,
199
+ inject,
200
+ apply
201
+ };
202
+ //#endregion
203
+ export { apply, src_default as default, hasSessionHeader, headerValueFor, inject, name, patchFetch, resolveConfig, withStore };
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@karoc/dsh-smoothly-opencode-session",
3
+ "version": "0.1.0",
4
+ "description": "Smoothly OpenCode Session (Smoothly OCS) — 思磨力 OpenCode 会话头: external DeepSeek Harness host plugin that attaches the OpenCode-required x-opencode-session header to model calls routed to OpenCode / OpenCode Go provider routes (stable per-conversation id; fixes 400 MissingSessionID, in effect for OpenCode Go since 2026-09-05).",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/index.js",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README.zh.md",
18
+ "CHANGELOG.md",
19
+ "CONTRIBUTING.md",
20
+ "LICENSE"
21
+ ],
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "bundle": "tsdown --config ./tsdown.config.ts",
29
+ "prepare": "tsdown --config ./tsdown.config.ts",
30
+ "test": "node --test tests/*.test.ts",
31
+ "release:check": "node scripts/release-check.mjs",
32
+ "prepack": "node scripts/release-check.mjs",
33
+ "prepublishOnly": "node scripts/release-check.mjs && tsdown --config ./tsdown.config.ts",
34
+ "postpublish": "node scripts/post-publish-check.mjs"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "keywords": [
40
+ "deepseek-harness",
41
+ "dsh",
42
+ "plugin",
43
+ "host-plugin",
44
+ "opencode",
45
+ "opencode-go",
46
+ "x-opencode-session",
47
+ "missing-session-id",
48
+ "session-affinity",
49
+ "prompt-cache",
50
+ "llm",
51
+ "smoothly",
52
+ "smoothly-ocs",
53
+ "smoothly-opencode-session",
54
+ "思磨力",
55
+ "ocs"
56
+ ],
57
+ "author": "karoc",
58
+ "license": "MIT",
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/karoc/dsh-smoothly-opencode-session.git"
62
+ },
63
+ "bugs": {
64
+ "url": "https://github.com/karoc/dsh-smoothly-opencode-session/issues"
65
+ },
66
+ "homepage": "https://github.com/karoc/dsh-smoothly-opencode-session",
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "devDependencies": {
71
+ "@deepseek-ai/cordis": "^4.0.2",
72
+ "@types/node": "^24.0.0",
73
+ "tsdown": "^0.22.0"
74
+ }
75
+ }