Skip to content

feat(agents): add ZCode (Z.AI) adapter - #139

Closed
ChHsiching wants to merge 55 commits into
nexu-io:mainfrom
ChHsiching:feat/zcode-adapter
Closed

ChHsiching wants to merge 55 commits into
nexu-io:mainfrom
ChHsiching:feat/zcode-adapter

Conversation

@ChHsiching

@ChHsiching ChHsiching commented Aug 12, 2026 •

Copy link
Copy Markdown

方案演进:初版 app-server 方案针对当时的 ZCode 版本开发并验证通过;ZCode 3.14 起无头接口变化且项目开源,本 PR 已切换到 CLI 一次性模式。当前方案与测试证据见下方评论:#139 (comment) 。下方为初版 PR 描述,与当前代码不符。

Closes #138.

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 的 --prompt headless 下不可用(auth 受限于 GUI-gated 的 zcode login),唯一 headless 入口是 app-server:spawn 后常驻,走 JSON-RPC 双向驱动,还要自动应答 server 反向发的 request,否则子进程 block。细节见 #138。

Changes

新增 packages/zcode-protocol/(repo 第一个 packages/*)

模块 作用
zcode-protocol.ts JSON-RPC 2.0 client(request / response / notification 编解码)
zcode-stream.ts notification → 通用事件映射(text_delta / tool_use / tool_result / usage / status)
zcode-session.ts turn driver:create → setMode → subscribe → send;自动应答 session/requestRuntimePreferences / interaction/requestPermission
zcode-config.ts 读取 ZCode 已保存的 provider 配置
zcode-model-picker.ts 据配置动态生成 model 列表(隐藏 enabled-but-empty 的 provider)

含一个连真实 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)

平台 发现方式
Windows Electron 包内的 zcode.cjs
Linux AppImage 自挂载发现 bundle 后定位 zcode.cjs;或 PATH 上的 zcode
macOS ZCode.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:

  • Windows:ZCode + GLM-5.2,跑一个 template,turn 完整执行,exit=0,流式产出 HTML。
  • Linux(Arch,AppImage):自挂载发现 zcode.cjs + per-turn 生命周期,端到端实测可用。WSL 下走同一 linux 分支。

What's intentionally NOT in this PR

  • macOS 真机验证。 mac 适配代码已在 PR 内(检测 ZCode.app/Contents/Resources/...),但无 mac 机器,未做真机验证;review 期间可请人验证,或合并后跟进。
  • "app-server" 类型的泛化。 目前只服务 ZCode;出现第二个同类 CLI 时再抽公共。

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @html-anything/zcode-protocol test
  • pnpm -F @html-anything/next typecheck && pnpm -F @html-anything/next test
  • pnpm -F @html-anything/cli typecheck
  • pnpm exec tsx scripts/guard.ts
  • pnpm -F @html-anything/next dev,选 ZCode,跑一个会触发工具调用的 prompt,确认 turn 正常完成、HTML 正常产出

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.
@lefarcen
lefarcen requested a review from mrcfps August 12, 2026 12:22
@lefarcen lefarcen added size/XXL PR size: 1500+ changed lines risk/high High-risk PR: dependencies, infra, security-sensitive, or broad runtime impact labels Aug 12, 2026
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 mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@ChHsiching

Copy link
Copy Markdown
Author

@lefarcen @nettee All 6 review threads are resolved. The real-server integration test passes; next/cli show no new regressions. If there's nothing else, I'd love to get this merged — happy to make further changes if needed.

…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.
@lefarcen

Copy link
Copy Markdown

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.

@lefarcen

Copy link
Copy Markdown

Heads-up: #157 is also open for ZCode support. Both PRs change next/src/lib/agents/argv.ts, detect.ts, and invoke.ts, but #157 uses the one-shot CLI JSON path rather than the app-server transport described here and in #138. Sharing the overlap so the authors and maintainers can compare the assumptions before choosing what lands.

…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.
… 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.
@ChHsiching

ChHsiching commented Sep 25, 2026 •

Copy link
Copy Markdown
Author

方案演进说明:本 PR 已从 app-server 切换到 CLI 一次性模式。 初版的 app-server 方案针对当时的 ZCode 版本开发并验证通过。ZCode 3.14 起无头接口发生变化,app-server 的会话中继方法在安装版上已被移除,实测返回 Method not found;同时 ZCode 开源,桌面应用自带的 CLI 入口支持一次性执行,成为更合适的接入路径,本 PR 随之整体切换。上方的 PR 描述仍是初版方案,与当前代码不符,请以本评论为准。此前评审意见中仍然有效的部分,修复都已在本分支上。

Closes #138.

Summary

  • 接入 ZCode,智谱 Z.AI 的 agentic coding 桌面应用:像 claude、codex 一样出现在 agent 选择器,直接使用用户在 ZCode 里已配好的账号登录或 API key provider
  • 无头入口是 Electron 包内的一个 CLI 文件,每轮生成 spawn 一次,结束即退出,不引入长驻进程
  • 模型选择器按 ZCode 自己的 provider 注册表列出全部模型和推理档位,每轮按所选模型显式绑定
  • 应用与 CLI 工具两侧镜像同步修改,README 加表格行

Background

为什么从 app-server 切换

  1. 上游接口变化:ZCode 3.14 移除了 app-server 的会话中继方法,原方案在新版上直接不可用
  2. 更优路径出现:ZCode 开源后,桌面应用自带的 CLI 支持 -p <引导语> --attach <文件> --output-format stream-json 一次性执行,实测在 Windows 与 Linux 上均可用
  3. 更符合本仓库约束:一次性 spawn 不引入独立长驻进程,符合贡献指南对后台进程的要求

Changes

  • next/src/lib/agents/ 与 cli/src/ 双镜像新增 argv-attach 协议分支:AgentDef 加 bin 为 node、参数为安装目录内 zcode.cjs 的定义,prompt 经临时 .md 附件投递
  • 新增 zcode-model-binding 模块,next 与 cli 两侧同步:只读解析 ZCode 的 provider 注册表,生成模型选择器数据,并按所选模型准备每轮绑定
  • 事件解析覆盖 stream-json 全部事件面:流式正文、思考流、工具生命周期、usage、session id、失败事件;模型改用文件写入工具时救回 HTML
  • 失败与清理:180 秒静默看门狗、非零退出的错误事件、abort 与 cancel 的临时目录清理
  • README.md 与 README.zh-CN.md:supported agents 表各加一行
  • next/next.config.ts:dev server 启动时剥离 ZCode 桌面向子进程注入的两个 provider 环境变量
  • 测试 fixture 含安装目录内置目录的结构忠实子集与两台真机脱敏后的配置形状

Implementation Details

进程与附件

每轮生成 spawn node <zcode.cjs> -p <引导语> --attach prompt.md --output-format stream-json --mode yolo,并设 ELECTRON_RUN_AS_NODE=1。完整 prompt 通常 20 到 30 KB,超出命令行限制,作为 .md 附件投递;附件上限随 ZCode 自身,为 256 KB / 2000 行,轮次结束即清理临时目录。

二进制发现

CLI 在 Electron 安装目录里,不在 PATH 上。Windows 与 macOS 从安装目录解析 zcode.cjs,带空格的路径直接 spawn。Linux 支持 AppImage 自挂载,每轮挂载和卸载,超时 5 秒;也支持 .deb 安装目录。

模型绑定

CLI 没有 --model 参数,新会话在配置无有效默认时回退到注册表第一个 provider 的最高推理档。为保证所选即所用,适配器每轮显式绑定:

  1. 只读解析 ZCode 的 provider 注册表:账户个人计划加上配好密钥的个人 provider,凭据存储只读键名,不读加密值。按 CLI 自己的展开规则,只有个人 Coding Plan 会实例化,Team、Start、Off-peak 计划不出现在选择器里。选择器按用户在 ZCode 里排的顺序列出全部模型和推理档位,同名模型以服务名区分。
  2. 把选定的模型和档位写进用户 provider 配置的临时副本,用户真实配置不写入;副本与安装目录内置的目录文件成对经环境变量交给子进程,CLI 硬性要求成对提供。
  3. 无头模式还需要一个只有 zcode login 才会写入的身份凭据键,GUI 用户没有。适配器以原子追加的方式在真实凭据存储补上这一个键,只增不改且幂等;已运行过 zcode login 的用户零写入。
  4. 绑定链任何一环断了,未登录、目录读不到、所选模型不在可用列表、档位不可用,都直接报错,信息明确可操作。

用户预设了这对环境变量时,适配器原样透传,跳过自己的绑定。

规模

贡献指南预期新 agent 是 argv 数组里约 10 行。ZCode 有三个其他 CLI 没有的问题,各自带来一块代码:

问题 带来的代码
CLI 在 Electron 安装目录里,不在 PATH 上 三平台安装路径发现 + Linux AppImage 自挂载生命周期
没有 --model 参数 上面的整条绑定链 + 选择器
命令行装不下完整 prompt --attach 附件投递与全路径清理

加上应用与 CLI 工具两侧的镜像同步、319 个用例和真机形状的 fixture,合计 35 files、+16,292 / −124。行数构成:测试代码约 4,900 行,测试 fixture 数据约 5,100 行,功能代码约 4,700 行;其中绑定模块 1,217 行在应用与 CLI 工具各有一份同步副本,去掉镜像重复后的独立逻辑约 3,500 行。

设计取舍

以下约束均为开发过程中在本机实测验证,决定了方案的形态:

  1. 桌面应用不往 PATH 上注册命令。 官方安装渠道,macOS dmg、Windows exe、Linux .deb 与 AppImage,把 CLI 文件放在安装目录内,需用 node 驱动且各平台路径不同。仅 npm 安装的 CLI shim 才在 PATH 上提供 zcode 命令。只走 PATH 发现的话,桌面应用用户检测不到 ZCode。因此适配器按平台探测安装目录。

  2. CLI 没有 --model 参数,且新会话在配置无有效默认时静默回退,落到注册表第一个 provider 的最高推理档。实测验证过:一轮生成静默落到一个自定义网关并带了 reasoning_effort=max,网关返回 400,进程退出码 1。用户选的模型和实际用的模型完全对不上,因此适配器每轮显式绑定。

  3. --output-format stream-json 提供逐事件流式输出。 一轮模板生成通常 30 秒到 5 分钟,流式让用户实时看到正文、思考与工具调用;等进程退出后一次性拿完整响应,这段等待期页面没有反馈。因此适配器走 stream-json 而非一次性输出。

Testing

项 结果
应用侧 agents 测试,invoke、detect、argv、绑定 149 / 149 通过
CLI 侧镜像测试 161 / 170 通过,9 个为 Windows 宿主敏感的既有失败,全部是其余 agent 的 PATH 探测类用例
typecheck 应用与 CLI,guard 全绿
全量套件失败数 与基线逐一相同,next 17、cli 16,均为既有宿主敏感失败

live 验证:

  • Windows 全链路:产品 POST /api/convert,显式选 GLM-5.3-Flash 的 high 档,CLI 会话日志确认实际请求模型一致;1,645 个增量拼出 5,358 B 完整 HTML,思考流 8,422 片段,usage 为 input 109,246 / output 10,123 tokens,exit 0,无 stderr
  • Windows 浏览器:默认档,个人 Coding Plan 的 GLM-5.3,完整生成;第三方 provider DeepSeek 走 Anthropic 兼容端点,绑定与流式正常;失败路径显示红色错误态
  • Linux:Arch,AppImage 自挂载发现与每轮挂载卸载生命周期,端到端生成可用
  • Linux .deb:Debian 13 WSL 安装官方 .deb,产物位于 /opt/ZCode/resources/glm/zcode.cjs,与探测路径一致
  • 失败面:绑定链每环的拒绝信息、非零退出的错误事件、静默看门狗,均有单测覆盖

@open-design-crew open-design-crew Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@open-design-crew

Copy link
Copy Markdown

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 zcode --json -p CLI path. Since both now take the same approach, we'd rather keep the smaller implementation to reduce long-term maintenance.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/high High-risk PR: dependencies, infra, security-sensitive, or broad runtime impact size/XXL PR size: 1500+ changed lines type/feature Feature or new user-facing capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(agents): 接入 ZCode (Z.AI) adapter

3 participants