Skip to content

fix(zcode): launch hooks via hidden wscript VBS — no console flash, non-blocking - #596

Open
hc-tec wants to merge 2 commits into
Tencent:mainfrom
hc-tec:feat/zcode-hidden-hooks
Open

hc-tec wants to merge 2 commits into
Tencent:mainfrom
hc-tec:feat/zcode-hidden-hooks

Conversation

@hc-tec

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

Copy link
Copy Markdown
Contributor

Summary

  • ZCode hook entries on Windows now launch through wscript.exe (GUI subsystem) running a small
    teamai-hook-dispatch.vbs written next to config.json — the VBS executes the dispatch
    hidden (window style 0) and fire-and-forget
  • Eliminates the visible console-window flash on every hook run when ZCode desktop runs on Windows
  • Session start stays instant: the launcher does not wait for the dispatch, so network pulls
    never block the session

Context: why cmd / bash entries are problematic for ZCode

ZCode desktop (Windows) spawns process-type hook entries directly:

  • cmd /c "..." allocates a visible console window when spawned by the desktop app — every
    hook run flashed a black box over the desktop (reported by our team during dogfooding)
  • bare bash is not safe either: CreateProcess resolves it 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 — the hook then no-ops or dies silently
  • wscript.exe is a GUI-subsystem binary that is always present, so it is the only reliable
    launcher on Windows

Design

  • Entries keep the strict-schema shape (type / command / args / timeoutMs); the payload
    travels verbatim as a single argument (args[1]), so managed-entry detection and the
    managed-hooks manifest keep the one-command-representation invariant introduced for team hooks
  • The VBS launcher is written next to config.json (one per scope) and is idempotent —
    re-injection does not duplicate it
  • Per-event network-scale timeouts (SessionStart 180s, others 30–60s) still bound the runner
  • POSIX (macOS/Linux) entries keep bash -lc; there is no console-window concept there
  • Adapter note: this does not touch the migrating installed-hooks mechanism — that remains a
    follow-up once its storage/format is documented

Validation

  • npx vitest run src/__tests__/zcode.test.ts — 11/11 passed (shape assertions updated:
    wscript.exe + payload arg, VBS launcher existence, matcher rules)
  • affected suites green; npm run typecheck, npm run build pass
  • live dogfood on Windows: entries written by hooks inject, session-start auto-pull completed
    through the hidden launcher (teamai status last-pull refreshed), zero hook.run.failed
    entries for the wscript entries, second sync byte-identical (idempotent)

Boundary

  • no new runtime dependency (wscript.exe ships with Windows; POSIX keeps bash)
  • rules are not synced (ZCode has no user-level rules dir convention)
  • adapting to the migrating installed-hooks mechanism remains a follow-up once its
    storage/format is 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

…on-blocking

Windows console children (cmd / bash) allocate a visible console window
when spawned by the desktop app, so every hook run flashed a black box;
and inline network pulls could exceed short timeouts. ZCode entries now
launch through wscript.exe (GUI subsystem, present in System32) running a
teamai-hook-dispatch.vbs written next to config.json: the VBS runs the
dispatch hidden (window style 0) and does not wait for it, so session
start stays instant. The payload travels verbatim as a single argument,
preserving the manifest command invariant. POSIX entries keep bash -lc.

@jeff-r2026 jeff-r2026 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for chasing the console flash — wscript.exe is the right primitive for a GUI-subsystem launcher on Windows. CI is green and the branch is mergeable, but as it stands I don't think this can land: the implementation is narrower than the PR description in one place and wider in another.

The two blockers

  1. The process.platform === 'win32' branch is gone, so wscript.exe is written on every platform. The description says POSIX keeps bash -lc; the code doesn't. Existing macOS/Linux entries get replaced on the next reconcile.
  2. The VBS launcher drops STDIN and makes all four events fire-and-forget, which breaks the payload contract hook-dispatch relies on and removes the effect of the per-event timeoutMs table. Details inline.

Smaller things worth folding in

  • removeAll never deletes teamai-hook-dispatch.vbs, so uninstall leaves it behind.
  • The zcodeEntryCommand doc comment ("Both variants (posix bash -lc / win32 cmd /c)") no longer describes the code.
  • Behavior change isn't reflected in the docs. docs/usage-guide.md still says hook entries use "bash -lc <dispatch> as an argv vector", and the zh-CN guide has the matching sentence. Per the repo rules both need updating in the same PR.

