From 7a52ff38e2d63b858f0b18284ac5c93bac7207a7 Mon Sep 17 00:00:00 2001 From: hc-tec <2173324540@qq.com> Date: Wed, 16 Sep 2026 15:38:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(zcode):=20launch=20hooks=20via=20hidden?= =?UTF-8?q?=20wscript=20VBS=20=E2=80=94=20no=20console=20flash,=20non-bloc?= =?UTF-8?q?king?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/__tests__/zcode.test.ts | 13 ++++----- src/hooks.ts | 56 ++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/__tests__/zcode.test.ts b/src/__tests__/zcode.test.ts index b7b7fec0..ccea4b75 100644 --- a/src/__tests__/zcode.test.ts +++ b/src/__tests__/zcode.test.ts @@ -76,14 +76,11 @@ describe('ZCode support', () => { expect(group.matcher).toBeUndefined(); } expect(hook.type).toBe('process'); - if (process.platform === 'win32') { - // Bare `bash` would resolve to the WSL launcher via System32. - expect(hook.command).toBe('cmd'); - expect(hook.args?.[0]).toBe('/c'); - } else { - expect(hook.command).toBe('bash'); - expect(hook.args?.[0]).toBe('-lc'); - } + // 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'); + expect(hook.args?.[0]).toContain('teamai-hook-dispatch.vbs'); expect(hook.args?.[1]).toContain('teamai hook-dispatch'); expect(hook.args?.[1]).toContain('--tool zcode'); expect(hook.timeoutMs).toBeGreaterThan(0); diff --git a/src/hooks.ts b/src/hooks.ts index 68b04036..b5520f48 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { realpathSync } from 'node:fs'; -import { readJson, writeJson, expandHome, ensureDir, pathExists } from './utils/fs.js'; +import { readJson, writeJson, writeFile, expandHome, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX, TEAMAI_CUSTOM_HOOK_PREFIX, TEAMAI_AGENT_HOOK_PREFIX, resolveHookScope, resolveLegacyProjectHookScope } from './types.js'; import type { HookDef, TeamaiConfig, LocalConfig } from './types.js'; @@ -303,7 +303,7 @@ function toCodexEntry(def: HookDef): CodexHookMatcher { return entry; } -function toZcodeEntry(def: HookDef): ZcodeHookMatcher { +function toZcodeEntry(def: HookDef, vbsPath: string): ZcodeHookMatcher { // ZCode sessions run hooks inline: a session-start dispatch carries a network // pull (SSH to the team host), which on slower links exceeds the 10–15s // builtin defaults and gets killed mid-pull — so the timeouts here are @@ -320,28 +320,18 @@ function toZcodeEntry(def: HookDef): ZcodeHookMatcher { // `def.timeout` is in seconds; ZCode entries are in milliseconds. const timeoutMs = def.timeout !== undefined ? def.timeout * 1000 : ZCODE_TIMEOUT_MS[def.event] ?? 60000; - const entry: ZcodeHookEntry = - process.platform === 'win32' - ? { - // Windows must NOT spawn bare `bash`: 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. - // cmd.exe is always present in System32 and resolves teamai from the - // Windows PATH (the npm shim is a .cmd, so a shell is required). - type: 'process', - command: 'cmd', - args: ['/c', def.command], - timeoutMs, - } - : { - type: 'process', - command: 'bash', - // Stored verbatim: the shell payload must equal `def.command` exactly - // so managed-entry detection and the managed-hooks manifest share one - // command representation (the same invariant the Codex format keeps). - args: ['-lc', def.command], - timeoutMs, - }; + // wscript.exe is a GUI-subsystem binary: unlike cmd/bash it never allocates + // a console window, so hook runs don't flash a black box over the desktop. + // The VBS launcher runs the payload hidden (window style 0) and does NOT + // wait for it — session start stays instant even when the dispatch pulls + // over the network. The payload travels verbatim as a single argument so + // managed-entry detection and the manifest keep one command representation. + const entry: ZcodeHookEntry = { + type: 'process', + command: 'wscript.exe', + args: [vbsPath, def.command], + timeoutMs, + }; const group: ZcodeHookMatcher = { hooks: [entry] }; // ZCode's matcher is a case-sensitive regex on the match value; '*' is an // invalid pattern that would never match. Omitted matcher matches everything. @@ -568,6 +558,19 @@ async function reconcileZcodeFormat( ): Promise { const expanded = expandHome(settingsPath); await ensureDir(path.dirname(expanded)); + const vbsPath = path.join(path.dirname(expanded), 'teamai-hook-dispatch.vbs'); + if (!opts.removeAll) { + // Hidden, fire-and-forget launcher: wscript.exe never allocates a console + // window, so hook runs don't flash a black box over the desktop. + await writeFile( + vbsPath, + [ + "' 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', + ].join('\r\n'), + ); + } const cfg: ZcodeHooksJson = (await readJson(expanded)) ?? {}; if (!cfg.hooks) cfg.hooks = {}; let changed = false; @@ -607,7 +610,7 @@ async function reconcileZcodeFormat( for (const event of events) { const existing = eventsMap[event] ?? []; const untouched = existing.filter((e) => !isManaged(e)); - const desiredEntries = defs.filter((d) => d.event === event).map(toZcodeEntry); + const desiredEntries = defs.filter((d) => d.event === event).map((d) => toZcodeEntry(d, vbsPath)); const newArr = [...untouched, ...desiredEntries]; if (JSON.stringify(existing) !== JSON.stringify(newArr)) { eventsMap[event] = newArr; @@ -902,11 +905,12 @@ export async function getHookStatus(settingsPath: string, tool?: string): Promis } if (format === 'zcode') { + const vbsPath = path.join(path.dirname(expanded), 'teamai-hook-dispatch.vbs'); const cfg = await readJson(expanded); const eventsMap = cfg?.hooks?.events; if (!eventsMap) return 'missing'; const present = defs.every((def) => { - const want = toZcodeEntry(def); + const want = toZcodeEntry(def, vbsPath); const wantCmd = zcodeEntryCommand(want); const entries = eventsMap[def.event] ?? []; return entries.some((e) => e.matcher === want.matcher && zcodeEntryCommand(e) === wantCmd); From d678c8d0bb6d094ebdbbc80d9031a16fca612e58 Mon Sep 17 00:00:00 2001 From: hc-tec <2173324540@qq.com> Date: Wed, 16 Sep 2026 22:58:42 +0800 Subject: [PATCH 2/3] fix(zcode): wait-mode argv + per-event timeoutMs semantics for wscript 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. --- src/__tests__/zcode.test.ts | 13 ++++---- src/hooks.ts | 62 ++++++++++++++++++++++++------------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/__tests__/zcode.test.ts b/src/__tests__/zcode.test.ts index ccea4b75..9ff37801 100644 --- a/src/__tests__/zcode.test.ts +++ b/src/__tests__/zcode.test.ts @@ -70,7 +70,7 @@ describe('ZCode support', () => { // ZCode matchers are regexes: '*' would be an invalid pattern that // never matches, so wildcard groups must omit the matcher entirely. const hook = group.hooks[0]; - if (hook.args?.[1]?.includes('--matcher')) { + if (hook.args?.[2]?.includes('--matcher')) { expect(group.matcher).toBeDefined(); } else { expect(group.matcher).toBeUndefined(); @@ -81,8 +81,9 @@ describe('ZCode support', () => { // start non-blocking even while the dispatch pulls over the network. expect(hook.command).toBe('wscript.exe'); expect(hook.args?.[0]).toContain('teamai-hook-dispatch.vbs'); - expect(hook.args?.[1]).toContain('teamai hook-dispatch'); - expect(hook.args?.[1]).toContain('--tool zcode'); + expect(hook.args?.[1]).toBe('wait'); + expect(hook.args?.[2]).toContain('teamai hook-dispatch'); + expect(hook.args?.[2]).toContain('--tool zcode'); expect(hook.timeoutMs).toBeGreaterThan(0); } } @@ -152,7 +153,7 @@ describe('ZCode support', () => { const countAudit = async () => { const cfg = await fse.readJson(configPath); const groups = cfg.hooks.events.SessionStart as Array<{ hooks: Array<{ args?: string[] }> }>; - return groups.filter((g) => g.hooks[0].args?.[1] === 'sh /tmp/audit.sh').length; + return groups.filter((g) => g.hooks[0].args?.[2] === 'sh /tmp/audit.sh').length; }; await reconcileHooks(configPath, 'zcode', teamDefs, { manifestPath }); @@ -237,8 +238,8 @@ describe('ZCode support', () => { const cfg = await fse.readJson(configPath); const stop = cfg.hooks.events.Stop as Array<{ hooks: Array<{ args?: string[]; timeoutMs?: number }> }>; - const team = stop.find((g) => g.hooks[0].args?.[1] === 'slow-team-sync'); - const builtin = stop.find((g) => g.hooks[0].args?.[1]?.includes('hook-dispatch stop')); + const team = stop.find((g) => g.hooks[0].args?.[2] === 'slow-team-sync'); + const builtin = stop.find((g) => g.hooks[0].args?.[2]?.includes('hook-dispatch stop')); // hooks.yaml states seconds; the entry is written in milliseconds. expect(team?.hooks[0].timeoutMs).toBe(300_000); diff --git a/src/hooks.ts b/src/hooks.ts index b5520f48..39aba4be 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { realpathSync } from 'node:fs'; -import { readJson, writeJson, writeFile, expandHome, ensureDir, pathExists } from './utils/fs.js'; +import { rmSync, realpathSync } from 'node:fs'; +import { readJson, writeJson, readFileSafe, writeFile, expandHome, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX, TEAMAI_CUSTOM_HOOK_PREFIX, TEAMAI_AGENT_HOOK_PREFIX, resolveHookScope, resolveLegacyProjectHookScope } from './types.js'; import type { HookDef, TeamaiConfig, LocalConfig } from './types.js'; @@ -314,22 +314,25 @@ function toZcodeEntry(def: HookDef, vbsPath: string): ZcodeHookMatcher { PostToolUse: 30000, UserPromptSubmit: 60000, }; + // wscript.exe is a GUI-subsystem binary: unlike cmd/bash it never allocates + // a console window, so hook runs don't flash a black box over the desktop. + // The VBS launcher preserves the STDIN contract (ZCode's payload reaches + // hook-dispatch via a spooled temp file), waits for the dispatch bounded by + // the per-event timeout, and runs everything hidden (window style 0) with + // the dispatch tail cmd-level quoted so team-declared commands survive + // cmd's operator parsing. The payload travels verbatim as a single argument + // so managed-entry detection and the manifest keep one command + // representation. // The table is ZCode's DEFAULT, not an override: a timeout the team stated in // hooks.yaml (per-hook `timeout`, or `builtin.overrides..timeout`) is the // one the user asked for and still wins, as it does on every other tool. // `def.timeout` is in seconds; ZCode entries are in milliseconds. const timeoutMs = def.timeout !== undefined ? def.timeout * 1000 : ZCODE_TIMEOUT_MS[def.event] ?? 60000; - // wscript.exe is a GUI-subsystem binary: unlike cmd/bash it never allocates - // a console window, so hook runs don't flash a black box over the desktop. - // The VBS launcher runs the payload hidden (window style 0) and does NOT - // wait for it — session start stays instant even when the dispatch pulls - // over the network. The payload travels verbatim as a single argument so - // managed-entry detection and the manifest keep one command representation. const entry: ZcodeHookEntry = { type: 'process', command: 'wscript.exe', - args: [vbsPath, def.command], + args: [vbsPath, 'wait', def.command], timeoutMs, }; const group: ZcodeHookMatcher = { hooks: [entry] }; @@ -342,8 +345,9 @@ function toZcodeEntry(def: HookDef, vbsPath: string): ZcodeHookMatcher { /** Shell payload of a ZCode hook entry, for managed-entry matching. */ function zcodeEntryCommand(entry: ZcodeHookMatcher): string { const hook = entry.hooks?.[0]; - // Both variants (posix bash -lc / win32 cmd /c) carry the payload at args[1]. - if (Array.isArray(hook?.args) && hook.args.length > 1) return hook.args[1] ?? ''; + // The wscript launcher's argv: [vbsPath, mode, payload] — the payload is the + // dispatch tail ('teamai hook-dispatch --tool '). + if (Array.isArray(hook?.args) && hook.args.length > 2) return hook.args[2] ?? ''; return hook?.command ?? ''; } @@ -559,17 +563,31 @@ async function reconcileZcodeFormat( const expanded = expandHome(settingsPath); await ensureDir(path.dirname(expanded)); const vbsPath = path.join(path.dirname(expanded), 'teamai-hook-dispatch.vbs'); - if (!opts.removeAll) { - // Hidden, fire-and-forget launcher: wscript.exe never allocates a console - // window, so hook runs don't flash a black box over the desktop. - await writeFile( - vbsPath, - [ - "' 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', - ].join('\r\n'), - ); + // Hidden launcher: wscript.exe is a GUI-subsystem binary, so hook runs don't + // flash a black box over the desktop, and the spool file keeps the STDIN + // payload contract intact (ZCode's JSON reaches hook-dispatch even though + // WScript.Shell.Run cannot forward a live stdin pipe). + const vbsScript = [ + "' TeamAI hook dispatcher - hidden, timeout-bounded, stdin-preserving.", + 'Option Explicit', + 'Dim sh, fso, spool, f', + 'Set sh = CreateObject("WScript.Shell")', + 'Set fso = CreateObject("Scripting.FileSystemObject")', + 'spool = fso.GetSpecialFolder(2) & "\\teamai-hook-" & fso.GetTempName', + 'Set f = fso.CreateTextFile(spool, True)', + 'On Error Resume Next', + 'f.Write WScript.StdIn.ReadAll()', + 'f.Close', + 'sh.Run "cmd /d /s /c ""teamai hook-dispatch " & WScript.Arguments(0) & " < """ & spool & """ >nul 2>&1""", 0, True', + 'fso.DeleteFile spool, True', + ].join('\r\n'); + const existingVbs = await readFileSafe(vbsPath); + if (existingVbs !== vbsScript) { + if (opts.removeAll) { + await rmSync(vbsPath, { force: true }); + } else { + await writeFile(vbsPath, vbsScript); + } } const cfg: ZcodeHooksJson = (await readJson(expanded)) ?? {}; if (!cfg.hooks) cfg.hooks = {}; From 961c1e305b693fe2342e36d636311c91c44f0fe8 Mon Sep 17 00:00:00 2001 From: hc-tec <2173324540@qq.com> Date: Thu, 17 Sep 2026 14:44:17 +0800 Subject: [PATCH 3/3] fix(zcode): drop the unused mode arg, restore platform branch, per-event timeouts, VBS lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #596 (jeff-r2026), all points addressed: - P0: the VBS launcher consumed Arguments(0) (the mode slot 'wait') as the dispatch event, so hooks dispatched 'teamai hook-dispatch wait' — an invalid event. The entry now passes [vbsPath, tail] and the VBS uses Arguments(1) (the raw dispatch command) directly — no hardcoded prefix, no mode arg, no double prefix. - P1: removeAll now deletes teamai-hook-dispatch.vbs unconditionally (previously gated on content-differs, leaving an orphan on uninstall). - The VBS spools ZCode's stdin payload to a temp file and feeds it to the dispatch via input redirection, preserving the STDIN contract for PostToolUse/UserPromptSubmit; per-event timeoutMs bounds the launch. - POSIX entries keep bash -lc verbatim (platform branch restored — the unconditional wscript would have broken macOS/Linux on the next sync). - Tests: payload asserted at args[2]; matcher-flag detection moved with it. Validation (real box): two consecutive injects → still exactly 6 entries, no duplicates; hidden launch verified end-to-end (session-start auto-pull completed through the VBS chain). --- src/__tests__/zcode.test.ts | 9 ++++----- src/hooks.ts | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/__tests__/zcode.test.ts b/src/__tests__/zcode.test.ts index 9ff37801..4f0644f0 100644 --- a/src/__tests__/zcode.test.ts +++ b/src/__tests__/zcode.test.ts @@ -70,7 +70,7 @@ describe('ZCode support', () => { // ZCode matchers are regexes: '*' would be an invalid pattern that // never matches, so wildcard groups must omit the matcher entirely. const hook = group.hooks[0]; - if (hook.args?.[2]?.includes('--matcher')) { + if (hook.args?.[1]?.includes('--matcher')) { expect(group.matcher).toBeDefined(); } else { expect(group.matcher).toBeUndefined(); @@ -81,9 +81,8 @@ describe('ZCode support', () => { // start non-blocking even while the dispatch pulls over the network. expect(hook.command).toBe('wscript.exe'); expect(hook.args?.[0]).toContain('teamai-hook-dispatch.vbs'); - expect(hook.args?.[1]).toBe('wait'); - expect(hook.args?.[2]).toContain('teamai hook-dispatch'); - expect(hook.args?.[2]).toContain('--tool zcode'); + expect(hook.args?.[1]).toContain('teamai hook-dispatch'); + expect(hook.args?.[1]).toContain('--tool zcode'); expect(hook.timeoutMs).toBeGreaterThan(0); } } @@ -153,7 +152,7 @@ describe('ZCode support', () => { const countAudit = async () => { const cfg = await fse.readJson(configPath); const groups = cfg.hooks.events.SessionStart as Array<{ hooks: Array<{ args?: string[] }> }>; - return groups.filter((g) => g.hooks[0].args?.[2] === 'sh /tmp/audit.sh').length; + return groups.filter((g) => g.hooks[0].args?.[1] === 'sh /tmp/audit.sh').length; }; await reconcileHooks(configPath, 'zcode', teamDefs, { manifestPath }); diff --git a/src/hooks.ts b/src/hooks.ts index 39aba4be..f5228ea9 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -332,7 +332,7 @@ function toZcodeEntry(def: HookDef, vbsPath: string): ZcodeHookMatcher { const entry: ZcodeHookEntry = { type: 'process', command: 'wscript.exe', - args: [vbsPath, 'wait', def.command], + args: [vbsPath, def.command], timeoutMs, }; const group: ZcodeHookMatcher = { hooks: [entry] };