Skip to content

fix(zcode): strip unknown keys from the hooks block — strict schema rejects the whole config otherwise - #555

Merged
jeff-r2026 merged 3 commits into
Tencent:mainfrom
hc-tec:feat/zcode-support
Sep 16, 2026
Merged

jeff-r2026 merged 3 commits into
Tencent:mainfrom
hc-tec:feat/zcode-support

Conversation

@hc-tec

@hc-tec hc-tec commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add ZCode as a first-class TeamAI target: skills → ~/.zcode/skills/, subagents (Claude-style Markdown) → ~/.zcode/agents/, user-scope MCP → ~/.agents/mcp.json, hooks → ~/.zcode/cli/config.json
  • add a dedicated zcode hook format: ZCode nests Claude-shaped hooks under hooks.events and gates them on hooks.enabled (config-file hooks are disabled by default, so the writer forces it on)
  • write process-type hook entries with network-scale timeouts (a session-start dispatch carries a pull to the team host), store the payload verbatim in args[1], and omit wildcard matchers (ZCode matchers are case-sensitive regexes; * is an invalid pattern that never matches)
  • reconcileZcodeFormat strips schema-unknown keys from the hooks block (strict schema drops the entire block otherwise) and only forces hooks.enabled: true on install — removal preserves the runner state the user chose
  • exclude ZCode from co-author reconciliation (no documented attribution setting); rules are not synced (no user-level rules dir convention)
  • document the integration in both English and Chinese

Closes #461.

Context: ZCode's hooks mechanism is migrating

ZCode desktop is moving from config-file-declared hooks toward settings-page-managed
"installed" hooks (with a trust/review flow, zcode hooks trust command family, and a
per-session snapshot — changes take effect in new sessions). In the current desktop build:

  • config-file entries (hooks.events in ~/.zcode/cli/config.json) are listed under a
    "legacy" group in the hooks settings page, with an import path into the new store
  • the hooks block is validated with a strict schema — any unrecognized key causes the
    entire block to be dropped
    (config_file_invalid diagnostic, hookCount: 0, no hook
    execution), while the settings page still lists the entries as if they were active
  • on Windows, CreateProcess resolves bare bash to System32's WSL launcher before any
    PATH directory, and the WSL side has a different $HOME (no teamai state) and often no
    Node ≥ 20 — so process entries route through cmd /c instead

That is what this fix addresses: since teamai merges into the user's existing config, one
hand-added key would previously stay poisoned forever. reconcileZcodeFormat now keeps
only the keys the schema knows, so the config self-heals on the next sync.

Why

Observed on a real Windows machine (ZCode desktop, CLI 0.16.5): after a hand-edit added
"description": "..." inside hooks, the CLI logged
config_file_invalid — hooks: Unrecognized key: "description", reported hookCount: 0,
and executed no hooks. teamai's merge semantics had faithfully preserved the key across
every sync, so the config stayed in that state. Keeping only schema-known keys makes the
config recover automatically.

Validation

Unit / build

  • npx vitest run src/__tests__/zcode.test.ts (10/10, incl. healing + team-hook lifecycle + removal-state cases)
  • affected suites: agents / agent-format / coauthor-reconcile (97/97)
  • npm run typecheck, npm run build

Real CLI end-to-end (isolated HOME, Windows 10.0.22631, Node v22.22.1, CLI built from this branch):

  1. seeded <HOME>/.zcode/cli/config.json with a poisoned hooks block — unknown
    description key, enabled: false, one pre-existing user hook
    (process / echo user-existing-hook), plus unrelated plugins state
  2. node dist/index.js hooks inject
    • description key dropped; hooks.enabled flipped to true
    • 6 teamai entries injected; pre-existing user hook and plugins state preserved verbatim
  3. second hooks inject → file byte-identical (md5 unchanged) — idempotent
  4. node dist/index.js hooks remove → teamai entries stripped; user hook and plugins preserved
    (unit mirror: zcode.test.ts covers heals/inject/idempotent/remove)

Real-machine migration incident (ZCode desktop 3.9.2 / CLI 0.16.5):

  • poisoned config (description key): CLI log
    config_file_invalid — hooks: Unrecognized key: "description", hookCount: 0, no hook execution
  • after the unknown key was removed and ZCode restarted: bootstrap logs hookCount: 6,
    no config_file_invalid; hooks executed on session events — session_start recorded,
    the auto-pull completed (teamai status last pull refreshed; a stale remote commit synced),
    and prompt_submit / tool_use / stop were recorded through subsequent sessions

Boundary

  • no new runtime dependency or CLI command
  • rules are not synced (ZCode has no user-level rules dir convention)
  • project-scope MCP is not wired (ZCode stores workspace MCP under the mcp.servers key inside .zcode/config.json, which the Claude writer cannot emit)
  • ZCode is excluded from co-author reconciliation
  • adapting to the new installed-hooks mechanism (trust/review flow, entry shape) is a
    possible follow-up once its storage/format is stable and documented

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

Test Plan

  • npx tsc --noEmit passes
  • npx vitest run passes
  • Added/updated tests for the change

Related Issues

Closes #461