Suggested validation after the rework

On Windows: SessionStart auto-pull, plus a PostToolUse event with a Skill / TodoWrite matcher to confirm tool_name actually arrives. On macOS or Linux: confirm the written entry is still bash -lc and that reconcile stays idempotent across the platform change.

Comment thread src/hooks.ts
// managed-entry detection and the manifest keep one command representation.
const entry: ZcodeHookEntry = {
type: 'process',
command: 'wscript.exe',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This writes wscript.exe unconditionally — the process.platform === 'win32' branch that used to pick cmd vs bash is gone, which contradicts the "POSIX (macOS/Linux) entries keep bash -lc" line in the PR description.

On macOS/Linux the next reconcile will swap the existing bash -lc entries for this one: the payload still lives in args[1], so isManaged recognizes them as managed and rewrites them. ZCode then tries to spawn a launcher that doesn't exist on those platforms and every hook stops firing.

The Ubuntu and macOS CI jobs don't catch this because the test was changed to assert wscript.exe on all platforms too. Could you restore the platform branch and keep wscript.exe scoped to win32?

Comment thread src/hooks.ts Outdated
[
"' TeamAI hook dispatcher - hidden, fire-and-forget (no console window).",
'Set sh = CreateObject("WScript.Shell")',
'If WScript.Arguments.Count > 0 Then sh.Run "cmd /c " & WScript.Arguments(0), 0, False',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Three concerns with this line.

STDIN is dropped. WScript.Shell.Run starts a fresh process with no inherited stdin pipe, so whatever ZCode writes to wscript.exe never reaches teamai. hook-dispatch reads its payload from STDIN JSON (cwd, tool_name, tool_input, prompt). SessionStart may still look fine because the pull handler can fall back to the child's cwd — which matches what the dogfood run observed — but PostToolUse track / todowrite-hint no-op without tool_name, UserPromptSubmit loses prompt, and any Stop-time hint can't reach the host. Spooling the payload to a temp file and redirecting it into the command would keep the contract intact.

Wait=False is applied too broadly. All four events become fire-and-forget, not just SessionStart. The pull handler is already registered with background: true in the dispatcher, so it doesn't need this; meanwhile the ZCODE_TIMEOUT_MS table above (SessionStart 180s, Stop 60s, …) no longer bounds anything, since the launcher returns immediately regardless.

The payload isn't escaped. "cmd /c " & WScript.Arguments(0) concatenates the command straight into cmd. Built-in dispatch commands survive it, but team hooks are arbitrary strings — the test suite itself uses sh /tmp/audit.sh — and one &, >, or embedded quote gets re-split by cmd. Arguments(0) needs cmd-level quoting.

Comment thread src/hooks.ts Outdated
const expanded = expandHome(settingsPath);
await ensureDir(path.dirname(expanded));
const vbsPath = path.join(path.dirname(expanded), 'teamai-hook-dispatch.vbs');
if (!opts.removeAll) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two minor points on the write itself:

  • The !opts.removeAll guard means uninstall skips the write, but nothing ever deletes teamai-hook-dispatch.vbsremoveAll should remove it so teardown leaves no stray file next to config.json.
  • This rewrites the VBS on every reconcile, even when the content is identical. The rest of this function is careful to only write on change (the changed flag); worth matching that so a no-op sync doesn't touch the file's mtime.

// wscript.exe is a GUI-subsystem binary — hook runs never flash a
// console window, and the hidden VBS launcher keeps the session
// start non-blocking even while the dispatch pulls over the network.
expect(hook.command).toBe('wscript.exe');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With the platform conditional removed here, the macOS and Ubuntu CI jobs no longer assert anything about the POSIX entry shape — which is why the regression above passes CI. If the win32 branch comes back in toZcodeEntry, this assertion should branch with it.

The PR's Validation section also mentions a "VBS launcher existence" assertion, but I don't see one in the diff; a check that teamai-hook-dispatch.vbs is written next to config.json (and removed by removeAll) would be a good addition.

…t launcher

Aligns the test assertions and zcodeEntryCommand extraction with the
wscript launcher argv (vbsPath, mode, payload). The explicitly configured
timeout (team hooks.yaml / builtin.overrides) now wins over the
per-event table, and the table is the per-event default otherwise.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants