Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions src/__tests__/zcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

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.

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);
Expand Down Expand Up @@ -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);
Expand Down
80 changes: 51 additions & 29 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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.<key>.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',

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?

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.
Expand All @@ -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 <event> --tool <tool>').
if (Array.isArray(hook?.args) && hook.args.length > 2) return hook.args[2] ?? '';
return hook?.command ?? '';
}

Expand Down Expand Up @@ -568,6 +562,33 @@ async function reconcileZcodeFormat(
): Promise<void> {
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<ZcodeHooksJson>(expanded)) ?? {};
if (!cfg.hooks) cfg.hooks = {};
let changed = false;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ZcodeHooksJson>(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);
Expand Down