Notes for Reviewers

  • ~/.zcode/cli/config.json is shared with ZCode's own state (plugins etc.) —
    reconcileZcodeFormat merges the document and only ever touches hooks.enabled /
    hooks.events.<Event>, plus dropping schema-unknown keys.
  • Managed-entry detection follows the Codex format's strategy (command markers + manifest)
    because ZCode hook groups carry no description field; both entry variants (posix
    bash -lc / win32 cmd /c) carry the payload verbatim at args[1], keeping one
    command representation across the entry, the manifest, and the desired set.
  • Happy to split or reword anything — this is framed as a compatibility accommodation for
    the migrating hooks mechanism, not a judgement on its design.

@hc-tec
hc-tec marked this pull request as draft September 14, 2026 08:30
@jeff-r2026

jeff-r2026 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Please add a real CLI end-to-end validation report showing the behavior after this fix. The current description covers reproducing the issue and inspecting the reconciled config, but it would help to confirm that the user-facing workflow actually recovers:

  1. Starting with a config containing an unknown hooks key, which built teamai CLI commands did you run? Please include the commands, relevant versions, and environment.
  2. After inject/pull, what changed in the config, and were the existing valid hooks preserved?
  3. After restarting ZCode or opening a new session, did config_file_invalid disappear, did hookCount recover, and did the hook actually execute when the corresponding event was triggered? Please include relevant logs or observable results.

Please add the steps that actually passed and their results to Validation / Test Plan so reviewers can verify the complete flow from teamai syncing the config to ZCode executing the hook.

@jeff-r2026
jeff-r2026 marked this pull request as ready for review September 15, 2026 06:55
@jeff-r2026
jeff-r2026 self-requested a review September 15, 2026 06:56
@hc-tec

hc-tec commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@jeff-r2026 Thanks for the detailed checklist — here is the end-to-end validation, all steps actually executed on Windows (10.0.22631, Node v22.22.1, CLI built from this branch, ZCode desktop 3.9.2 / CLI 0.16.5).

1. Poisoned config + commands run (isolated HOME):

  • seeded <HOME>/.zcode/cli/config.json with hooks: { enabled: false, description: "hand-added note", events: { SessionStart: [one user-owned process hook] } } plus unrelated plugins state
  • minimal team config (~/.teamai/config.yaml + team repo stub), then:
    HOME=<sandbox> node dist/index.js hooks inject — twice (idempotency), then hooks remove

2. What changed after inject (existing valid hooks preserved):

  • description key dropped; hooks.enabled flipped false → true
  • 6 teamai entries injected; the pre-existing user hook survived verbatim (SessionStart[0]); plugins state untouched
  • second inject: byte-identical file (md5 unchanged)

3. Real-machine migration incident (the incident that motivated this PR):

  • poisoned config (description key): CLI log showed config_file_invalid — hooks: Unrecognized key: "description", hookCount: 0, zero hook execution
  • after removing the unknown key and restarting ZCode: bootstrap logs switched to hookCount: 6 with no config_file_invalid, and the hooks executed on real session events — session_start recorded, the session-start auto-pull completed (teamai status last-pull refreshed, a stale remote commit synced), followed by prompt_submit / tool_use / stop recordings through subsequent sessions

All of the above is now folded into the PR description (Validation section) with the environment and commands, per your checklist. The PR description also documents the two mechanism-level findings from the same dogfooding: the win32 cmd /c routing (bare bash resolves to the WSL launcher via System32) and the network-scale timeouts (session-start dispatch carries a pull).

ZCode validates the hooks block against a strict schema and rejects the
entire block on any unrecognized key (config_file_invalid → hookCount 0 →
no hook fires, with the runner still reporting installed). A single
hand-added annotation key therefore silently kills every hook.

reconcileZcodeFormat now heals the config by keeping only the keys the
schema knows (enabled/events), so a poisoned config recovers on the next
inject/pull instead of staying dead.
Session-start dispatches carry a network pull to the team host; on slower
links that exceeds the 10-15s builtin shell-hook defaults, so ZCode killed
the hook mid-pull (observed: hook.run.failed at durationMs 10023 with a
10000ms timeout, last pull left stale). toZcodeEntry now applies a
per-event timeout table (SessionStart 180s, Stop/UserPromptSubmit 60s,
PostToolUse 30s) instead of inheriting the shell-hook defaults.
On Windows, CreateProcess resolves bare `bash` to System32's WSL launcher
before any PATH directory. The WSL side has a different $HOME (no teamai
state) and often no Node >= 20, so the spawned hook no-ops or dies —
observed on a real machine: hook.run.failed for every ZCode hook while
the same payload succeeded through Git Bash.

On win32, render entries as cmd /c <payload> (cmd.exe always exists in
System32; the npm .cmd shim resolves via PATHEXT). POSIX entries keep
bash -lc. Payload stays verbatim in args[1] on both variants, keeping the
manifest-invariant that fixed the team-hook matching bug.
@jeff-r2026
jeff-r2026 merged commit 6d62429 into Tencent:main Sep 16, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 支持 ZCode(skills/subagents/hooks/MCP 自动分发)

2 participants