feat(agents): add ZCode (Z.AI) adapter - #139
ChHsiching wants to merge 55 commits into
Conversation
Pure type-layer expand for the upcoming ZCode adapter (parent #1): - AgentProtocol gains an "app-server" union member (ZCode's JSON-RPC-over-stdio app-server protocol; distinct from "acp" — see ADR-0002 decision 2) - AgentDef gains optional binArgs?: string[] for node-script CLIs that must be spawned as `node <cjsPath> app-server` rather than a standalone exec (ADR-0002 decision 3) No runtime changes: buildArgv and invokeAgent are untouched, so existing agents keep working unchanged. The field is optional (defaults absent) and the new protocol value is not yet handled in the invoke path — that wiring lands in later tickets (T3 discovery, T5 invoke). Mirrored across cli/src/agents-detect.ts and next/src/lib/agents/detect.ts per the repo's manual-sync convention, with parallel type-surface tests in both. Closes #3
… (T1)
Add packages/zcode-protocol/ — the repo's first shared package (ADR-0002) —
holding the foundational config layer the app-server protocol work (T4) and
agent registration (T7) will consume.
- packages/zcode-protocol/: package.json (@html-anything/zcode-protocol,
private, lib exports), tsconfig (emits declarations), vitest config, and
a .gitignore mirroring cli/.
- src/zcode-config.ts: readZcodeConfig() parses ~/.zcode/v2/model-providers.json
via os.homedir() (cross-platform) and returns the saved API-key provider
selection as { provider, model, models[] }. Missing file / malformed JSON /
no api-key provider all return null — never throws. parseZcodeConfig() is
the pure-tested entry.
- src/zcode-config.test.ts: 12 tests — valid fixture, first-key selection,
missing/empty/malformed JSON, malformed-entry skip, and default-path
resolution via os.homedir().
- pnpm-workspace.yaml: add packages/* glob.
- pnpm-lock.yaml: workspace entry for the new package.
Closes #2.
Add per-platform ZCode install discovery to both cli/src/agents-detect.ts
and next/src/lib/agents/detect.ts (mirrored, per the repo's hand-mirror
convention). Probe order, first match wins (see CONTEXT.md → "ZCode
install discovery"):
1. ZCODE_BIN env var (absolute path, else PATH lookup)
2. `zcode` on PATH (Linux .deb/AUR; rare on Windows/macOS)
3. Platform default install path of the .cjs bundle:
Windows : %ZCODE_WINDOWS_APP_INSTALL_DIR%\resources\glm\zcode.cjs
→ C:\Program Files\ZCode\resources\glm\zcode.cjs
macOS : /Applications/ZCode.app/Contents/Resources/glm/zcode.cjs
Linux : ~/Applications/ZCode.AppImage (AppImage mounts the .cjs)
Discovery only — registering ZCode in the AGENTS array is T7 (ADR-0002
decision 3). Returns null gracefully when nothing is found; never throws.
Platform default paths are built with posix/win32 join helpers so they
stay correct regardless of the host running the probe (e.g. a Windows
default path is probed with backslashes even on a Linux CI runner).
Tests: extend cli/src/__tests__/agents-detect.test.ts with a
resolveZcodeBin block of 10 cases following the existing existsSync-mock
pattern — ZCODE_BIN absolute/PATH override, Windows install-dir +
Program Files fallback (+ precedence), macOS default, Linux AppImage,
PATH `zcode` precedence, not-found, and override-beats-default. All 10
green; the 7 pre-existing host-sensitive failures in the file (Unix PATH
tests on a win32 host) are unchanged.
Closes #4.
…ession (T4) Implements issue #5: the three modules that drive one full JSON-RPC turn over a spawned `zcode app-server` child. - zcode-protocol.ts: createZcodeProtocolClient(child) — bidirectional JSON-RPC 2.0 over stdio. request()/onNotification()/respond()/dispose(). dispose() detaches listeners + rejects pending but does NOT kill the child (caller owns the process lifecycle — ADR-0001). - zcode-stream.ts: createZcodeStreamHandler(onEvent) — maps async notification frames (text_delta, status, thinking, tool_use/result, usage, error, conversation_title) to events. Drops unrecognised payloads (security: never forwards requestHeaders/responseHeaders). - zcode-session.ts: startZcodeProtocolTurn({client,cwd,prompt,...}) — drives workspace/upsertModelProvider → setDefaultModel → session/create (or resume) → setMode → subscribe → send, returns {sessionId, unsubscribe}. Auto-responds to interaction/requestProviderRuntimeHeaders with { headersApplied: true }. - internal.ts: shared isRecord/JsonRecord helpers (the package exists to avoid duplicating these across modules — ADR-0002). Tests: protocol unit seam (PassThrough child), stream mapper, session sequence (FakeClient), and an end-to-end integration test driving the full turn through a real createZcodeProtocolClient. 60 tests green. Timeout + abort + dispose-no-kill + late-abort-noop all covered. Closes #5.
Implements issue #6: wires the cli side of the ZCode app-server protocol end-to-end in agents-invoke.ts. - Shared spawn path honours AgentDef.binArgs: spawn(bin, [...binArgs, ...argv]) when the agent defines binArgs, else the current behaviour. The reported {type:"start"} argv now reflects the full argv. Optional field, so all existing adapters (no binArgs) are unaffected. (ADR-0002 decision 3.) - New "app-server" protocol branch invokeAppServerAgent: spawns `node <cjs> app-server` (binArgs from the AgentDef), wraps the child with createZcodeProtocolClient, drives one turn via startZcodeProtocolTurn, and bridges the protocol stream into InvokeEvent: text_delta -> {type:"delta"} write tool_use -> rescueHtmlFromToolUse -> {type:"html"} final-result -> {type:"done", code:0} error/exit -> {type:"error"} Owns the child lifecycle (ADR-0001): dispose + kill on turn end, stream cancel, child close, and abort. Distinct from the "acp" branch — ZCode's wire format is not the ACP JSON-RPC the hermes/kimi family speaks (ADR-0002 decision 2). - cli depends on @html-anything/zcode-protocol (workspace:*); deep imports for readZcodeConfig / createZcodeProtocolClient / startZcodeProtocolTurn. Tests (agents-invoke.test.ts): end-to-end seam over a mocked spawn + PassThrough child whose stdin is scripted to auto-respond to the 6-method JSON-RPC turn. Covers binArgs spawn + start.argv, text_delta->delta, write tool_use->html, final-result->done, session/create rejection-> error, and no-saved-provider short-circuit. 6 new tests; existing invoke tests unaffected. cli typecheck clean. Full cli suite: 137 pass (was 131; +6), 16 fail — all pre-existing win32 host-sensitive cases, unchanged from baseline.
Mirror cli's T5 (922508b) into the next app: next/src/lib/agents/invoke.ts gets the same "app-server" protocol branch + binArgs spawn splicing, importing the protocol layer from @html-anything/zcode-protocol (shared package, no protocol duplication). Keeps cli and next in sync per the repo's dual-copy convention (Q8). - next/invoke.ts: route def.protocol === "app-server" to invokeAppServerAgent before the argv/argv-message/stdin logic; splice def.binArgs as leading argv in the argv branch's spawn + start.argv; add invokeAppServerAgent driving one JSON-RPC turn via createZcodeProtocolClient + startZcodeProtocolTurn, bridging text_delta/tool_use/usage/status/error into InvokeEvent, with abort + cancel teardown. Adopts next's spawn convention (quoted bin + windowsVerbatimArguments). - next/argv.ts: export rescueHtmlFromToolUse so the app-server branch reuses the canonical HTML-rescue logic instead of duplicating it. - next/package.json + pnpm-lock.yaml: add @html-anything/zcode-protocol workspace dep. - next/__tests__/invoke.test.ts: mirror cli's 6 app-server cases + 2 argv regression guards; runs under @vitest-environment node (happy-dom interferes with vi.mock on node: builtins). typecheck green; next tests 169 pass / 17 pre-existing win32-tar fails (baseline unchanged). Closes #7.
Implements issue #8: adds ZCode as a first-class agent in both cli and next AGENTS arrays, tying together T3's discovery, T1's config reader, and the T5/T6 invoke support. - New AGENTS entry in cli/src/agents-detect.ts and next/src/lib/agents/ detect.ts: id 'zcode', label 'ZCode', vendor 'Z.AI', envOverride 'ZCODE_BIN', protocol 'app-server', bin 'node', binArgs [ZCODE_CJS_SENTINEL, 'app-server']. binArgs carries the ADR-0002 decision 3 node-script leading argv; ZCODE_CJS_SENTINEL is a shared exported constant ("<resolved-zcode-cjs>") so a typo in the invoke substitution can't silently spawn the literal. - detectAgents() gains an app-server branch: availability is driven by resolveZcodeBin() (the CLI is a .cjs bundle, not a standalone exec), so the generic PATH scan can't falsely report node as the install. On a hit returns available:true with path=<cjs>, resolvedBin='node', and unsupported undefined — app-server IS implemented (T5/T6), so unlike the acp/pi-rpc family it is never flagged unsupported. No install → available:false, no throw. - fallbackModels are read from ~/.zcode/v2/model-providers.json via T1's readZcodeConfig, gated on availability (don't touch the config file for an agent that can't run). DEFAULT_MODEL always first; saved provider's GLM models appended so the picker can switch variants. Static floor is [DEFAULT_MODEL]. - cli/src/agents-invoke.ts and next/src/lib/agents/invoke.ts substitute the ZCODE_CJS_SENTINEL in def.binArgs with resolveZcodeBin() at spawn time, so ZCode runs as 'node <real-cjs-path> app-server' end to end. Tests: agents-detect.test.ts (cli) and detect.test.ts (next) gain a ZCode registration block — spec fields, available=true on install hit (path set, resolvedBin 'node', unsupported undefined), available=false with no throw when absent, fallbackModels from saved provider, [DEFAULT_MODEL] floor. next/detect.test.ts moves to @vitest-environment node so process.env / platform stubs reach resolveZcodeBin()/resolveOnPath() (the happy-dom default provides a synthetic process). agents-invoke.test.ts (cli) and invoke.test.ts (next) drop the now-obsolete temporary ZCODE_DEF push/pop (ZCode is registered) and stub ZCODE_BIN so the sentinel resolves to the test .cjs path. cli + next typecheck clean. Full cli suite 142 pass (was 137; +5), 16 fail — all pre-existing win32 host-sensitive, unchanged from baseline. Full next suite 175 pass (was 169; +6), 17 fail — pre-existing.
T8 end-to-end validation against the real ZCode app-server (v0.16.1)
surfaced five protocol-layer bugs that the mock-based T4 tests missed.
All five block the adapter from talking to the real binary; all fixed
and re-verified, plus the README/badge work T8 asked for.
Protocol fixes (packages/zcode-protocol/):
- strip jsonrpc:"2.0" envelope — server rejects it (-32600)
- provider must be an object, not a bare id string — send full
ZcodeProviderRecord (providerId/kind/apiKey/models/baseURL)
- setDefaultModel model must be {modelId, providerId}
- auto-reply session/requestRuntimePreferences with
{nativeSearchEnhancementsEnabled:false} or session/create hangs
- resolveProviderKind picks openai-compatible+baseURL for vendor
gateways (anthropic/openai kinds hard-code the official hosts and
reject vendor keys)
Invoke fix (next/src/lib/agents/invoke.ts):
- quoteWindowsArg so C:\Program Files\ZCode\... survives shell:true
on win32 (cmd.exe was splitting the path on the space)
README.md + README.zh-CN.md:
- ZCode row in the agents table (detection binary + invocation)
- agent badge 9 -> 10, hero list updated, parity across both files
Tests updated to assert the real server schema (the mock-based tests
passed before because the mock didn't validate). 64 tests pass.
Note: the full text_delta artifact stream for GLM Coding Plan (OAuth)
users is blocked on a separate capability gap (encrypted OAuth token in
credentials.json is not injected by the headless app-server). API-key
providers work end-to-end; the pipeline reaches the model API.
Without ELECTRON_RUN_AS_NODE=1 the zcode.cjs child initializes Electron components and hangs at boot, answering no JSON-RPC frame. Merge the env var into envFor(...) at both invokeAppServerAgent spawn sites (cli + next) so the rest of the process env survives and the child boots as a pure Node protocol server. Scoped to the app-server branch — other agents (claude/codex/gemini/...) must not inherit it. The cli + next spawn-mock tests assert the env var is present (pins the fact without a real server). A gated live check confirms the child now answers session/list in ~2s instead of hanging (proof in .scratch, OUT of PR). Refs #11 (parent spec #10). ADR-0004.
…tes (T10, #13) ADR-0004 live-proved the zcode app-server child self-authenticates from the user's logged-in state (it resolves the BigModel Coding Plan entitlement at boot). The committed readZcodeConfig() → providerSelection → workspace/upsertModelProvider relay was therefore dead weight, and worse: it was the root of the "Coding Plan can't be served" failure. The old parseProvider rejected entries with apiKey:"" — exactly what Coding Plan entries legitimately have — so it picked the wrong provider entirely. Deleting the relay dissolves the gap; the child was always able to serve Coding Plan users, we were feeding it the wrong way. This collapses the adapter to the claude/codex shape (spawn → drive → parse, no credential handling), enforcing the orchestrator principle structurally — there is no config-reading code left to accidentally start relaying keys. Changes: - Delete packages/zcode-protocol/src/zcode-config.ts entirely (readZcodeConfig/parseZcodeConfig/parseProvider + ZcodeConfig/ ZcodeProviderRecord/ZcodeApiKey — all credential-bearing) and its test. - Drop workspace/upsertModelProvider + workspace/setDefaultModel and the providerSelection option from zcode-session.ts. Turn sequence is now create/resume → setMode → subscribe → send. - Repoint package.json exports `.`/main/types to zcode-protocol.js; drop the ./zcode-config export. - Drop the readZcodeConfig() call + "no saved provider" error branch from cli/agents-invoke.ts and next/invoke.ts invokeAppServerAgent. - Delete zcodeModels() from cli/agents-detect.ts and next/detect.ts; the app-server branch uses AgentDef.fallbackModels verbatim (the static [DEFAULT_MODEL] floor), like every other agent. Q1 resolution (live-probed, 17 candidate protocol methods tried against a booted authed child — all returned -32601 Method not found): there is no JSON-RPC method that returns a model list, and a credential-free read of ~/.zcode/v2/model-providers.json would leak cross-provider models (the real file lists Anthropic/OpenAI/Google/xAI entries from the user's other providers). So fallbackModels comes from the static [DEFAULT_MODEL] floor — send no --model, let the child's resolved entitlement win. Probe evidence lives in .scratch/ (OUT of PR). Tests pin the structural absence: cli/next agents-invoke.test.ts capture the wire and assert sentMethods contains no upsertModelProvider/ setDefaultModel and the exact turn is create→subscribe→send. zcode-session.test.ts + zcode-turn.integration.test.ts apply the same double-pin (exact toEqual sequence + .not.toContain) at the protocol layer. Detect tests assert models === [DEFAULT_MODEL]. zcode-protocol: 48/48 tests pass. next+cli typecheck clean. Agent tests green modulo pre-existing win32 host-sensitive PATH failures (identical on clean HEAD dbdbb8a — zero regressions introduced). Refs #13 (parent spec #10). ADR-0004.
…11, #14) 13 live probes against the real zcode.cjs app-server (ELECTRON_RUN_AS_NODE=1, bare JSON-RPC frames, no mocks) closed gaps 3+4 and DISPROVED #13's premise. The #13 finding that 'the child self-resolves its model, so the provider relay is dead' was an unverified extrapolation: #13's probe only confirmed session/list (auth) and the session/send schema, never a fresh create→send turn (ADR-0004's own open-follow-up flagged this). #14's probes closed that loop and proved: - session/list works → LOGIN self-authenticates (ADR-0004 auth finding TRUE). - Fresh session/create FAILS with 'Model config is missing' unless the workspace has a configured default model first. The relay #13 deleted (upsertModelProvider + setDefaultModel) is REQUIRED once-per-boot before the first create. Restore it as ensureWorkspaceModel. - Full turn works WITH the relay reading ~/.zcode/v2/config.json (the GUI's RESOLVED config, where the Coding Plan apiKey is non-empty — NOT the model-providers.json template the deleted code read). Probe-12 captured an actual Coding Plan model reply (PROBE_E2E_OK) with a real final-result. - Relay is once-per-boot, not per-session (2nd create needs no relay). - session/resume of an existing session needs NO relay (session carries model). The relay is model SELECTION (left-pocket → right-pocket), not credential grafting — orchestrator principle holds. User-approved direction. Live-confirmed Zod schemas (encoded into the code): - session/create: { workspace: {workspaceKey, workspacePath} }; workspaceKey is the FULL cwd (real sessions carry the full path, not od-<basename>). - session/resume: { sessionId } (workspace ignored). - session/send: { sessionId, content } (Q3 resolved — only two required). - session/subscribe: deliveryKind enum desktop-continuous|web-remote-replayable. - session/setMode: mode enum plan|build|edit|yolo|auto. - workspace/upsertModelProvider: provider {providerId, kind, apiKey:{source, value}, models:[{modelId}], baseURL?}. - workspace/setDefaultModel: model {providerId, modelId}. Vestigial handlers: - interaction/requestProviderRuntimeHeaders — REMOVED (never issued across 2 full turns, probes 3+12). - session/requestRuntimePreferences — KEPT (issued during create+send; reply {nativeSearchEnhancementsEnabled:false}, exact spelling confirmed probe-13). Changes: - packages/zcode-protocol/src/zcode-config.ts: restored config reader, corrected to read v2/config.json (the resolved GUI config), mapped to the live upsert schema. - packages/zcode-protocol/src/zcode-session.ts: + ensureWorkspaceModel (once-per-boot relay); workspaceKey defaults to full cwd; session/resume drops workspace; removed vestigial headers handler; extracted shared makeRequester factory. - packages/zcode-protocol/src/zcode-real-server.integration.test.ts: NEW — real-server integration test, gated to skip when binary absent. Covers boot+auth (session/list) + a full relay→create→subscribe→send→reply round-trip. - zcode-turn.integration.test.ts → zcode-turn-composition.test.ts: renamed (it was a mock-composition test, not a real integration); 'integration' name now belongs to the real-server test above. Corrected frames to real schemas. - zcode-session.test.ts / zcode-config.test.ts: corrected to assert against the live Zod schemas; + ensureWorkspaceModel tests. - cli/src/agents-invoke.ts + next/src/lib/agents/invoke.ts: call ensureWorkspaceModel once per booted child before the turn. - cli + next invoke tests: assert the relay frames + the no-provider error path; mock readZcodeConfig so tests don't touch disk. zcode-protocol 64 pass / 1 skip (real-server test passes on this host); cli 143 pass (16 pre-existing win32 host-sensitive failures = baseline); next 175 pass (baseline + pre-existing binArgs-quote failure); typechecks clean. /code-review passed both axes after fixing the silent-return (now ctx.skip) and collapsing the speculative resolver branches.
html-anything is an external process; on a clean Windows host `where node` finds nothing (only ZCode.exe exists), so the app-server spawn was un-runnable outside a ZCode session. resolveZcodeBin() located the .cjs bundle but not the node driver the .cjs runs under. Strategy (live-proven on the clean host 2026-08-10, not guessed): the ZCode install tree ships NO node.exe — only ZCode.exe (the Electron app). So resolveZcodeNodeBin() probes: ZCODE_NODE_BIN env → `node` on PATH → the ZCode Electron executable (driven under ELECTRON_RUN_AS_NODE=1, which the spawn branch already sets per #11). A live `ZCode.exe <zcode.cjs> app-server` spawn with that env booted in ~1s and answered JSON-RPC frames. - cli/agents-detect.ts + next/detect.ts: add resolveZcodeNodeBin() + defaultZcodeElectronExePaths() (per-platform exe paths, separator discipline mirroring defaultZcodeCjsPaths). - cli/agents-invoke.ts + next/invoke.ts: app-server branch resolves the bin via resolveZcodeNodeBin() when no binOverride is set; reconciles with resolveZcodeBin() (ZCODE_BIN owns the .cjs, ZCODE_NODE_BIN owns the node — disjoint, no overload). Degrades with a clear error naming the three knobs (install Node / ZCODE_NODE_BIN / install ZCode) when nothing resolves. - detectAgents(): resolvedBin now reports the real node driver (or the Electron exe) instead of the literal "node", so the picker shows what will spawn. - cli app-server spawn: quote the bin + argv on win32 (mirrors next), so the space-bearing resolved Electron-exe / zcode.cjs paths survive cmd.exe. Tests: 22 new (11 cli + 11 next) covering the discovery function, the Electron-exe fallback in the invoke branch, and the graceful-degradation error. typecheck + full suite green vs the host-sensitive baseline (cli 16 / next 18 pre-existing failures, unchanged).
The app-server spawn branch quotes every argv element via quoteWindowsArg on win32 (matching cli's app-server branch), because the child runs through a shell. The next invoke test asserted an UNquoted argv, so it failed on win32 while the equivalent cli test passed. Test-only fix: make the assertion expect the quoted form on win32, matching cli. Closes the last failing criterion of #14 (corrected mock-based protocol unit tests assert against the real spawn shape).
The ZCode adapter's protocol/invoke/API layers (T1-T12) all emit protocol: "app-server", but the frontend PROTOCOL_KEY tables only indexed 5 old protocols. When ZCode was detected (available:true), AgentCard crashed at proto.tone lookup — 'Cannot read properties of undefined (reading tone)'. This is the spec-omitted slice surfacing when the app was actually run end-to-end. Add "app-server" to: - AgentInfo.protocol union (store.ts) — now the two Record<protocol> maps are exhaustively checked, so a future protocol can't silently crash again - both PROTOCOL_KEY definitions (welcome-modal + settings-modal) with tone:"ok" (fully implemented, not 'warn') - DictKey type + en + zh dictionaries (protocol.appServer) Live-verified: GET / returns 200, welcome modal renders ZCode as an installed agent with badge 'app-server · JSON-RPC' (non-warning), no client-side TypeError. Closes #15
…aseline (#17) T8 wired quoteWindowsArg into the SHARED argv spawn path in next/src/lib/agents/invoke.ts, so every argv-protocol agent (deepseek-tui, openclaw) had each argv element double-quoted on Windows — a behaviour the `main` baseline never had and a latent prompt-metacharacter surface (argv-message agents pass the prompt through argv; quoteWindowsArg doesn't escape embedded quotes or cmd metacharacters like &/|/^). Restore the baseline: the shared argv spawn passes argv verbatim; per-element quoting stays in the app-server (ZCode) branch only, where spaced Windows paths (C:\Program Files\ZCode\...\zcode.cjs) genuinely need it. The bin quoting (`useShell ? "${bin}" : bin`) is unchanged — it predates this branch and is required for .cmd/.bat shims. Asymmetry note: cli/src/agents-invoke.ts was never affected — its shared branch was always bare (T8 only touched next's mirror; cli gained quoteWindowsArg in T12, directly inside the app-server branch). So only next's prod code needed the fix; cli gets just the pinning test. Tests: add a regression case to each mirror asserting an argv-protocol agent (deepseek-tui) spawns with bare argv elements on a win32-mocked host (no per-element quoting), pinning the 'other agents untouched' guarantee. Existing ZCode app-server spawn tests still pass (quoting preserved in that branch). typecheck green (cli + next); 0 new test regressions vs the win32 baseline (next 17 / cli 16 pre-existing host-sensitive failures).
T15 / ADR-0005 decision 3: resolveZcodeNodeBin()'s null return was dead
code. detectAgents() reports zcode available only when zcode.cjs was
found, zcode.cjs existing ⟺ ZCode installed ⟺ the sibling Electron exe
exists, so the third fallback (defaultZcodeElectronExePaths) always hits
for any UI-driven caller. The defensive error implied users must install
Node.js — a false implication, since ZCode's bundled Electron is the
intended driver.
- cli/src/agents-detect.ts + next/src/lib/agents/detect.ts: tighten
resolveZcodeNodeBin() return type string|null → string. The 3-step
probe chain (ZCODE_NODE_BIN → node on PATH → Electron exe) is
unchanged; the Electron-exe path is now the terminal fallback (returns
the canonical install location on the orphaned-.cjs miss path, letting
the spawn's own ENOENT surface the real problem).
- cli/src/agents-invoke.ts + next/src/lib/agents/invoke.ts: delete the
'if (!bin) { error "could not find a node binary..." }' branch from
the app-server resolution. Only the binOverride-missing error remains
(a user-supplied path that does not resolve is a real, reachable typo).
- detect.test.ts (cli + next): assert resolveZcodeNodeBin() returns a
non-empty string when the Electron-exe fallback exists; the resolvedBin
detection assertion no longer coalesces to the literal 'node'.
- invoke.test.ts (cli + next): drop the now-contradictory 'no node AND
no Electron exe' error test (it pinned the removed unreachable branch);
add a beforeEach mockSpawn reset to the next argv-regression describe
block so the count assertion is isolated from the app-server block.
Verified: typecheck green (cli + next); 0 new test regressions vs the
16/17 win32 baseline; 'could not find a node binary' absent from
non-test source (grep exit 1).
Replace ZCode's single-entry [DEFAULT_MODEL] picker with a dynamic list read
at detect time from ~/.zcode/v2/config.json — every enabled provider's models
(no systemDisabledReason). Plumbed through to session/create as
model:{providerId, modelId} when the user picks a non-default model. Live-
proven: session/create accepts and binds the nested model object
(contextWindow 1M→200K on GLM-5.2→GLM-5-Turbo); session/send rejects model
fields (create-time concern). See ADR-0005 decision 4.
- packages/zcode-protocol: new zcode-model-picker reader (credential-free;
filters enabled && !systemDisabledReason; derives defaultProviderId +
defaultModelId from setting.json's modelProviderFamilySelectedKeys).
startZcodeProtocolTurn gains optional model? param, carried on
session/create only.
- detect (cli+next): ModelOption gains optional providerId; ZCode detection
builds [DEFAULT_MODEL, ...pickerModels] when available, gated on
availability (unavailable keeps the static floor).
- invoke (cli+next): resolveZcodeTurnModel resolves the user's per-agent pick
into {providerId, modelId}, preferring the GUI's selected provider when the
model id is ambiguous across providers; default/absent/not-found → no model
field (workspace default applies).
Tests: picker reader (11), session driver model threading (3), detect dynamic
models + unavailable fallback (cli+next), invoke model-on-create / model-
omitted / provider-disambiguation (cli+next). All green; no new regressions
vs the win32 baseline (next 17 / cli 16 pre-existing host-sensitive failures
unchanged).
Live UI verified: /api/agents returns DEFAULT + GLM-5.2/GLM-5-Turbo/glm-5v-
turbo (bigmodel) + GLM-5.2/GLM-5-Turbo (bigmodel-coding-plan); a live
session/create frame carried model.modelId === "GLM-5-Turbo" and the turn
completed with contextWindow 200000 (GLM-5-Turbo bound).
…ow-up)
The model-picker filter was reading only `enabled && !systemDisabledReason`,
which let placeholder providers like `builtin:bigmodel` (enabled:true, but
apiKey:"") surface their models. Those models can't actually run headlessly,
and because the same modelId (GLM-5.2 / GLM-5-Turbo) exists under both the
placeholder and the usable Coding Plan provider, the picker produced
duplicate ids — breaking ModelPicker's `key={id}` / `active = id === modelId`
uniqueness contract (visible as repeated buttons and multi-button highlight).
Align the filter with ensureWorkspaceModel's relay reader (zcode-config.ts):
a provider is usable iff enabled + no systemDisabledReason + recognized kind +
non-empty apiKey. Extracted isUsableProvider() so both the picker list and the
default-selection highlight share one filter (no drift). ModelPicker itself is
unchanged — the fix is purely in the ZCode data layer, restoring the
"id-unique" convention every other agent already meets.
Live-verified: /api/agents now returns DEFAULT + GLM-5.2 + GLM-5-Turbo (from
builtin:bigmodel-coding-plan only); glm-5v-turbo and the empty-key provider's
duplicates are gone. 12 picker tests green.
#21) The ZCode app-server branch's onEvent handler dropped non-HTML tool_use and tool_result events, so during the model's tool window (e.g. a multi-second WebSearch) the SSE stream emitted zero bytes — freezing the UI with no indication the agent was still working. Map non-HTML tool_use to {type:'meta', key:'status', value:'🔍 <name>'} and tool_result to {type:'meta', key:'status', value:'✓ <name>'}. meta is already in the InvokeEvent union (openclaw emits it for model/session/ result), so this adds no new event type and no contract change. The frontend ignores meta today; the events now flow through the SSE stream providing byte-level keepalive and making progress available to a future frontend improvement with no further adapter work. The tool_result payload carries only toolUseId (no name), so the name is recovered from a toolNamesById map populated by the preceding tool_use, falling back to a bare '✓' when untracked. thinking_* and conversation_title remain dropped (high frequency, weak UX value — would flood the stream, per spec #20 grilling). Scope: ZCode app-server branch of next/src/lib/agents/invoke.ts only. argv branch, SSE routes, frontend readers, and @html-anything/zcode- protocol package are unchanged. Tests cover tool_use→meta, tool_result→meta (matched + orphan name), and the thinking_delta drop, in the existing describe('invokeAgent — app-server protocol branch (ZCode)') block. Refs #20 (parent spec, N1). Closes #21.
#22) The ZCode app-server branch waits indefinitely after session/send returns for a terminal usage/status:completed event to call finish(). If the model's turn never completes (stuck tool, runaway reasoning, dropped terminal event), the SSE stream stays open forever, emitting nothing — the frontend spinner hangs with no recovery. Install a SILENCE timer (not a hard turn cap) in invokeAppServerAgent: - Arm after the turn driver resolves (session/send returned). NOT earlier — the once-per-boot model relay can legitimately take seconds and must not be on the silence clock. - Reset on EVERY onEvent call, as the first statement before any guard, so any event (including the dropped thinking_delta — the model's liveness signal during deep reasoning) refreshes the clock. This distinguishes 'model working slowly' from 'turn genuinely dead'. - Fire after 180s of zero onEvent calls: emit {type:'error', message:'zcode turn went silent for 180s...'} + finish(1). - Disarm in finish(), teardown(), and cancel() — no stray post-teardown fire. Scoped to the app-server (ZCode) branch only; argv branch, SSE routes, frontend, and @html-anything/zcode-protocol unchanged. Tests (vi.useFakeTimers + advanceTimersByTime, no real waiting): - slow-but-alive: thinking_delta @0s+@179s + usage @200s → done 0, no error - genuine-silence: 181s of zero events → /went silent/ error + done 1 - reset-on-tool: tool_use @170s resets → error at ~350s, not ~180s - teardown hygiene: child close mid-turn → no stray fire after teardown Fixes #22.
…llow_project) The ZCode app-server issues interaction/requestPermission as a server->client JSON-RPC request whenever the model calls a tool flagged 'has side effects and requires approval' (WebSearch, web-fetch, Bash). The child BLOCKS on the reply: until the client responds with one of the request's options[*].response, no further model events (text_delta, usage, status:completed) are emitted -- the turn appears 'silent' to the adapter. This is the true root cause of the SSE tool-silence stall that #21 (meta keepalive) papered over and #22 (silence timer) detected: the model isn't dead, it's waiting for tool approval the headless adapter never sends. Install an auto-responder in the turn driver's notification listener (same site as session/requestRuntimePreferences), selecting allow_project by optionId priority (allow_project > allow_once > deny). allow_project wins deliberately: it aligns with the GUI's 'Always allow in this project' UX and its permissionUpdates.addRules payload lets ZCode persist the rule so subsequent same-tool calls in the turn/project do NOT re-trigger the request (avoids re-stalls). Fallbacks: allow_once when allow_project is absent; deny (with a logged warning, no throw) when neither allow option is present. The #22 silence timer and #21 meta keepalive stay as-is -- they are NOT reverted; the timer correctly detects genuine stalls and after this fix simply stops firing for the tool-approval case (tool_result events resume, resetting the clock). Tests: 2 new scenarios -- (1) a frame with all three options in non-priority order asserts the allow_project response is sent with the matching id; (2) a frame with only allow_once + deny asserts the allow_once fallback. Selection is by optionId (order-independent). Verified: pnpm -F @html-anything/zcode-protocol test (81 pass, 1 skip); build (dist regenerated); pnpm -F @html-anything/next typecheck green. Fixes #23.
…op nameless noise Three follow-up improvements to the #21 tool-event → meta status mapping, all surfaced during #23's live verification: A. Drop emoji from tool-status log lines; match the panel's natural- description convention. '🔍 WebSearch' → '调用工具 WebSearch'; '✓ WebSearch' → '工具 WebSearch 完成'. Aligns with existing log lines ('收到首个 HTML 片段', 'agent 进程退出 (exit=0)', '准备调用 zcode …'). B. Read toolName directly off the result frame instead of relying solely on a tool_call→result Map reverse-lookup. Live probes (#23) proved the kind:'result' frame carries its own toolName field; the prior code ignored it and reverse-resolved via toolNamesById, which produced nameless '✓' entries whenever a tool_call arrived late or out of order (the recurring bare-✓ noise in earlier deck runs). The kind: 'tool_result' commit-anchor frame is intentionally dropped — it adds no content beyond kind:'result' and forwarding both duplicated every 'tool done' line. C. Emit nothing for a tool_result whose name cannot be resolved (neither on the event nor in the Map). A nameless '✓' line is noise worse than no line at all. Tests: 2 new stream-handler tests (forwards toolName on result frame; drops kind:tool_result commit anchor). invoke.test.ts updated for the new wording + the nameless-result-emits-nothing behaviour. zcode-protocol 83 pass/1 skip; next invoke 23 pass; next typecheck clean. Live-verified in IAB (deck-guizang-editorial, ZCode+GLM-5-Turbo): '调用工具 mcp__chhsich-web-search__web_search' ×2 + '工具 … 完成' ×2 (1:1 per call, no duplicates, no bare ✓), exit=0, 35.6 KB output.
…le (#24) On Linux ZCode ships as an AppImage with zcode.cjs packed inside, reachable only while mounted. The old Linux branch guessed a version-less ~/Applications/ZCode.AppImage that never matched the real download, so ZCode showed unavailable even when installed + logged in. Per ADR-0007: Discovery (detect.ts): read the XDG .desktop entry ZCode writes on first GUI launch (~/.local/share/applications/zcode.desktop, Exec= line) instead of guessing a filename. parseZcodeDesktopExec is a pure quote-aware tokenizer (handles paths with interior spaces); discoverZcodeAppImage is thin I/O glue. resolveZcodeBin / resolveZcodeNodeBin step 3 on Linux calls discoverZcodeAppImage (priority ZCODE_BIN -> zcode on PATH -> .desktop Exec=). defaultZcodeCjsPaths() Linux branch returns [] (the .cjs lives inside the mount). Lifecycle (invoke.ts): on Linux, mountZcodeAppImage spawns `AppImage --appimage-mount` before the app-server child, reads the FUSE mount point from stdout (5s bounded timeout), and the cjs argv becomes <mountPoint>/resources/glm/zcode.cjs. The mount child is killed on teardown (child death), cancel, mount failure, mount timeout, and abort-during-mount (signal wired into the helper). Per-turn mount+unmount keeps the model stateless, matching Win/macOS. Windows/macOS paths unchanged (Linux-only gate). The protocol package is unchanged — it receives the cjs path as a string. Tests: .desktop parsing+validation (incl. quoted path with spaces); mount before app-server; cjs argv is mount-point path; mount killed on teardown, cancel, mount failure, mount timeout, and abort-during-mount. Mirrored across next + cli. typecheck + guard green; failure counts unchanged vs baseline. Fixes #24.
…0006 D1) (#25) The app-server onEvent dropped thinking_* events per ADR-0006 Decision 1 ("high-frequency, weak UX value, would flood the stream"). A live comparison on hsiarch (ZCode + GLM-5.2, 2026-08-12) disproved that: the Claude Code adapter streams the same per-fragment thinking lines and that continuous flow is good UX. ZCode's reasoning was a black box only because this layer dropped it — the protocol layer already mapped reasoning_delta to thinking_start / thinking_delta. Forward thinking_delta as {type:"meta", key:"thinking", value:delta} — the exact event shape the Claude Code argv path emits and formatMeta renders as `thinking …`. thinking_start (no payload) is ignored via early-return. HTML output is unaffected (separate text_delta -> delta channel). - next/src/lib/agents/invoke.ts: add thinking_start/thinking_delta branches - cli/src/agents-invoke.ts: mirror the same branches (had the same drop) - next invoke.test.ts: flip the #21 drop-assertion to a forward-assertion (uses reasoning_delta payloads — the stream handler maps those; the prior test's kind:"thinking_delta" payloads never reached onEvent) Live-verified on hsiarch: a probe driving the real ZCode app-server through the real zcode-protocol package forwarded reasoning_delta to meta thinking frames; formal text output traveled its own channel unaffected. ADR-0006 Decision 1's "drop thinking" half is overturned (the tool-event forwarding half stands; D2/D3 untouched). ADR-0008 records the reversal + live evidence (local, per project convention — ADRs are not committed).
The ZCode app-server onEvent was the one place a ZCode-emitted meta-ish event
was still silently dropped: the protocol layer (zcode-stream.ts) surfaces the
model's generated conversation title (source:"generated") as a
`conversation_title` event, but the app-server onEvent had no branch for it, so
it fell through and was lost.
Forward it as {type:"meta", key:"conversation_title", value:<title>}. This uses
the EXISTING `meta` InvokeEvent type — no union change — and formatMeta's generic
fallback renders it (`conversation_title: <title>`). No shared/frontend code
touched; the edit lives entirely in the app-server onEvent closure (ZCode-only
execution path), mirroring where #21/#25 put their branches. cli mirror synced.
With this, every event ZCode's stream emits is forwarded: thinking_delta,
text_delta, tool_use/result, usage(+duration), status, error, and now
conversation_title. The remaining protocol-layer drops are intentional
(tool-args bookkeeping, per-iteration turn-result, model-request telemetry that
carries auth headers, config-noise state changes).
Clean-checkout CI failed: package.json exports pointed at a gitignored dist/ that no build step produced, so next typecheck/build on a fresh clone could not resolve @html-anything/zcode-protocol. Consume the package from TS source instead — exports/main/types point at src/*.ts, next lists it in transpilePackages, cli resolves via tsx. Intra-package imports dropped .js extensions (bundler style) so Next's bundler resolves them from source. No runtime behaviour change; zcode-protocol (83/1skip), next+cli typecheck, next build, next agents (77) all green with dist removed.
ZCode is not a PATH CLI like the other 9 — it is an Electron desktop app whose zcode.cjs is auto-discovered via install path and driven with node (AgentDef bin 'node', binArgs [<cjs>, 'app-server']; no 'zcode' command is registered). Keep the existing '9 coding-agent CLIs auto-detected on your PATH' sentence intact and append ZCode as the exception. The ZCode table row shows bin 'node' + 'Electron app, discovered via install path'. Badge count 9 -> 10. Both READMEs.
parseZcodePickerModels emitted one entry per usable-provider model without deduping, so when two usable providers carried the same modelId (e.g. a Coding Plan and a separately-keyed provider both exposing GLM-5.2) the picker had two entries with the same bare-modelId id. That broke the three id consumers: duplicate React keys in both model modals, two indistinguishable chips, and a resolver that could only ever bind the defaultProviderId entry. Fix the uniqueness bug at the source: dedup by modelId in parseZcodePickerModels, keeping the id as the bare modelId (shape unchanged). Add an optional preferredProviderId param — on a usable-vs-usable collision the GUI-default provider's entry wins (even when it appears later in config order); with no preference, or the preferred provider not among the colliding ones, the first-in-config-order entry stays. readZcodeModelPicker resolves the GUI selection first and passes defaultProviderId as the preference, so the deduped entry matches the provider the user sees selected. The resolver, both modals, ModelOption, the detect layer, the cli, DEFAULT_MODEL, and the session/create wire format are all unchanged. ADR-0010 Decision 3 / T7. zcode-protocol: 103 passed (picker 12->14: dedup + preferred-no-op), integration test green; next/cli typecheck clean; guard clean; next/cli unit tests unchanged vs win32 baseline (17/16 host-sensitive failures).
mrcfps
left a comment
There was a problem hiding this comment.
@ChHsiching Thanks for the follow-up — the picker uniqueness fix on this head is clean, and the rest of the adapter still holds together well 🙌
I re-reviewed c3199c5 plus the surrounding app-server path. parseZcodePickerModels now dedupes by modelId and prefers the GUI-default provider, so ModelPicker keys stay unique without changing the stored id shape or resolveZcodeTurnModel. The earlier notes (Linux .cjs / .deb skip-mount, shared isUsableZcodeProvider, protocol timeout cleanup, host-independent T12 spawn assertion) remain in place. Protocol client lifecycle, session driver (runtime preferences + permission auto-reply), invoke teardown, and the detect/invoke contract look correct on the main path.
Really nice, careful work on a hard integration — thank you.
🔁 Powered by Looper · runner=reviewer · agent=grok-build · An autonomous AI dev team for your GitHub repos.
…ormatMeta guard) Conflict in next/src/lib/agents/__tests__/detect.test.ts (add/add): kept the ZCode detect suite, appended upstream's MiniMax picker test verbatim.
2ecfa26 to
2aab035
Compare
|
Hi @ChHsiching! The ZCode adapter is a substantial addition, and the protocol/app-server separation plus the follow-up fixes are clearly documented. @mrcfps has re-reviewed the current implementation; we’re now checking the product fit for this new agent option before the final merge decision. Thanks for working through the iteration so carefully. |
|
Heads-up: #157 is also open for ZCode support. Both PRs change |
…runk Replace the app-server JSON-RPC branch with a per-turn spawn of `zcode.cjs -p <fixed guide> --attach <temp prompt file> --output-format stream-json --mode yolo` riding the generic argv trunk (new "argv-attach" protocol; no dedicated branch). ELECTRON_RUN_AS_NODE=1 is merged zcode-only in envFor; the full prompt travels in a mkdtemp attachment cleaned on close/error/abort/cancel; model.streaming text_delta NDJSON lines stream as deltas while noise lines (hook frames, turn lifecycle, the bare result terminator) are dropped. Linux AppImage self-mount + binArgs sentinel fill are reused unchanged; win32 spawns absolute .exe drivers directly with verbatim argv (spaced install paths need no quoting). Both mirrors (next + cli) updated and the frontend protocol badge becomes "argv-attach · stream-json". Parser/invoke tests pin the plumbing against real NDJSON captured from the installed CLI; the app-server branch, its turn-model resolution, and their test describes are deleted. Verified against the installed CLI: a full turn streamed 170 real HTML deltas, exited 0, closed done, and removed the temp attachment.
…lay, ready detection, failure surfaces) ZCode's CLI has no --model flag; a fresh -p turn with no valid config default quietly falls back to the first visible registry provider with the max reasoning level (observed live: a custom provider gateway returning 400 for the auto-picked max reasoning level, exit 1). The CLI's own fallback is unobservable, so the adapter pre-validates and binds explicitly. - packages/zcode-protocol/zcode-model-binding (new): resolve the GUI plan (setting.json current keys providerFamilyDomain + providerFamilyConnectionSelections, legacy keys as read-boundary fallback; login signal = credentials.json oauth:<family>:access_token KEY NAMES), the bundled catalog (install file next to the spawned cjs first, runtime cache by freshness second, schemaVersion gate), and the model and level table from modelRules (array-order overlay, last matching rule wins; mirrors the ZCode engine). prepareZcodeModelBinding clones the user's provider_config.json to the attach temp dir, writes the exact defaultModelSelection, and returns the paired env values; every broken link (GUI keys missing / catalog unreadable / model not in plan / level unavailable / stale bare model id) refuses with an error message that says what to do. The user's real config file is never written (snapshot-asserted). A user who pre-set BOTH ZCODE_*_PROVIDER_CONFIG_FILE vars keeps them verbatim (zero-code reroute). - detect (next + cli mirrors): the picker lists the plan's models and levels as chips + a Default entry; new ready/notReadyReason (gui-not-initialized / not-logged-in). Settings card renders the amber "已安装 · 未就绪" state with guidance; cli's agents list prints the not-ready marker. - invoke (both mirrors): the paired ZCODE_PERSONAL/_BUILTIN vars ride the spawn env, with builtin set to the very catalog file the selection was validated against, so what was validated is what runs; a bound-model meta event surfaces after start. - use-convert: zcode-gated failure surface; error events and non-zero exits now end the task in the red error state (previously logged and shown as done); diff-edit baseline is no longer committed from a failed turn. - fixtures pin the binding discriminator: wrong binding = gateway 400/exit 1 (captured stderr), right binding = exit 0 (captured turn.completed/result lines); package tests cover plan parsing, catalog order, refusals, zero-write, and the discriminator. A zero-token dry check against the installed CLI resolves ready=true, plan=account:bigmodel-individual-coding-plan, install catalog first, GLM-5.3 and GLM-5.3-Flash at low/high/max; an explicit pick writes the exact triple, a stale GLM-5.2 pick is refused, the real config stays untouched. typecheck (next/cli/protocol/e2e), guard, and the win32 baseline diff (next 17 / cli 16 / protocol 1; all pre-existing) unchanged.
…entions
The "@"-joined model and level id and the middot label did not match repo
conventions; the compound picker-id convention is the slash (openclaw
`openrouter/anthropic/…`, opencode `anthropic/…`) and label qualifiers
are parenthetical ("Opus 4.7 (OpenRouter)"). Switch to `GLM-5.3/high` /
"GLM-5.3 (high)", and shorten the zcode model hint to one sentence like
the siblings'.
…one cannot provide A live web run failed: the adapter delivered a valid defaultModelSelection via the paired ZCODE_*_PROVIDER_CONFIG_FILE vars, yet the child quietly rerouted to the first personal provider (llama.cpp failing with ECONNREFUSED 11 times, then turn.failed, exit 1). Root cause (source-level, apps/zcode-cli standalone-account-provider-runtime.ts): the headless CLI materializes an account Coding-Plan provider only when the credential store holds `account-provider:<providerId>:identity`. The GUI never writes that key (only `zcode login` does; the desktop host pushes its account snapshot through a different channel). Without it the plan provider is absent from the registry, isSelectable() finds no provider, and resolveInitialModelSelection falls back to registry order with no visible error. Fix (verified live: exit 0 with the reply bound to account:bigmodel-individual-coding-plan): clone the credentials store into a temp ZCODE_DATA_BASE_DIR under the attach dir and add a plaintext identity key derived from the GUI's own api-key key name (decrypt() passes non-enc:v1: values through). New refusal link credentials-missing when no api-key key exists. Trade-off: the turn's session lands in the temp dir (the "session visible in the ZCode GUI" acceptance is traded for the zero-user-write guarantee). Package tests green (identity-bridge content, credentials zero-write snapshot, credentials-missing refusal); baselines unchanged; typecheck + guard green.
…pping the temp data-dir redirect The temp-data-dir trade-off does not hold up: redirecting the whole data dir to a temp clone (to preserve zero-user-write) costs far more than the one key it avoids; sessions die with the turn (invisible in the GUI) and every spawn cold-boots the full plugin/MCP stack (~10x slower, 465s vs 43s). A product also cannot ask every user to run `zcode login` first. The bridge now ensures `account-provider:<providerId>:identity` exists in the REAL ~/.zcode/v2/credentials.json as an atomic append (tmp+rename; idempotent when the stored identity already matches, a differing stale value is overwritten; self-healing). Value = the account id already embedded in the GUI's own api-key key name. Everything else keeps using the real data dir: sessions land in ~/.zcode and stay visible in the GUI, plugin caches stay warm. This one key is the deliberate, documented exception to the zero-user-write posture. Tests green (append semantics, idempotent-match, untouched-other-keys assertions); baselines, typecheck, guard unchanged.
…cards leaking into the HTML The builtin-tool display cards seen in generated pages are model-side text: the server-executed tools (webReader/webSearch) stream their display cards as assistant text (the names appear nowhere in the CLI bundle), and our parser forwards all text_delta; so they land in the final HTML whenever the model decides to fetch a link from the user content. Stopgap: the fixed -p guide now forbids all tool calls (the html-anything task needs none; pure HTML output). The deterministic fix (parser-level filtering of server-tool display frames) belongs to the parsing-surface work.
…er tool cards leaking into the HTML" This reverts commit cfccefc. On review: banning builtin tools (webReader/webSearch) cripples ZCode; fetching links from the user content is a wanted capability. The leak is tool-card DISPLAY text, not the tools; leave it alone for now (the final HTML overwrites the streamed text); any deterministic handling belongs to later parsing work.
The webReader/webSearch cards stream as assistant text (server-side tools, names absent from the CLI bundle) and leak into the final HTML. The filter is a streaming state machine on the zcode text_delta path: the fixed card marker starts swallowing, the next HTML tag or card marker ends it; the tools stay fully enabled, only the card text is dropped. Fixtures use the real leaked card text from a live web run, split across delta boundaries.
…amed text" This reverts commit 0511866.
… HTML rescue, usage/session/result)
Expand the zcode one-shot parser beyond text_delta to the full event
surface, mirrored across next and cli:
- model.streaming reasoning_delta -> meta thinking, forwarded fragment
by fragment under the same key the Claude path emits; tool_call ->
shared rescueHtmlFromToolUse on the assembled {file_path, content}
input (rescued HTML replaces the preview) or a "调用工具 X" status
line, recording toolCallId->toolName for the nameless result event.
- tool.updated kind=result -> "工具 X 完成" via that map; a result with
no resolvable name emits nothing (a bare ✓ is noise). Other
tool.updated kinds (scheduled/started/progress/error/batch) drop.
- turn.completed -> exactly one usage (camelCase remapped to the
snake_case keys the consumer reads), duration_ms, and resultType
(incl. cancelled) as the result meta. The bare result terminator
contributes only its sessionId (session meta); its duplicate
cumulative usage is never re-emitted.
- turn.failed -> error part (new AgentParse kind) forwarded as
{type:"error"} so the existing zcodeTurnFailed consumer gate renders
the red error state.
- session.*/turn.started/message.upserted and non-JSON lines still drop
with zero output.
Event shapes are pinned against the ZCode open-source contracts
(session.events.ts ModelStreamingPayload / ToolCallResultPayload,
session-mapper.ts tool.updated kind injection); fixtures reuse real
captured NDJSON (source disclosed in the tests), and the constructed
reasoning/tool lines are disclosed and contract-cited there too. Both
mirrors green vs the pre-existing win32 baseline (cli 16 / next 17
unchanged).
…rror mapping, abort cleanup) Harden the zcode CLI one-shot against the three ways a turn can die without tearing itself down, mirrored across next and cli: - Silence watchdog (zcode-gated, 180s): a healthy stream-json turn parses events continuously, so 180s of zero parsed events means the model or CLI hung. The timer arms only after a successful spawn (the argv / mount / binding window keeps its own bounded failures), every stdout line that parses to at least one event resets the clock (dropped noise lines do not; they are the silence being watched), and firing emits one clear error, SIGTERMs the child, runs the argv-attach cleanup, and closes the stream with no done event (the turn failed, it did not complete). Cleared on close / child-error / abort / cancel. - Non-zero exit (zcode-gated): close(code!=0) now emits an error before done. The existing zcodeTurnFailed consumer gate renders the red failure state off any error event; the error carries the exit code plus the most recent stderr essence (2KB rolling tail, 500-char quote; the full stderr keeps streaming to the log). Exit-0 and unknown-code closes keep the historical done-only shape, and sibling agents are entirely untouched. - Abort: onAbort already killed the child + mount holder and removed the prompt temp dir; it now also clears the watchdog so nothing fires after the cancel. Timer tests ride the library's existing fake-timer pattern (vi.useFakeTimers + advanceTimersByTime + per-describe flushMicrotasks, same as the 5s mount-timeout tests): fire-after-180s-of-noise, reset-on-parsed-events, never-armed-for-sibling-agents, never-armed-pre-spawn (binding refusal leaves no orphan timer), exit-error ordering + stderr cap, exit-0 regression guard, and abort-clears-the-watchdog. The turn.failed test now expects the second exit-mapped error. Both mirrors green vs the pre-existing win32 baseline (cli 16 / next 17 unchanged).
…le-pick fallback and truthful docs
- Default chip label names what Default resolves to right now (the GUI's
own defaultModelSelection when it validly targets the plan, else the
plan's first enabled model + last level) via the new
readZcodePlanDefaultChoice in zcode-model-binding; the exact
precedence prepareZcodeModelBinding applies to a default pick, refactored
into a shared resolveDefaultSelection so label and binding cannot drift.
Mirrored in the next + cli detect copies with tests on both sides.
- Stale persisted model picks (plan changed, or a legacy bare model id)
fall back to Default on every read path, in both pickers (active-chip
render plus the amber revert notice, i18n en/zh) and on the
convert-chip/toolbar/use-draft send paths, via the new pure
lib/agent-models helper. An unloaded agent list passes picks through
(a valid pick is never dropped); a truly stale id still hits the loud
invoke refusal, never a quiet model swap.
- welcome-modal's picker shows the truthful ZCode Default hint (mirrors
the settings picker; the generic "--model" hint was wrong for ZCode).
VENDOR_HINT/VENDOR_GRADIENT gain Z.AI ("install the ZCode desktop app ·
open it & log in once"); the Settings agent section re-scans on every
mount (plan chips and readiness follow GUI state).
- ZCode's static picker floor is truthful: "Default (ZCode GUI plan)",
never "Default (CLI config)"; ZCode has no --model flag. Stale
app-server-era "dynamic picker / session/create" doc comments updated
to the plan-chip reality.
- README (en/zh): fix the stale app-server table row (headless CLI
one-shot now) + the four ZCode notes: default model follows the GUI
plan, 256 KB / 2000-line attachment cap, per-turn cost follows the
user's plugin config (trim on the ZCode side; html-anything never
writes ZCode config), generated sessions stay visible in the GUI.
- Protocol package: drop the dead ./zcode-config and ./zcode-model-picker
export entries; zero external consumers remain (the module files are
package-internal to the app-server-era zcode-session and die with the
package deletion), so nothing outside the package can compile against
the old dynamic picker.
- Verified: next/cli/protocol/e2e typecheck + guard green; win32 failure
baseline unchanged (next 17 / cli 16 / protocol live-integration 1, all
pre-existing and host-sensitive).
…migrated into both mirrors) The workspace package, its unit tests, and the real-server integration test are gone; the model-binding module survives, migrated byte-identical into the next and cli agent dirs as the package's only remaining consumer. Workspace dependencies and the packages/* glob are dropped with it.
…st titles Comments and test names referenced development-local artifacts the upstream cannot resolve (internal doc numbers, local ticket ids, scratch paths, dated provenance markers). Each reference is either dropped (the surrounding comment already states the fact) or restated inline in English; quoted ZCode source strings and public ZCode repo citations are kept. Also removes the dead packages/* glob from pnpm-workspace.yaml and turns two self-authored Chinese comment phrases into English per CONTRIBUTING. Behavior-neutral: mirror files stay byte-identical; next/cli suites at the win32 baseline (17/16), typecheck green.
… fixes, style) Covers what the first scrub pass missed plus audit findings: strip remaining internal ticket refs from UI components and libs; neutralize captured fixtures (developer paths, real provider/account ids) with assertions kept in sync; correct stale docstrings left from the superseded temp-data-dir design (one temp artifact, the credential store identity key is the only real-file write), soften the identity idempotency claim to match the actual value-differs gate, fix the refusal-chain count, replace the wrong Chromium-safe-storage attribution, and retire the AppImage-only premise in test comments. Style: README bullets to the house bold-dash shape with the credential-write exception spelled out, tone down shouting caps and provenance bragging, unify terms, slim the five heaviest comment blocks, and restore the next.config template comment. Mirrors stay byte-identical; next/cli suites at the win32 baseline (17/16); typecheck green.
…and the picker Replace the v2 plan-parsing binding with a mirror of the registry the headless CLI itself builds: paired account individual plans (identity + api-key credential keys, team/start/offpeak never expanded) plus every keyed personal provider, ordered by catalog declaration and the user's providerOrder. setting.json leaves the contract; ready collapses to one state. Picker ids are provider-namespaced (<providerId>/<modelId>[/<level>]) with the service name in the label; level-less models stay selectable and bind without reasoningLevel; the default chain is the configured defaultModelSelection (legacy provider ids migrated) falling back to the first provider's first model at its highest level. Enabled is a last-write-wins overlay across builtin/template/personal rule tables. The identity bridge stays add-only and fires for account targets only. Fixtures: a structurally-verbatim subset of the install catalog plus two scrubbed real-machine shapes (dual-form, pure API-key — the shape v2 rejected wholesale). next and cli mirrors stay byte-identical.
The ZCode desktop exports its paired provider-config env vars into every terminal it spawns. A dev server started from one of those terminals inherited them, and the invoke layer relayed the host's config instead of binding itself, masking real first-run behaviour. next.config now strips both vars at dev startup (development phase only) and logs what it removed, so a dev run matches a clean machine; production keeps whatever the real environment carries. stripZcodeHostProviderEnv lands in both next/cli copies of zcode-model-binding with unit tests. README (en + zh): the picker bullet now notes the per-chip service-name labeling, and a new bullet records that Team / Start / Off-peak plans never run headless because the CLI expands only individual Coding Plans.
Drop the ZCode-specific section and the intro mention: no other agent has either. Behavior notes belong in the PR description. The support-matrix row stays (the one place a new agent must appear), with the trailing parenthetical removed and the zh row carrying the same command placeholders as the en row.
The old hint claimed Default follows ZCode's own default model, but the GUI never writes a default selection, so no such model exists for GUI users: Default always resolves through the registry chain, which lands on the first service's first model. Say that plainly and leave the resolved value and level to the chip label, which already shows both.
|
方案演进说明:本 PR 已从 app-server 切换到 CLI 一次性模式。 初版的 app-server 方案针对当时的 ZCode 版本开发并验证通过。ZCode 3.14 起无头接口发生变化,app-server 的会话中继方法在安装版上已被移除,实测返回 Closes #138. Summary
Background为什么从 app-server 切换
Changes
Implementation Details进程与附件每轮生成 spawn 二进制发现CLI 在 Electron 安装目录里,不在 PATH 上。Windows 与 macOS 从安装目录解析 模型绑定CLI 没有
用户预设了这对环境变量时,适配器原样透传,跳过自己的绑定。 规模贡献指南预期新 agent 是 argv 数组里约 10 行。ZCode 有三个其他 CLI 没有的问题,各自带来一块代码:
加上应用与 CLI 工具两侧的镜像同步、319 个用例和真机形状的 fixture,合计 35 files、+16,292 / −124。行数构成:测试代码约 4,900 行,测试 fixture 数据约 5,100 行,功能代码约 4,700 行;其中绑定模块 1,217 行在应用与 CLI 工具各有一份同步副本,去掉镜像重复后的独立逻辑约 3,500 行。 设计取舍以下约束均为开发过程中在本机实测验证,决定了方案的形态:
Testing
live 验证:
|
There was a problem hiding this comment.
Product approved. Adding ZCode as a first-class agent alongside Claude / Codex fits our direction of broad agent compatibility, and reusing the user's existing ZCode login keeps credentials out of html-anything. Please refresh the PR description so it matches the current CLI one-shot approach.
|
Hi @ChHsiching, thank you for the substantial work on ZCode support, including adapting the approach when ZCode changed its headless interface and went open source. We're closing this PR because a much lighter ZCode adapter (#157, ~80 lines) has been submitted. It uses the same one-shot Sorry for the earlier approval here. We hadn't connected the two PRs at that point. If anything in this PR is missing from #157 (for example the model picker or Windows handling), please feel free to suggest it as a follow-up on top of #157. Thanks again! |
Background
ZCode(智谱 Z.AI 的 agentic coding 桌面应用,Electron 包内带
zcode.cjs)headless 下唯一可用的是app-server模式——--prompt因 auth 在 headless 下不可用,无法用 argv adapter 方式接入。完整论证见 #138。本 PR 接入 ZCode:给
AgentProtocol加"app-server"类型 + 独立的packages/zcode-protocol/适配包。ZCode 像 claude/codex 一样出现在 agent 选择器,复用用户已登录的 session,html-anything 不经手 credential。Closes #138.
协议类型
ZCode 的
--promptheadless 下不可用(auth 受限于 GUI-gated 的zcode login),唯一 headless 入口是app-server:spawn 后常驻,走 JSON-RPC 双向驱动,还要自动应答 server 反向发的 request,否则子进程 block。细节见 #138。Changes
新增
packages/zcode-protocol/(repo 第一个packages/*)zcode-protocol.tszcode-stream.tstext_delta/tool_use/tool_result/usage/status)zcode-session.tssession/requestRuntimePreferences/interaction/requestPermissionzcode-config.tszcode-model-picker.ts含一个连真实 ZCode server 的 integration test。
AgentProtocol新增"app-server"类型next/src/lib/agents/{detect,invoke,argv}.ts+cli/src/agents-{detect,invoke}.ts:新增 app-server 分支,early return 隔离。argv.ts:AgentDef加binArgs字段——ZCode 的 spawn 形如node <zcode.cjs> app-server,故bin="node"、binArgs=["<zcode.cjs 路径>", "app-server"]。子进程自认证
app-server 在
ELECTRON_RUN_AS_NODE=1下 spawn 时,自动以用户 GUI 登录的身份认证,html-anything 不经手 credential,和 claude/codex 一致。平台探测(resolveZcodeBin)
zcode.cjszcode.cjs;或 PATH 上的zcodeZCode.app/Contents/Resources/...(代码已写,未实测)其他
README.md/README.zh-CN.md:Supported agents 表各加一行 ZCode(9 → 10)。pnpm-workspace.yaml:加packages/*。next/next.config.ts+packages/zcode-protocol/package.json:协议包从 TS 源码消费(transpilePackages+ exports 指向src/*.ts),不 build dist,干净 checkout 即可解析。next/src/components/{settings-modal,welcome-modal}.tsx、i18n.ts、store.ts:vendor 渐变 / hint / app-server 选项。Testing
pnpm -F @html-anything/zcode-protocol test→ 7 files / 83 passed / 1 skipped(含真实 server integration test,~45s round-trip 通过)。pnpm -F @html-anything/next test src/lib/agents→ 3 files / 77 passed(invoke 31 / detect 37 / argv 9)。pnpm -F @html-anything/{next,cli}typecheck → 全绿。pnpm exec tsx scripts/guard.ts→Guard passed.Live:
What's intentionally NOT in this PR
ZCode.app/Contents/Resources/...),但无 mac 机器,未做真机验证;review 期间可请人验证,或合并后跟进。"app-server"类型的泛化。 目前只服务 ZCode;出现第二个同类 CLI 时再抽公共。Test plan
pnpm install --frozen-lockfilepnpm -F @html-anything/zcode-protocol testpnpm -F @html-anything/next typecheck && pnpm -F @html-anything/next testpnpm -F @html-anything/cli typecheckpnpm exec tsx scripts/guard.tspnpm -F @html-anything/next dev,选 ZCode,跑一个会触发工具调用的 prompt,确认 turn 正常完成、HTML 正常产出