diff --git a/src/__tests__/zcode.test.ts b/src/__tests__/zcode.test.ts index b7b7fec0..4f0644f0 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); @@ -240,8 +237,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 68b04036..f5228ea9 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 { 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'; @@ -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 @@ -314,34 +314,27 @@ function toZcodeEntry(def: HookDef): 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; - 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, - }; + 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. @@ -352,8 +345,9 @@ function toZcodeEntry(def: HookDef): 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 ?? ''; } @@ -568,6 +562,33 @@ async function reconcileZcodeFormat( ): Promise { const expanded = expandHome(settingsPath); await ensureDir(path.dirname(expanded)); + const vbsPath = path.join(path.dirname(expanded), 'teamai-hook-dispatch.vbs'); + // 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 = {}; let changed = false; @@ -607,7 +628,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 +923,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);