zcode-workbuddy-connect 0.0.0-stage → 1.0.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/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +22 -0
- package/NOTICE.md +49 -0
- package/README.md +264 -2
- package/bin/cli.mjs +8 -0
- package/docs/adr/0001-loopback-provider-for-zcode.md +66 -0
- package/docs/adr/0002-widest-window-and-output-ceiling.md +98 -0
- package/docs/adr/0003-region-by-url-path.md +97 -0
- package/docs/adr/0004-workbuddy-56-at-rest-credentials.md +111 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/docs/verify-endpoint.mjs +162 -0
- package/hooks/service-ensure.mjs +133 -0
- package/package.json +59 -4
- package/skills/workbuddy-models/SKILL.md +107 -0
- package/src/app-version.js +119 -0
- package/src/auth.js +441 -0
- package/src/catalog.js +100 -0
- package/src/cli.js +1010 -0
- package/src/client-identity.js +213 -0
- package/src/config.js +91 -0
- package/src/desktop-credential-protection.js +890 -0
- package/src/loopback.js +40 -0
- package/src/server.js +873 -0
- package/src/upstream.js +762 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zcode-workbuddy-connect",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bring WorkBuddy desktop-app models — domestic and international (GLM-5.3, DeepSeek-V4-Pro, Kimi-K3, MiniMax-M3, Hy3, GPT-5.6, Gemini-3.5-Flash …) — into ZCode as OpenAI-compatible providers, with zero configuration.",
|
|
5
|
+
"description_i18n": {
|
|
6
|
+
"en": "Bring WorkBuddy desktop-app models, domestic and international, into ZCode as OpenAI-compatible providers, with zero configuration.",
|
|
7
|
+
"zh-CN": "把 WorkBuddy 桌面 App 的国内版与国际版模型(GLM-5.3、DeepSeek-V4-Pro、Kimi-K3、MiniMax-M3、Hy3、GPT-5.6、Gemini-3.5-Flash 等)零配置接入 ZCode 的模型供应商。"
|
|
8
|
+
},
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "hawchou1995 (a port of corrinehu/dsh-workbuddy-connect, MIT)"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"homepage": "https://github.com/hawchou1995/zcode-workbuddy-connect",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"workbuddy",
|
|
16
|
+
"codebuddy",
|
|
17
|
+
"openai-compatible",
|
|
18
|
+
"provider",
|
|
19
|
+
"glm",
|
|
20
|
+
"deepseek"
|
|
21
|
+
],
|
|
22
|
+
"skills": "skills",
|
|
23
|
+
"hooks": {
|
|
24
|
+
"SessionStart": [
|
|
25
|
+
{
|
|
26
|
+
"hooks": [
|
|
27
|
+
{
|
|
28
|
+
"type": "process",
|
|
29
|
+
"command": "node",
|
|
30
|
+
"args": [
|
|
31
|
+
"${ZCODE_PLUGIN_ROOT}/hooks/service-ensure.mjs"
|
|
32
|
+
],
|
|
33
|
+
"timeoutMs": 60000,
|
|
34
|
+
"statusMessage": "Starting the WorkBuddy endpoint"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Corrine Hu
|
|
4
|
+
Copyright (c) 2026 workbuddy-connect contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Attribution
|
|
2
|
+
|
|
3
|
+
This project is a port of **[corrinehu/dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect)**
|
|
4
|
+
(MIT, Copyright (c) 2026 Corrine Hu) from the DeepSeek Harness plugin API to
|
|
5
|
+
ZCode's.
|
|
6
|
+
|
|
7
|
+
The upstream project in turn ports its WorkBuddy wire behaviour from
|
|
8
|
+
**[Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api)** (MIT).
|
|
9
|
+
|
|
10
|
+
## What came from where
|
|
11
|
+
|
|
12
|
+
**Brought over from the upstream (behaviour preserved deliberately, because it is
|
|
13
|
+
what makes the endpoint work at all):**
|
|
14
|
+
|
|
15
|
+
- The upstream HTTP client: chat streaming, token refresh, model catalog,
|
|
16
|
+
credit/billing endpoints, error classification
|
|
17
|
+
- The wire quirks: forced `stream: true`, `developer` → `system` role rewrite,
|
|
18
|
+
string-form `tool_choice`, the `X-No-*` / `X-User-Id` / `X-Product` header set
|
|
19
|
+
- The desktop-shaped chat `User-Agent` and its installed-App → saved → fallback
|
|
20
|
+
resolution order
|
|
21
|
+
- The credential model: read the desktop app's sign-in file read-only, refresh
|
|
22
|
+
into a separate plugin-owned copy, prefer identity over expiry
|
|
23
|
+
- The loopback OpenAI-compatible endpoint and its inbound hardening (loopback
|
|
24
|
+
Host, loopback Origin, JSON content type, constant-time bearer check)
|
|
25
|
+
- The promotion/billing display rules, including dropping a rate that can no
|
|
26
|
+
longer be stood behind rather than keep claiming a discount
|
|
27
|
+
|
|
28
|
+
**Rewritten for this port:**
|
|
29
|
+
|
|
30
|
+
- Everything that touched DSH APIs: the provider adapter, the settings card, the
|
|
31
|
+
Composer probe control, the DSH-home path resolution, the atomic-write
|
|
32
|
+
dependency. ZCode has no equivalents, so the provider half became
|
|
33
|
+
configuration and the UI half was dropped.
|
|
34
|
+
- The shim became a standalone service rather than an in-process object.
|
|
35
|
+
- The endpoint bearer became persisted rather than per-process, because the
|
|
36
|
+
client records it in a config file and it must survive restarts.
|
|
37
|
+
- Reasoning-level probing was dropped (see the README).
|
|
38
|
+
- SSE handling was rewritten after testing found three real defects in the port
|
|
39
|
+
— see the git history.
|
|
40
|
+
- Windows is the primary target, so the App-version and credential probes cover
|
|
41
|
+
Windows install roots rather than macOS bundles.
|
|
42
|
+
- The international catalog's User-Agent was CORRECTED relative to upstream:
|
|
43
|
+
the gateway now rejects the App-shaped UA with HTTP 400 (code 12403) and
|
|
44
|
+
requires the CLI-form UA (live-verified 2026-09-18). Upstream's assumption
|
|
45
|
+
no longer holds on today's wire.
|
|
46
|
+
|
|
47
|
+
## Licence
|
|
48
|
+
|
|
49
|
+
MIT throughout. See [LICENSE](./LICENSE).
|
package/README.md
CHANGED
|
@@ -1,3 +1,265 @@
|
|
|
1
|
-
#
|
|
1
|
+
# zcode-workbuddy-connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use the models inside the **WorkBuddy desktop app** from ZCode (or any
|
|
4
|
+
OpenAI-compatible client), with zero configuration.
|
|
5
|
+
|
|
6
|
+
A port of [corrinehu/dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect)
|
|
7
|
+
(MIT) from the DeepSeek Harness plugin API to ZCode's, keeping the original's
|
|
8
|
+
wire behaviour and discarding the parts that have no ZCode equivalent.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
ZCode ──(OpenAI chat-completions)──▶ 127.0.0.1:39271 ──▶ copilot.tencent.com
|
|
12
|
+
local endpoint (WorkBuddy)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why this shape
|
|
16
|
+
|
|
17
|
+
The upstream plugin registered models into DSH's own provider registry and drew
|
|
18
|
+
a settings card in DSH's UI. ZCode has **no "register an LLM provider" plugin
|
|
19
|
+
slot**, so a literal port is impossible. What ZCode *does* have is native
|
|
20
|
+
support for third-party models configured as
|
|
21
|
+
`api.type: "openai-chat-completions"` with a `baseUrl` and API key — and the
|
|
22
|
+
original plugin already contained a loopback OpenAI-compatible shim
|
|
23
|
+
(`src/shim.ts`). Extracting that shim into a standalone service and pointing a
|
|
24
|
+
ZCode provider at it yields something the original could not give: the models
|
|
25
|
+
appear as first-class entries in ZCode's model selector, driving the normal agent
|
|
26
|
+
loop with tool calls, images and streaming.
|
|
27
|
+
|
|
28
|
+
The discarded parts are the ones with no ZCode surface: the settings card, the
|
|
29
|
+
Composer probe control, and the reasoning-level detection feature. Reasoning
|
|
30
|
+
levels now use the efforts the upstream catalog declares, with no probing
|
|
31
|
+
requests (which would have consumed credit with nowhere to show the result).
|
|
32
|
+
|
|
33
|
+
## Layout
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
.zcode-plugin/plugin.json plugin manifest (skills + SessionStart hook)
|
|
37
|
+
skills/workbuddy-models/ operational skill for the agent
|
|
38
|
+
hooks/service-ensure.mjs starts the endpoint at session start
|
|
39
|
+
src/ the service (no dependencies; Node >= 18)
|
|
40
|
+
upstream.js WorkBuddy wire client: chat, refresh, catalog, billing
|
|
41
|
+
auth.js reads the desktop app's sign-in; refreshes into our own copy
|
|
42
|
+
catalog.js model roster + static fallback
|
|
43
|
+
server.js the loopback OpenAI-compatible endpoint
|
|
44
|
+
loopback.js Host/Origin guards
|
|
45
|
+
client-identity.js desktop-shaped chat User-Agent
|
|
46
|
+
app-version.js installed-App version resolution
|
|
47
|
+
config.js runtime paths
|
|
48
|
+
cli.js command-line interface
|
|
49
|
+
bin/cli.mjs entry point
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
node bin/cli.mjs serve # run the endpoint (foreground)
|
|
56
|
+
node bin/cli.mjs status # sign-in, credit, model roster
|
|
57
|
+
node bin/cli.mjs doctor # diagnose credentials/upstream/endpoint
|
|
58
|
+
node bin/cli.mjs refresh # re-pull the roster
|
|
59
|
+
node bin/cli.mjs setup # (re)write the ZCode provider entry
|
|
60
|
+
node bin/cli.mjs service-status # is it reachable; is autostart registered
|
|
61
|
+
node bin/cli.mjs install-service # register a logon autostart launcher
|
|
62
|
+
node bin/cli.mjs uninstall-service # remove it
|
|
63
|
+
node bin/cli.mjs logout # drop our token copy (desktop app untouched)
|
|
64
|
+
node bin/cli.mjs token # print the endpoint bearer
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
1. Put this directory anywhere and add its **path** to ZCode's
|
|
70
|
+
`plugins.dirs` in `%USERPROFILE%\.zcode\cli\config.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{ "plugins": { "dirs": ["<absolute-path-to-this-repo>"] } }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Plugins found this way get the marketplace id `inline` and are enabled by
|
|
77
|
+
default. The listed directory **is** the plugin root — do not nest it.
|
|
78
|
+
|
|
79
|
+
2. Write the provider entries (one per signed-in region):
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
node bin/cli.mjs setup
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`setup` refuses to guess: it writes the roster the upstream actually reports,
|
|
86
|
+
including each model's real context window, output ceiling and vision
|
|
87
|
+
support. It also **validates before writing** — the document is serialised to
|
|
88
|
+
a temp file and run through `docs/validate-provider-config.mjs`, and is only
|
|
89
|
+
renamed into place if it passes. See the strict-schema note below for why
|
|
90
|
+
that gate is not optional.
|
|
91
|
+
|
|
92
|
+
3. Restart ZCode. The `SessionStart` hook starts the endpoint; the models appear
|
|
93
|
+
in the model selector under **WorkBuddy** (CN) and **WorkBuddy AI**
|
|
94
|
+
(international).
|
|
95
|
+
|
|
96
|
+
### WorkBuddy 5.6 encrypted credentials
|
|
97
|
+
|
|
98
|
+
Since 5.6 the desktop app no longer writes its sign-in as plain JSON: both
|
|
99
|
+
token fields arrive sealed in a `{$wbEncrypted:1, envelope}` wrapper opened with
|
|
100
|
+
AES-256-GCM. The at-rest key lives in the app's own (Electron-modified) process,
|
|
101
|
+
so the plugin unlocks a sealed file by running **the app's own Electron binary
|
|
102
|
+
once as plain Node** (`ELECTRON_RUN_AS_NODE=1`) and asking its private
|
|
103
|
+
`workbuddyStorage` binding for the sealed secret. The key is derived with
|
|
104
|
+
`sha256(atRestSecretKey)`, cached in memory only, and never written anywhere.
|
|
105
|
+
|
|
106
|
+
This is automatic on Windows and macOS for the CN app, whose install locations
|
|
107
|
+
are the platform defaults:
|
|
108
|
+
|
|
109
|
+
| Platform | CN (`WorkBuddy`) | International (`WorkBuddy AI`) |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| Windows | `C:\Program Files\WorkBuddy\WorkBuddy.exe` | `C:\Program Files\WorkBuddyAI\WorkBuddyAI.exe` |
|
|
112
|
+
| macOS | `/Applications/WorkBuddy.app/Contents/MacOS/Electron`, falling back to a Spotlight search for the CN bundle id | none — `WORKBUDDY_ELECTRON_BIN` only |
|
|
113
|
+
| Linux | none — `WORKBUDDY_ELECTRON_BIN` only | none — `WORKBUDDY_ELECTRON_BIN` only |
|
|
114
|
+
|
|
115
|
+
**Overriding the binary.** If your app lives somewhere else, point the plugin at
|
|
116
|
+
it:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
set WORKBUDDY_ELECTRON_BIN=C:\path\to\WorkBuddy.exe
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The variable must be set in the environment the **service** starts in — for the
|
|
123
|
+
ZCode `SessionStart` hook, that means ZCode's own environment, so restart ZCode
|
|
124
|
+
after setting it. Setting it only in a terminal does nothing.
|
|
125
|
+
|
|
126
|
+
There is deliberately **no automatic search on Linux**, and **none at all for
|
|
127
|
+
the international app on macOS/Linux**: upstream restricts auto-discovery to the
|
|
128
|
+
one product and platform whose encrypted-credential chain has been verified
|
|
129
|
+
live, and executing a binary that has not been identified is not something a
|
|
130
|
+
credential reader should do on a guess.
|
|
131
|
+
|
|
132
|
+
`node bin/cli.mjs doctor` reports what it found — the file's format
|
|
133
|
+
(`encrypted` is the healthy state for a current install), which Electron binary
|
|
134
|
+
the unlock would use, and, when the unlock fails, a reason naming sizes, key ids
|
|
135
|
+
and exit codes only.
|
|
136
|
+
|
|
137
|
+
The reasoning behind this — and where it deliberately differs from upstream's
|
|
138
|
+
discovery policy — is in `docs/adr/0004-workbuddy-56-at-rest-credentials.md`.
|
|
139
|
+
|
|
140
|
+
### Two regions, two mounts
|
|
141
|
+
|
|
142
|
+
Both regions are served from one endpoint, told apart by the URL the client
|
|
143
|
+
uses — not by renaming models:
|
|
144
|
+
|
|
145
|
+
| Region | Provider | Base URL |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| CN | `workbuddy` | `http://127.0.0.1:39271/v1` |
|
|
148
|
+
| International ("WorkBuddy AI") | `workbuddy-ai` | `http://127.0.0.1:39271/ai/v1` |
|
|
149
|
+
|
|
150
|
+
Seven model ids exist in both regions (`glm-5.3`, `glm-5.2`, `hy3`,
|
|
151
|
+
`hy4-preview`, `deepseek-v4.1-flash`, `kimi-k2.8-preview`, `kimi-k2.6`) with
|
|
152
|
+
different windows and rates. They are kept apart by the mount rather than by an
|
|
153
|
+
id prefix, because ZCode's picker renders the **raw model id and nothing else**
|
|
154
|
+
in each row while grouping rows under a provider header — so `glm-5.3` under
|
|
155
|
+
"WorkBuddy" and `glm-5.3` under "WorkBuddy AI" are already unambiguous to the
|
|
156
|
+
user, while a `wbai:` prefix was pure noise in the one string they read. A
|
|
157
|
+
leading `wbai:` on a chat request is still *accepted* and wins over the mount, so
|
|
158
|
+
an older config keeps routing correctly. See `docs/adr/0003-region-by-url-path.md`.
|
|
159
|
+
|
|
160
|
+
### Limits written into ZCode
|
|
161
|
+
|
|
162
|
+
Both limits are taken at their **maximum**:
|
|
163
|
+
|
|
164
|
+
| ZCode field | Source | Why not the alternative |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `properties.contextWindow` | upstream `maxInputTokens` | `contextWindow.defaultLength` is a softer tier the UI offers and can be up to 3.3× smaller; declaring it makes ZCode compact the conversation early and waste the window. Verified by probe: 312,856 tokens accepted, 1,294,869 rejected. |
|
|
167
|
+
| `optionSpecs.maxOutputTokens.max` | upstream `maxOutputTokens` | This is the ceiling on the value a user may select. There is no `maxTokens` property — see below. |
|
|
168
|
+
|
|
169
|
+
`map` is deliberately left out of `optionSpecs.maxOutputTokens`: the shipped
|
|
170
|
+
`openai-chat-completions` API rule already supplies
|
|
171
|
+
`{'max_completion_tokens': maxOutputTokens}`, so restating it would only add an
|
|
172
|
+
expression-language failure mode. `serve` reports the same window through
|
|
173
|
+
`/v1/models`; `--default-context-window` reverts to the softer number.
|
|
174
|
+
|
|
175
|
+
### The strict-schema trap
|
|
176
|
+
|
|
177
|
+
ZCode parses `provider_config.json` with zod schemas that are `.strict()`, and
|
|
178
|
+
its failure mode is brutal: **one unrecognised key anywhere makes ZCode discard
|
|
179
|
+
the entire personal provider config** and fall back to an empty one — taking
|
|
180
|
+
every other provider you had configured (OpenRouter, DeepSeek, …) with it, and
|
|
181
|
+
surfacing no error. Two keys are easy to get wrong:
|
|
182
|
+
|
|
183
|
+
- **`maxTokens` is not a property.** Output limits live in
|
|
184
|
+
`optionSpecs.maxOutputTokens.max`.
|
|
185
|
+
- **`provider: []` is not a config key.** It belongs to the legacy CLI registry
|
|
186
|
+
in `config.json`, a different document.
|
|
187
|
+
|
|
188
|
+
Both mistakes existed in this codebase and were caught by the validator's first
|
|
189
|
+
run. After any hand-edit, check it yourself:
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
node docs/validate-provider-config.mjs
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Stream lifecycle (why a turn never "just stops")
|
|
196
|
+
|
|
197
|
+
Long turns are where every proxy flaw looks identical: the model goes quiet,
|
|
198
|
+
the spinner keeps turning, nothing errors. Five rules prevent that:
|
|
199
|
+
|
|
200
|
+
| Situation | What the endpoint does |
|
|
201
|
+
|---|---|
|
|
202
|
+
| Upstream silent for 180 s on an open stream | `error` frame (`type: "upstream_stalled"`) + `finish_reason: "network_error"` + `[DONE]`, and the upstream call is aborted. The client retries instead of hanging behind our heartbeat. |
|
|
203
|
+
| Upstream closes without a finish frame | Finishes with `finish_reason: "length"`. A cut answer is reported as cut, never rewritten to `stop`. |
|
|
204
|
+
| Tool calls streamed, finish labelled `stop` | Corrected to `finish_reason: "tool_calls"`; the mirror case (promising `tool_calls` with no call delivered) still degrades to `stop`. |
|
|
205
|
+
| Turn produced nothing at all | An `error` frame so the client retries rather than accepting an empty assistant message. |
|
|
206
|
+
| Client disconnects mid-stream | The upstream request is aborted, so a cancelled turn stops being generated (and billed) immediately. |
|
|
207
|
+
|
|
208
|
+
Watchdog interval: `ZC_STREAM_IDLE_TIMEOUT_MS` (default `180000`). The 10 s
|
|
209
|
+
heartbeats keep the *client's* read timer alive but deliberately do not reset
|
|
210
|
+
the watchdog, which only counts real upstream bytes.
|
|
211
|
+
|
|
212
|
+
## Design decisions
|
|
213
|
+
|
|
214
|
+
| Decision | Why |
|
|
215
|
+
|---|---|
|
|
216
|
+
| Standalone loopback service, not an MCP server | An MCP tool cannot drive ZCode's agent loop; a provider can. Native streaming, tool calls and images come for free. |
|
|
217
|
+
| Zero runtime dependencies | Node 18+ has `fetch`, `AbortSignal.timeout` and `http`. Fewer moving parts to break, and no install step. |
|
|
218
|
+
| Dual region, one service | CN and international (WorkBuddy AI) variants share one endpoint, separated by URL mount: the root (`/v1`) serves CN and `/ai/v1` serves the international region. The mount is what keeps the 7 shared ids (glm-5.3, hy3…) distinct, so the region never has to be encoded into the model id a client displays. A region whose app is not signed in is skipped at `setup` and exposes no models. |
|
|
219
|
+
| Fixed port + persisted bearer | The client records the bearer in its own config, so it must survive restarts. A per-process secret would break on every restart. |
|
|
220
|
+
| Credential copy kept separate from the desktop app | The app's sign-in file is read-only input. Refreshes go to our own state dir, so the plugin can never break the app's login. |
|
|
221
|
+
| Startup folder for autostart | `schtasks` needs elevation on this host (it fails outright); the Startup folder is per-user and needs none. |
|
|
222
|
+
| Heartbeats during silence | Reasoning models think for minutes; the upstream pads that with SSE comments. Dropping them (as the first port did) leaves the client seeing zero bytes and timing out on a working request. |
|
|
223
|
+
| Validate before writing provider config | A stray key does not fail one model, it silently deletes every provider. A temp file plus a schema mirror turns that into a loud error. |
|
|
224
|
+
| Decrypt WorkBuddy 5.6 credentials inside the plugin | Since 5.6 the desktop app seals `accessToken`/`refreshToken` at rest, so the file is no longer readable as plain JSON. The plugin opens them by running the app's own Electron once as Node and reading its private storage binding — never by copying the tokens anywhere new. |
|
|
225
|
+
|
|
226
|
+
## Notes
|
|
227
|
+
|
|
228
|
+
- **Never write to WorkBuddy's own files.** The sign-in file is input only.
|
|
229
|
+
- **No credential ever reaches ZCode's config** other than the local endpoint's
|
|
230
|
+
own bearer. The WorkBuddy token stays in the state directory, in memory, or on
|
|
231
|
+
the wire to WorkBuddy.
|
|
232
|
+
- **Prices are informational.** Rates come from the upstream roster and are
|
|
233
|
+
cached; an expired promotion is dropped (`rateUnknown`) rather than left
|
|
234
|
+
claiming a discount.
|
|
235
|
+
- State lives in `%USERPROFILE%\.zcode-workbuddy-connect\`
|
|
236
|
+
(`ZCODE_WORKBUDDY_CONNECT_HOME` overrides it).
|
|
237
|
+
|
|
238
|
+
## A note on what this talks to
|
|
239
|
+
|
|
240
|
+
The endpoint forwards to WorkBuddy's own internal API (`copilot.tencent.com`),
|
|
241
|
+
using the WorkBuddy desktop app's existing login. That API is **undocumented and
|
|
242
|
+
unofficial** — it is not a public integration surface, and it can change or break
|
|
243
|
+
without notice. This project therefore:
|
|
244
|
+
|
|
245
|
+
- makes **no stability promise** on the wire format, and
|
|
246
|
+
- inherits any terms the WorkBuddy service imposes on its own client.
|
|
247
|
+
|
|
248
|
+
It reads only the app's own sign-in file, never writes to it, and reuses session
|
|
249
|
+
the user already established — there is no bypass, no credential harvesting, and no
|
|
250
|
+
traffic to any host the desktop app does not already talk to. Use it with your own
|
|
251
|
+
account. This is a port of a project that already does the same thing publicly; the
|
|
252
|
+
caveats above are the original's, restated because they matter more than the
|
|
253
|
+
feature list.
|
|
254
|
+
|
|
255
|
+
**Never committed:** the credential copy, the endpoint bearer, and all runtime
|
|
256
|
+
state. See `.gitignore` — state lives in `%USERPROFILE%\.zcode-workbuddy-connect\`, and
|
|
257
|
+
`endpoint.json` there holds a bearer that would let a reader call *your* endpoint.
|
|
258
|
+
|
|
259
|
+
## Licence
|
|
260
|
+
|
|
261
|
+
MIT, following the original. The wire behaviour is itself ported by the original
|
|
262
|
+
project from [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api) (MIT).
|
|
263
|
+
|
|
264
|
+
See [NOTICE.md](./NOTICE.md) for exactly which parts were carried over from the
|
|
265
|
+
upstream and which were rewritten for this port.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Entry point for the zcode-workbuddy-connect CLI. */
|
|
3
|
+
import { main } from '../src/cli.js'
|
|
4
|
+
|
|
5
|
+
main().catch(error => {
|
|
6
|
+
console.error(`zcode-workbuddy-connect: ${error instanceof Error ? error.message : String(error)}`)
|
|
7
|
+
process.exitCode = 1
|
|
8
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ADR 0001 — Expose WorkBuddy models as a loopback OpenAI-compatible provider
|
|
2
|
+
|
|
3
|
+
Status: accepted (2026-09-18)
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
`corrinehu/dsh-workbuddy-connect` is a **DeepSeek Harness (DSH) Cordis plugin**,
|
|
8
|
+
not a Claude Code plugin. It calls DSH APIs to (a) register WorkBuddy desktop-app
|
|
9
|
+
models into DSH's provider registry, (b) draw a settings card and a Composer
|
|
10
|
+
probe control into DSH's UI slots, and (c) host a loopback OpenAI-compatible
|
|
11
|
+
"shim" that applies WorkBuddy's wire quirks (`src/shim.ts`).
|
|
12
|
+
|
|
13
|
+
The task was to port it to ZCode and install it. Inspection of
|
|
14
|
+
`D:\Program Files\ZCode\resources\glm\zcode.cjs` and the installed plugin set
|
|
15
|
+
established that **ZCode's plugin API has no "register an LLM provider"
|
|
16
|
+
capability**. A plugin may contribute skills, commands, agents, hooks and MCP
|
|
17
|
+
servers — none of which can drive ZCode's model selector or agent loop.
|
|
18
|
+
|
|
19
|
+
A literal port is therefore impossible. Something had to be dropped or
|
|
20
|
+
re-shaped.
|
|
21
|
+
|
|
22
|
+
## Decision
|
|
23
|
+
|
|
24
|
+
Extract the shim into a **standalone, dependency-free Node service** exposing an
|
|
25
|
+
OpenAI-compatible endpoint on `127.0.0.1:39271`, and register it with ZCode the
|
|
26
|
+
way ZCode already supports third-party models: an
|
|
27
|
+
`api.type: "openai-chat-completions"` entry in
|
|
28
|
+
`%USERPROFILE%\.zcode\v2\provider_config.json`.
|
|
29
|
+
|
|
30
|
+
The plugin proper contributes three things: the service source, one operational
|
|
31
|
+
skill, and a `SessionStart` hook that starts the endpoint.
|
|
32
|
+
|
|
33
|
+
Discarded, because ZCode has no surface for them: the settings card, the
|
|
34
|
+
Composer probe control, and reasoning-level probing.
|
|
35
|
+
|
|
36
|
+
## Consequences
|
|
37
|
+
|
|
38
|
+
**Gained.** The models are first-class entries in ZCode's model selector,
|
|
39
|
+
driving the normal agent loop with streaming, tool calls and image input. This
|
|
40
|
+
is stronger than the MCP-server alternative, which could only ever have been
|
|
41
|
+
*one tool that asks a model something* — usable, but unable to participate in
|
|
42
|
+
ZCode's agent loop at all.
|
|
43
|
+
|
|
44
|
+
**Lost.** Credit/quota and promotion data is no longer displayed on a card; it
|
|
45
|
+
is reachable through `status` and `doctor`. Reasoning-level detection is gone:
|
|
46
|
+
the plugin now uses the efforts the upstream catalog declares, so no probing
|
|
47
|
+
requests are made. Given probes consume credit and ZCode has nowhere to render
|
|
48
|
+
their result, dropping them is a smaller loss than it first appears.
|
|
49
|
+
|
|
50
|
+
**Constraint inherited.** The provider is only usable while the endpoint is
|
|
51
|
+
running, which is a deployment obligation the DSH original did not have (DSH
|
|
52
|
+
loaded the plugin in-process). Mitigated by the `SessionStart` hook, which is
|
|
53
|
+
self-verifying: it probes the endpoint after spawning and reports plainly when it
|
|
54
|
+
could not start it, rather than leaving the failure to surface as an unexplained
|
|
55
|
+
connection error on the first message.
|
|
56
|
+
|
|
57
|
+
## Alternatives rejected
|
|
58
|
+
|
|
59
|
+
- **MCP server wrapping the models as tools** — cannot drive the agent loop;
|
|
60
|
+
the models would never appear in the model selector.
|
|
61
|
+
- **Patching ZCode's provider registry from a plugin** — the capability does not
|
|
62
|
+
exist in the plugin API, and fabricating it would break on every upgrade.
|
|
63
|
+
- **A per-process bearer** — the original shim used a fresh in-memory secret per
|
|
64
|
+
process, which is correct for a client that resolves it in-process. Here the
|
|
65
|
+
bearer is recorded in ZCode's config file, so it must survive restarts; it is
|
|
66
|
+
generated once and persisted (mode 0600) instead.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ADR 0002 — Declare the widest context window, and cap output via `optionSpecs`
|
|
2
|
+
|
|
3
|
+
Status: accepted (2026-09-18)
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
The port must register each WorkBuddy model with ZCode carrying its real limits.
|
|
8
|
+
Two limits matter, and the second one has a trap in it.
|
|
9
|
+
|
|
10
|
+
**Context window.** The upstream catalog reports two different numbers: a soft
|
|
11
|
+
`contextWindow.defaultLength` (300 000 for many models) and the hard
|
|
12
|
+
`maxInputTokens` (1 000 000 for those same models). It also occasionally ships
|
|
13
|
+
`contextWindow.supportedLengths`, a list of selectable tiers whose maximum
|
|
14
|
+
equals `maxInputTokens`.
|
|
15
|
+
|
|
16
|
+
Declaring the soft default makes ZCode compact the conversation far earlier
|
|
17
|
+
than the model can actually accept, wasting most of the window. A live probe
|
|
18
|
+
confirmed the hard number is the honest one: a 312 856-token prompt was
|
|
19
|
+
accepted while 1 294 869 tokens was rejected with "> 1048576 maximum".
|
|
20
|
+
|
|
21
|
+
**Output ceiling.** ZCode's provider schema lives only as compiled zod inside
|
|
22
|
+
`ZCode\resources\app.asar` (there is no JSON Schema file shipped). Reading that
|
|
23
|
+
schema out of `out/host/index.js` shows the rule object is `.strict()`, and the
|
|
24
|
+
two writable paths are:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
config.properties { contextWindow, inputFormat.{supportsImage,Video,Pdf},
|
|
28
|
+
supportsToolCall, supportsJsonSchemaOutput,
|
|
29
|
+
supportsNativeWebSearch, supportsMidConversationSystem,
|
|
30
|
+
requiresMfjsToolSchema }
|
|
31
|
+
config.optionSpecs { reasoningLevel:{values,map}, maxOutputTokens:{max,map} }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
There is **no `maxTokens` property**. An output limit belongs in
|
|
35
|
+
`optionSpecs.maxOutputTokens.max`, which is the ceiling ZCode enforces on the
|
|
36
|
+
value a user may select — the CLI throws "maxOutputTokens is outside the model
|
|
37
|
+
option range" above it.
|
|
38
|
+
|
|
39
|
+
The trap: because the schema is strict and ZCode's failure mode is to discard
|
|
40
|
+
the **entire** personal provider config and fall back to an empty one, a single
|
|
41
|
+
unrecognised key does not break one model — it silently removes every provider
|
|
42
|
+
the user had (OpenRouter, DeepSeek, …), with nothing surfaced anywhere.
|
|
43
|
+
|
|
44
|
+
## Decision
|
|
45
|
+
|
|
46
|
+
Take the **maximum** for both limits, and write them through the two paths the
|
|
47
|
+
schema actually accepts.
|
|
48
|
+
|
|
49
|
+
- `properties.contextWindow` = `max(contextWindow, ...supportedContextWindows)`
|
|
50
|
+
— the widest window the upstream admits.
|
|
51
|
+
- `optionSpecs.maxOutputTokens.max` = the upstream's own `maxOutputTokens`,
|
|
52
|
+
taken per model rather than guessed. `map` is **omitted**: the shipped
|
|
53
|
+
`openai-chat-completions` API rule already supplies
|
|
54
|
+
`{'max_completion_tokens': maxOutputTokens}`, and a hand-written expression
|
|
55
|
+
with a typo in the option-map mini-language would fail validation and take the
|
|
56
|
+
whole document down with it.
|
|
57
|
+
|
|
58
|
+
Both are gated behind a new `docs/validate-provider-config.mjs`, which mirrors
|
|
59
|
+
the strict schema and now runs on every `setup` write: the document is
|
|
60
|
+
serialised to a temp file, validated, and only then renamed over the real path.
|
|
61
|
+
A rejected document is never written and the offending key is printed.
|
|
62
|
+
|
|
63
|
+
`serve` applies the same intent to `/v1/models` via the catalogue's
|
|
64
|
+
`useMaximumContextWindow` switch, which had existed in `catalog.js` but was
|
|
65
|
+
never wired to anything. `--default-context-window` restores the softer number.
|
|
66
|
+
|
|
67
|
+
## Consequences
|
|
68
|
+
|
|
69
|
+
**Gained.** ZCode plans against the real window instead of the conservative
|
|
70
|
+
one, and the output-budget control is bounded by what the model can actually
|
|
71
|
+
produce. All 36 models carry both values (verified: 0 rules missing
|
|
72
|
+
`maxOutputTokens`).
|
|
73
|
+
|
|
74
|
+
**A latent defect fixed.** The `setup` template for a *missing* config file
|
|
75
|
+
contained a `provider: []` key that is not in the schema — writing it produced a
|
|
76
|
+
document ZCode would discard wholesale. Found by the new validator on its first
|
|
77
|
+
run against a throwaway path.
|
|
78
|
+
|
|
79
|
+
**A host policy recorded, not worked around.** `install-service` writes its
|
|
80
|
+
launcher to the Startup folder, and this host refuses script files there
|
|
81
|
+
(`.vbs`, `.cmd`, `.bat`, `.ps1` all return `EPERM` while `.txt` in the same
|
|
82
|
+
folder succeeds). That is a deliberate security control, so the command now
|
|
83
|
+
reports it plainly and points at the `SessionStart` hook — which is the path
|
|
84
|
+
that actually starts the endpoint, and was verified to bring it up from a cold
|
|
85
|
+
stop.
|
|
86
|
+
|
|
87
|
+
## Alternatives rejected
|
|
88
|
+
|
|
89
|
+
- **Writing `properties.maxTokens`** — not in the schema; this is the exact
|
|
90
|
+
mistake the original comment warned about, and it costs the whole document.
|
|
91
|
+
- **Supplying `optionSpecs.maxOutputTokens.map` as well** — redundant against
|
|
92
|
+
the built-in API rule, and adds an expression-language failure mode for no
|
|
93
|
+
gain.
|
|
94
|
+
- **Trusting `contextWindow.defaultLength`** — understates the window by up to
|
|
95
|
+
3.3× and makes the agent compact early.
|
|
96
|
+
- **Validating in-process instead of via a subprocess** — the validator is
|
|
97
|
+
meant to be runnable by hand for diagnosis; a copy that could drift from the
|
|
98
|
+
schema mirror would be worse than none.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# ADR 0003 — Separate the regions by URL path, not by an id prefix
|
|
2
|
+
|
|
3
|
+
Status: accepted (2026-09-18)
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
The two WorkBuddy regions share **seven** model ids with different windows and
|
|
8
|
+
rates: `glm-5.3`, `glm-5.2`, `hy3`, `hy4-preview`, `deepseek-v4.1-flash`,
|
|
9
|
+
`kimi-k2.8-preview`, `kimi-k2.6`. One id therefore cannot stand for both, and
|
|
10
|
+
something has to distinguish them.
|
|
11
|
+
|
|
12
|
+
The original port did it by renaming: international models were exposed as
|
|
13
|
+
`wbai:glm-5.3`. That prefix did two jobs at once — routing a chat request to the
|
|
14
|
+
right credential store, and keeping the two rows apart.
|
|
15
|
+
|
|
16
|
+
It also leaked into the one place it hurts most. Reading ZCode's renderer out of
|
|
17
|
+
`resources/app.asar` shows the model picker rows render `e.name`, which is the
|
|
18
|
+
**raw model id and nothing else**:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// styles-ou2or4Yg.js, model-row renderer
|
|
22
|
+
jsx('span', { className: `min-w-0 truncate`, title: e.name, children: e.name })
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
So `wbai:` was not an internal wire detail; it was literally the text the user
|
|
26
|
+
read in the model list.
|
|
27
|
+
|
|
28
|
+
The same investigation settled what alternatives exist:
|
|
29
|
+
|
|
30
|
+
- **The picker groups by provider.** Each provider contributes a submenu whose
|
|
31
|
+
header is `providerName`, so `glm-5.3` under "WorkBuddy" and `glm-5.3` under
|
|
32
|
+
"WorkBuddy AI" are already unambiguous, and the closed trigger even shows
|
|
33
|
+
`WorkBuddy/glm-5.3`.
|
|
34
|
+
- **There is no display-name mechanism.** The personal-provider schema is
|
|
35
|
+
`.strict()` with no `name`/`displayName`/`label`/`alias` key, and the
|
|
36
|
+
`settings.modelProvider.modelDisplayName` i18n string has zero references
|
|
37
|
+
outside its own dictionary — dead UI. A model cannot be given a friendlier
|
|
38
|
+
label.
|
|
39
|
+
- **Identity is `(providerId, modelId)`.** Cross-provider id collisions are
|
|
40
|
+
legal and preserved; nothing suffixes or renames ids.
|
|
41
|
+
- **ZCode sends no provider identifier.** No header, no query parameter, no body
|
|
42
|
+
field names the provider, and it never calls `/models` to learn the roster.
|
|
43
|
+
The baseUrl is used essentially verbatim (`<baseUrl>/chat/completions`, with
|
|
44
|
+
one trailing slash trimmed).
|
|
45
|
+
|
|
46
|
+
## Decision
|
|
47
|
+
|
|
48
|
+
Route by **URL path**, and leave the model ids bare.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
CN http://127.0.0.1:39271/v1/chat/completions
|
|
52
|
+
International http://127.0.0.1:39271/ai/v1/chat/completions
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`setup` writes one provider per region with its own `baseUrl` and its own bare
|
|
56
|
+
`modelIds`. The endpoint resolves the variant from the request path
|
|
57
|
+
(`routePath`), so `/v1/models` lists only the CN roster and `/ai/v1/models` only
|
|
58
|
+
the international one — each client sees 16 or 20 bare ids rather than a union
|
|
59
|
+
of 36.
|
|
60
|
+
|
|
61
|
+
A leading `wbai:` on a model id is still accepted and still selects the
|
|
62
|
+
international store, so a client configured the old way keeps working; the
|
|
63
|
+
prefix is stripped before the body goes upstream. `setup` also retires leftover
|
|
64
|
+
`wbai:`-prefixed model rules from the user's document, since they name models
|
|
65
|
+
that no longer exist under that spelling.
|
|
66
|
+
|
|
67
|
+
## Consequences
|
|
68
|
+
|
|
69
|
+
**Gained.** The model list reads as model names again. The region is a property
|
|
70
|
+
of the *connection*, which is where the user configures it, rather than of the
|
|
71
|
+
*name*, which only the display consumed.
|
|
72
|
+
|
|
73
|
+
**Cost.** Two baseUrls instead of one — a client that hard-codes `/v1` for
|
|
74
|
+
everything reaches only the CN region. Accepted because the only such client is
|
|
75
|
+
`setup` itself, which writes both, and the `wbai:` fallback covers hand-written
|
|
76
|
+
configs.
|
|
77
|
+
|
|
78
|
+
**Not a compatibility break for ZCode.** It has no notion of a shared endpoint
|
|
79
|
+
between providers; two providers pointing at two paths is the ordinary shape.
|
|
80
|
+
|
|
81
|
+
## Alternatives rejected
|
|
82
|
+
|
|
83
|
+
- **`api.headers` on each provider rule** — this would be the tidiest signal,
|
|
84
|
+
but it cannot be trusted to reach the upstream client: the header set is
|
|
85
|
+
assembled through several merge layers (`kEt`, `qVr`, `gN6`), and on a
|
|
86
|
+
`127.0.0.1` baseUrl some of them are skipped entirely. Path routing depends on
|
|
87
|
+
none of that.
|
|
88
|
+
- **The `Authorization` bearer** — ZCode does send it, but both providers share
|
|
89
|
+
one endpoint bearer by design; giving each its own would mean two secrets to
|
|
90
|
+
rotate and would still leave the regions indistinguishable to any other
|
|
91
|
+
client.
|
|
92
|
+
- **Renaming one region's ids** (the status quo) — the only place the id is ever
|
|
93
|
+
read by a human is the picker, so the cost lands entirely on the user and the
|
|
94
|
+
benefit is zero.
|
|
95
|
+
- **One provider listing all 36 ids** — impossible: ZCode's `personalModelIds`
|
|
96
|
+
is a flat list, and the two `glm-5.3` entries would collide within a single
|
|
97
|
+
provider (the schema allows only one rule per provider+model).
|