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.
@@ -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
- # Temporary Holding Version
1
+ # zcode-workbuddy-connect
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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).