diff --git a/src/__tests__/zcode.test.ts b/src/__tests__/zcode.test.ts index e63f8927..21f36f87 100644 --- a/src/__tests__/zcode.test.ts +++ b/src/__tests__/zcode.test.ts @@ -43,7 +43,6 @@ describe('ZCode support', () => { expect(def.command.startsWith('teamai hook-dispatch ')).toBe(true); expect(def.command).toContain('--tool zcode'); expect(def.command).not.toContain('bash -lc'); - expect(def.timeout).toBeDefined(); } const events = new Set(defs.map((d) => d.event)); expect(events).toEqual(new Set(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit'])); @@ -70,17 +69,24 @@ describe('ZCode support', () => { for (const group of entries) { // ZCode matchers are regexes: '*' would be an invalid pattern that // never matches, so wildcard groups must omit the matcher entirely. - if (group.hooks[0].args?.[1]?.includes('--matcher')) { + const hook = group.hooks[0]; + if (hook.args?.[1]?.includes('--matcher')) { expect(group.matcher).toBeDefined(); } else { expect(group.matcher).toBeUndefined(); } - expect(group.hooks[0].type).toBe('process'); - expect(group.hooks[0].command).toBe('bash'); - expect(group.hooks[0].args?.[0]).toBe('-lc'); - expect(group.hooks[0].args?.[1]).toContain('teamai hook-dispatch'); - expect(group.hooks[0].args?.[1]).toContain('--tool zcode'); - expect(group.hooks[0].timeoutMs).toBeGreaterThan(0); + 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'); + } + expect(hook.args?.[1]).toContain('teamai hook-dispatch'); + expect(hook.args?.[1]).toContain('--tool zcode'); + expect(hook.timeoutMs).toBeGreaterThan(0); } } @@ -191,6 +197,28 @@ describe('ZCode support', () => { } }); + it('heals unknown keys in the hooks block (ZCode strict schema rejects the whole block otherwise)', async () => { + const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-zcode-test-')); + try { + const configPath = path.join(home, '.zcode', 'cli', 'config.json'); + await fse.ensureDir(path.dirname(configPath)); + // A hand-added annotation key is enough for ZCode to drop every hook. + await fse.writeJson(configPath, { + plugins: {}, + hooks: { enabled: false, description: 'my hooks', events: {} }, + }); + + await reconcileHooks(configPath, 'zcode'); + + const cfg = await fse.readJson(configPath); + expect(Object.keys(cfg.hooks).sort()).toEqual(['enabled', 'events']); + expect(cfg.hooks.enabled).toBe(true); + expect(await getHookStatus(configPath, 'zcode')).toBe('installed'); + } finally { + await fse.remove(home); + } + }); + it('removeAll preserves a user-disabled hooks.enabled while stripping entries', async () => { const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-zcode-test-')); try { diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index a8f54528..1829deb3 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -217,7 +217,9 @@ const BUILTIN_HOOK_SPECS: BuiltinHookSpec[] = [ const WRAPPER_TOOLS = SHELL_DEPENDENT_TOOLS; export function builtinHookDefs(tool: string): HookDef[] { - const withTimeout = tool === 'cursor' || tool === 'workbuddy' || tool === 'codebuddy' || tool === 'zcode'; + // ZCode renders per-event timeouts from the ZCODE_TIMEOUT_MS table in its own + // writer (toZcodeEntry), so def.timeout stays unset for it. + const withTimeout = tool === 'cursor' || tool === 'workbuddy' || tool === 'codebuddy'; const buildCommand = tool === 'zcode' ? getRawDispatchCommand : WRAPPER_TOOLS.has(tool) ? getWrapperDispatchCommand : getDispatchCommand; diff --git a/src/hooks.ts b/src/hooks.ts index 52bc8bd6..425b92e4 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -105,8 +105,8 @@ interface ZcodeHookMatcher { interface ZcodeHooksJson { hooks?: { enabled?: boolean; - description?: string; events?: Record; + [key: string]: unknown; }; [key: string]: unknown; } @@ -303,17 +303,38 @@ function toCodexEntry(def: HookDef): CodexHookMatcher { } function toZcodeEntry(def: HookDef): ZcodeHookMatcher { - const entry: ZcodeHookEntry = { - 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). teamai - // hook-dispatch is silent and failure-tolerant on its success paths, so no - // shell redirection is layered on top of the payload. - args: ['-lc', def.command], - ...(def.timeout !== undefined ? { timeoutMs: def.timeout * 1000 } : {}), + // 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 + // network-scale, not the shell-hook defaults. + const ZCODE_TIMEOUT_MS: Record = { + SessionStart: 180000, + Stop: 60000, + PostToolUse: 30000, + UserPromptSubmit: 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: ZCODE_TIMEOUT_MS[def.event] ?? 60000, + } + : { + 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: ZCODE_TIMEOUT_MS[def.event] ?? 60000, + }; 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. @@ -324,7 +345,8 @@ 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]; - if (hook?.command === 'bash' && hook.args?.[0] === '-lc') return hook.args[1] ?? ''; + // 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] ?? ''; return hook?.command ?? ''; } @@ -542,6 +564,15 @@ async function reconcileZcodeFormat( const cfg: ZcodeHooksJson = (await readJson(expanded)) ?? {}; if (!cfg.hooks) cfg.hooks = {}; let changed = false; + // ZCode validates the hooks block against a strict schema and REJECTS THE + // WHOLE BLOCK on any unrecognized key (observed: `config_file_invalid — + // hooks: Unrecognized key: "description"` → hookCount 0 → nothing fires). + // Heal the config by keeping only the keys the schema knows about. + const unknownHookKeys = Object.keys(cfg.hooks).filter((k) => k !== 'enabled' && k !== 'events'); + if (unknownHookKeys.length > 0) { + for (const k of unknownHookKeys) delete cfg.hooks[k]; + changed = true; + } // Config-file hooks are disabled by default in ZCode; entries we write would // never fire unless the runner is explicitly enabled. Persist the flip even // when the event arrays are already up to date — but only when installing.