Skip to content

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

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

feat(agents): add ZCode (Z.AI) adapter#139
ChHsiching wants to merge 35 commits into
nexu-io:mainfrom
ChHsiching:feat/zcode-adapter

Conversation

@ChHsiching

Copy link
Copy Markdown

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.tsAgentDefbinArgs 字段——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}.tsxi18n.tsstore.ts:vendor 渐变 / hint / app-server 选项。

Testing

  • pnpm -F @html-anything/zcode-protocol test7 files / 83 passed / 1 skipped(含真实 server integration test,~45s round-trip 通过)。
  • pnpm -F @html-anything/next test src/lib/agents3 files / 77 passed(invoke 31 / detect 37 / argv 9)。
  • pnpm -F @html-anything/{next,cli} typecheck → 全绿。
  • pnpm exec tsx scripts/guard.tsGuard 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

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

Thanks @ChHsiching for this substantial ZCode adapter — the protocol package, live-probe-driven session driver, and careful app-server isolation are a strong foundation 🙌

I focused this pass on implementation correctness around install discovery, provider selection, and protocol client lifecycle. Main-path AppImage/Windows flow looks coherent and well tested; the notes below are non-blocking follow-ups that would make the Linux PATH/override paths and multi-provider defaulting match the PR’s stated behavior more closely.

🔁 Powered by Looper · runner=reviewer · agent=grok-build · An autonomous AI dev team for your GitHub repos.

Comment thread next/src/lib/agents/invoke.ts
Comment thread packages/zcode-protocol/src/zcode-config.ts Outdated
Comment thread packages/zcode-protocol/src/zcode-protocol.ts
Comment thread packages/zcode-protocol/src/zcode-config.test.ts Outdated
…efault

Extract one isUsableZcodeProvider predicate (enabled + no non-empty
systemDisabledReason + recognized kind + non-empty apiKey) that both the
relay reader (parseProviderEntry) and the picker call, so the two can no
longer drift — the relay used to bind an entitlement-expired provider
whose models the picker hid. Add parseZcodeConfigForProvider +
readZcodeConfigForProvider so the relay can target the GUI-selected
provider specifically, and route ensureWorkspaceModel through the picker's
defaultProviderId, falling back to the first usable entry when the GUI
default is absent/unusable (ADR-0010 Decision 2; refines ADR-0005 #4).

Closes #29
On Linux the app-server branch always ran mountZcodeAppImage() on resolveZcodeBin()'s result. But a ZCODE_BIN override can point at the zcode.cjs bundle (the detect tests set ZCODE_BIN=<...>.cjs and assert available=true); for a .cjs the mount spawns '<.cjs> --appimage-mount', which fails -- a JS bundle is not an executable AppImage. Detect promised available; every turn then failed at mount.

Branch on the resolved path's shape: when it ends in .cjs, use it directly (no mount, mountChild stays null); otherwise keep the AppImage self-mount flow (ADR-0007 decision 2). The non-Linux branch and the primary AppImage install are untouched. Mirrored in next/ and cli/.

Also drop the 'put zcode on PATH' advice from the not-found hint (ZCode's only official Linux distribution is the AppImage -- ADR-0007) and soften the resolveZcodeBin() doc-comment that advertised Linux .deb/AUR as supported. Decision of record: ADR-0010 decision 1 (amends ADR-0007). Refs #30.
T3 (e339f68) made resolveWorkspaceModelConfig call the new
readZcodeConfigForProvider export before falling back to readZcodeConfig.
But the next/cli app-server invoke tests mocked
@html-anything/zcode-protocol/zcode-config with only readZcodeConfig, so
readZcodeConfigForProvider was undefined — ensureWorkspaceModel threw on
every turn, and all app-server-branch tests failed with empty deltas (next
invoke.test.ts 17 failures; cli agents-invoke.test.ts 9). Production code
is unchanged; this adds the missing mock.

The shared defaultProviderConfig backs both readers. The "no usable
provider" case now nulls BOTH mocks: resolveWorkspaceModelConfig tries the
GUI-default provider first, so nulling only readZcodeConfig no longer
reaches the error path.

next invoke.test.ts 32/32; cli agents-invoke.test.ts app-server branch green
(only the 2 pre-existing win32 relative-binOverride baseline failures
remain). Refs #29.

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

Thanks @ChHsiching — the follow-up commits land the earlier notes cleanly. Linux .cjs now skips AppImage mount, the protocol timeout path calls cleanup(), the shared isUsableZcodeProvider predicate is in place, and the fixture key is sanitized. Really nice tightening of the adapter 🙌

One remaining issue: the new T12 Electron-fallback test stubs process.platform to win32 but asserts with a module-level USE_SHELL captured from the host, so pnpm -F @html-anything/next test fails on Linux/macOS (this repo’s CI runner is ubuntu-latest). Details inline.

🔁 Powered by Looper · runner=reviewer · agent=grok-build · An autonomous AI dev team for your GitHub repos.

Comment thread next/src/lib/agents/__tests__/invoke.test.ts Outdated
…s probe

ZCode's official Linux distributions are the .deb package AND the AppImage
(ADR-0007's AppImage-only premise was wrong — corrected per ADR-0010 / #31).
The .deb installs a loose resources/glm/zcode.cjs at /opt/ZCode/ next to the
Electron binary /opt/ZCode/zcode, the same layout as Windows/macOS.

On Linux, resolveZcodeBin() now probes the on-disk .cjs BEFORE AppImage
discovery (a loose .cjs cleanly distinguishes a .deb from an AppImage, whose
.cjs is packed in the squashfs), and resolveZcodeNodeBin() probes /opt/ZCode/
zcode so a .deb-only host without a system node can still drive the .cjs.
defaultZcodeCjsPaths() Linux returns the .deb path (was []); defaultZcode-
ElectronExePaths() lists /opt/ZCode/zcode first. The AppImage path is
unchanged and remains the fallback when no loose .cjs exists.

Mirrored in next/src/lib/agents/detect.ts and cli/src/agents-detect.ts, with
tests in both detect suites. T4's invoke .cjs-direct branch then runs the
.deb install without mounting.

Refs #31
…dent

The T12 test forces process.platform to "win32" but its decisive assertion
branched on the module-level USE_SHELL (const USE_SHELL = process.platform
=== "win32", evaluated once at import from the REAL host). On Linux/macOS CI
USE_SHELL is false, so the assertion expected the unquoted exe path while
production quoted it under the stubbed win32 -> the test failed CI
(reproduced on darwin: 77 passed / 1 failed). The stubbed platform was also
never restored, leaking win32 to later tests in the file.

Assert the win32 spawn shape (quoted bin + shell:true) unconditionally, since
the test forces win32, and restore process.platform in try/finally. Mirrored
in cli. Production spawn quoting is unchanged. Refs the new mrcfps thread on
next/src/lib/agents/__tests__/invoke.test.ts.

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

Thanks @ChHsiching — the follow-up commits land cleanly. T12 now asserts the win32 spawn shape unconditionally and restores process.platform, the Linux .cjs / .deb path skips AppImage mount, and the earlier protocol/provider notes stay fixed. Nice tightening 🙌

One remaining non-blocking gap on the #19 picker uniqueness contract is inline.

🔁 Powered by Looper · runner=reviewer · agent=grok-build · An autonomous AI dev team for your GitHub repos.

Comment thread packages/zcode-protocol/src/zcode-model-picker.ts Outdated
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.

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

Labels

needs-product-review Feature PR awaiting product sign-off before merge (see roadmap) 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 —— 新增 app-server 协议类型

3 participants