Skip to content
Merged
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
44 changes: 36 additions & 8 deletions src/__tests__/zcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']));
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion src/builtin-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 43 additions & 12 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ interface ZcodeHookMatcher {
interface ZcodeHooksJson {
hooks?: {
enabled?: boolean;
description?: string;
events?: Record<string, ZcodeHookMatcher[]>;
[key: string]: unknown;
};
[key: string]: unknown;
}
Expand Down Expand Up @@ -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<string, number> = {
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.
Expand All @@ -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 ?? '';
}

Expand Down Expand Up @@ -542,6 +564,15 @@ async function reconcileZcodeFormat(
const cfg: ZcodeHooksJson = (await readJson<ZcodeHooksJson>(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.
Expand Down
Loading