From 40e604a21cfef2bcc7263c7aad616f7eddde1bd1 Mon Sep 17 00:00:00 2001 From: 0027007744 Date: Tue, 6 Oct 2026 17:11:59 +0800 Subject: [PATCH] fix(zcode): re-add ZCode support with prompt-unwrap and resume-keep fixes #1040 dropped ZCode because it could not be tested. Field debugging on ZCode 3.x (Windows) found the two behaviors that made it look broken, both now fixed and live-verified: - ZCode renders a slash command as a markdown link, so UserPromptSubmit prompts arrive as [$ponytail](path) args; the mode tracker now unwraps that form. Bare /ponytail, @ponytail and $ponytail pass through unchanged. - ZCode resumes its runtime right after a slash-command prompt, firing SessionStart(resume) that reset a just-switched level to the default within seconds; activate now reads the hook payload's source and, on ZCode only, keeps the live level across resume/compact. Other hosts keep their long-standing reset behavior. Restores the isZcode detection, the strict-JSON output branch (#798), the hook test and the INSTALL/portability rows removed by #1040. --- INSTALL.md | 4 + docs/agent-portability.md | 1 + hooks/ponytail-activate.js | 285 +++++++++++++++++++-------------- hooks/ponytail-mode-tracker.js | 11 +- hooks/ponytail-runtime.js | 10 +- tests/hooks-windows.test.js | 31 ++++ tests/hooks.test.js | 105 ++++++++++++ 7 files changed, 324 insertions(+), 123 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 2387889fb..1239522ef 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -167,6 +167,10 @@ Start a new session (or reload plugins). Skills show as `/ponytail`, `/ponytail- `AGENTS.md` still works instruction-only from a checkout without the plugin. +## ZCode + +ZCode loads Claude Code plugins. Open **Settings → Plugins**, choose **Create → Add marketplace**, enter `DietrichGebert/ponytail`, then install ponytail from the Personal tab. The ruleset arrives at session start and `/ponytail` level switches work, like in Claude Code (needs ponytail 4.10.3 or later and `node` on your PATH). ZCode has no `SubagentStart` event, so subagents run without the ruleset. + ## Goose ```bash diff --git a/docs/agent-portability.md b/docs/agent-portability.md index 5488b50c6..f8b362635 100644 --- a/docs/agent-portability.md +++ b/docs/agent-portability.md @@ -12,6 +12,7 @@ to load in a given agent. | Codex | `.codex-plugin/plugin.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. | | CodeBuddy | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | CodeBuddy Code reads the Claude-format manifests when no `.codebuddy-plugin/` exists and expands `${CLAUDE_PLUGIN_ROOT}` in hook commands, so `/plugin marketplace add DietrichGebert/ponytail` + `/plugin install ponytail@ponytail` installs the Claude plugin unchanged. The hooks detect it by `CODEBUDDY_PLUGIN_ROOT`, keep the mode flag in `$CODEBUDDY_CONFIG_DIR` or `~/.codebuddy`, and answer in `hookSpecificOutput` JSON. No subagent injection: CodeBuddy discards `SubagentStart` hook output. Verified with CodeBuddy CLI 2.161.2. | | Grok Build | root `plugin.json`, `.grok-plugin/marketplace.json`, `skills/`, `commands/` | `grok plugin install DietrichGebert/ponytail --trust`, then enable. Grok can auto-invoke ponytail from its coding-task skill description; `/ponytail` makes activation explicit. Grok lifecycle hooks are not used because passive hook output cannot inject instructions. | +| ZCode | `.claude-plugin/plugin.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | ZCode loads Claude Code plugins: **Settings → Plugins → Create → Add marketplace** → `DietrichGebert/ponytail`, then install ponytail. The hooks answer in ZCode's strict JSON shape (detected via `ZCODE_APP_VERSION`, #799), so the ruleset arrives at session start and `/ponytail` switches land. ZCode renders `/ponytail` prompts as markdown links and resumes its runtime right after a slash-command prompt; the hooks unwrap the link and keep the switched level across that resume. ZCode has no `SubagentStart` event, so subagents run without the ruleset; the statusline nudge is skipped. Confirmed by users on ZCode 3.14.4 with ponytail 4.10.3 (#798). | | Goose | `plugin.json`, `skills/`, `AGENTS.md` | `goose plugin install https://github.com/DietrichGebert/ponytail.git` clones the repo into `~/.agents/plugins/ponytail/` and imports the six skills as `ponytail:`. Always-on rules come from `AGENTS.md`: goose reads it from the project (git root down to the working directory) and from `~/.agents/AGENTS.md`, plus `.goosehints` from `~/.config/goose/`. No lifecycle hooks, so no persistent `/ponytail` levels. Verified with Goose 1.53.0. | | Muse Code (Meta) | `AGENTS.md`, `skills/` | Muse reads `AGENTS.md` (or `CLAUDE.md`) in the project once the workspace is trusted, and `~/.config/muse/AGENTS.md` for every project. Skills: copy the folders from `skills/` to `~/.agents/skills/`, or run `muse skills install ./skills/ --scope user` per skill. Instruction-tier, no `/ponytail` levels or hooks. Verified with Muse Code 1.4.2. | | OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. Installs globally with `opencode plugin add @dietrichgebert/ponytail` (OpenCode writes the `plugins` entry itself), or per project through the `plugins` array in `opencode.json`. | diff --git a/hooks/ponytail-activate.js b/hooks/ponytail-activate.js index 68894bd69..95596a153 100644 --- a/hooks/ponytail-activate.js +++ b/hooks/ponytail-activate.js @@ -19,144 +19,187 @@ const { isCodex, isCopilot, isCursor, + isZcode, + readMode, setMode, writeHookOutput, } = require('./ponytail-runtime'); -const claudeDir = getClaudeDir(); -const settingsPath = path.join(claudeDir, 'settings.json'); +let input = ''; +let done = false; -const mode = getDefaultMode(); +function finish() { + if (done) return; + done = true; -// "off" mode — skip activation entirely, don't write flag or emit rules -if (mode === 'off') { - clearMode(); - process.exit(0); -} + // A resumed/compacted session keeps the level the user switched to; a fresh + // start (or a missing flag) initializes from the default. ZCode resumes the + // runtime right after a slash-command prompt, so an unconditional reset here + // erased a just-switched level within seconds. Other hosts only resume + // between sessions, where resetting to the default is long-standing + // behavior — keep this fix scoped to ZCode (#1040 follow-up). + let source = 'startup'; + try { + const data = JSON.parse(input.replace(/^\uFEFF/, '')); + if (data && typeof data.source === 'string') source = data.source; + } catch (e) { /* no/invalid stdin — treat as a fresh start */ } -// Cursor with the always-on rule in the workspace: the rule already carries the -// ruleset and would contradict any other level, so leave the flag alone and -// hand the model a one-line notice instead of a second copy (#817). -if (isCursor) { - const rule = cursorRulePath(); - if (rule) { - try { - writeHookOutput('SessionStart', mode, cursorRuleNotice(rule)); - } catch (e) { - // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure - } - process.exit(0); + const claudeDir = getClaudeDir(); + const settingsPath = path.join(claudeDir, 'settings.json'); + + let mode = getDefaultMode(); + if ((source === 'resume' || source === 'compact') && isZcode) { + const live = readMode(); + if (live && live !== 'off') mode = live; } -} -// 1. Write flag file -try { - setMode(mode); -} catch (e) { - // Silent fail -- flag is best-effort, don't block the hook -} + // "off" mode — skip activation entirely, don't write flag or emit rules + if (mode === 'off') { + clearMode(); + process.exit(0); + } -// 2. Emit the ponytail ruleset, filtered to the active intensity level. -let output = getPonytailInstructions(mode); - -// 3. Detect missing statusline config — nudge Claude to help set it up -if (!isCodex && !isCopilot && !isCursor && !isCodeBuddy) try { - const isWindows = process.platform === 'win32'; - let statusCommand = null; - if (fs.existsSync(settingsPath)) { - // Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse) - const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); - const settings = JSON.parse(raw); - if (settings.statusLine) { - statusCommand = String(settings.statusLine.command || ''); + // Cursor with the always-on rule in the workspace: the rule already carries the + // ruleset and would contradict any other level, so leave the flag alone and + // hand the model a one-line notice instead of a second copy (#817). + if (isCursor) { + const rule = cursorRulePath(); + if (rule) { + try { + writeHookOutput('SessionStart', mode, cursorRuleNotice(rule)); + } catch (e) { + // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure + } + process.exit(0); } } - // A statusLine already set up before #1032 can still run a script from a - // plugin version that has since been deleted. Only absolute paths are checked, - // so ~ or $HOME forms are never mistaken for missing files. - const ref = statusCommand && - statusCommand.match(/"([^"]*ponytail-statusline\.(?:sh|ps1))"|(\S*ponytail-statusline\.(?:sh|ps1))/); - const refPath = ref ? (ref[1] || ref[2]) : null; - // isShellSafe keeps quotes, newlines and shell metacharacters from - // settings.json out of the model context. - // On Windows only drive-letter or UNC paths count: a Git Bash path such as - // /c/Users/... is absolute to Node but does not resolve, so it would be - // flagged as broken while it works. - const checkable = refPath && isShellSafe(refPath) && path.isAbsolute(refPath) && - (!isWindows || /^([A-Za-z]:[\\/]|\\\\)/.test(refPath)); - const stalePath = checkable && !fs.existsSync(refPath) ? refPath : null; - - // The plugin root is a versioned cache dir (.../ponytail/4.12.0/) that Claude - // Code deletes on update, so a statusLine pointing into it goes blank after the - // next update (#1032). Point it at a copy in the config dir instead. A statusLine - // that already runs ponytail keeps its script type, so its copy stays fresh. - const usePs1 = refPath ? refPath.endsWith('.ps1') : isWindows; - const scriptName = usePs1 ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh'; - const scriptPath = path.join(claudeDir, scriptName); - - // Nudge at most once — the flag file marks that the user has already seen - // (and implicitly declined) the statusline setup offer. Repeating it every - // session start turns a helpful hint into a nag. A broken path is nudged once - // per path: the flag records it. - const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged'); - let nudged = null; - try { nudged = fs.readFileSync(nudgeFlagPath, 'utf8'); } catch (e) { /* not nudged yet */ } - const nudge = stalePath ? nudged !== stalePath : statusCommand === null && nudged === null; - - // Refresh the copy every session so script fixes ship with plugin updates. - // Copy to a new temp file, then rename: the rename replaces a symlink at - // scriptPath instead of writing through it, and a concurrent session never - // runs a half-written script. - if (nudge || fs.existsSync(scriptPath)) { - const tmpPath = scriptPath + '.' + process.pid + '.tmp'; - try { - fs.copyFileSync(path.join(__dirname, scriptName), tmpPath, fs.constants.COPYFILE_EXCL); - fs.chmodSync(tmpPath, 0o644); - fs.renameSync(tmpPath, scriptPath); - } finally { - try { fs.unlinkSync(tmpPath); } catch (e) { /* renamed */ } - } + // 1. Write flag file + try { + setMode(mode); + } catch (e) { + // Silent fail -- flag is best-effort, don't block the hook } - if (nudge) { - try { fs.writeFileSync(nudgeFlagPath, stalePath || ''); } catch (e) { /* best-effort */ } - if (stalePath) { - output += "\n\n" + - "STATUSLINE BROKEN: The statusLine in " + settingsPath + " runs " + stalePath + - ", which no longer exists (the ponytail plugin was updated and its old version removed), " + - "so the ponytail badge is blank. Replace that path with " + scriptPath + ", which survives updates, " + - "quoting it for your shell. Keep the rest of the command. " + - "Proactively offer to fix this for the user on first interaction."; - } else if (isShellSafe(scriptPath)) { - const command = isWindows - ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` - : `bash "${scriptPath}"`; - const statusLineSnippet = - '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; - output += "\n\n" + - "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " + - "(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " + - "To enable, add this to " + settingsPath + ": " + - statusLineSnippet + " " + - "Proactively offer to set this up for the user on first interaction."; - } else { - // ponytail: config dir path has shell metacharacters; don't embed it in a - // command snippet; have the agent wire it up by hand instead. - output += "\n\n" + - "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " + - "Its path contains characters unsafe to embed in a shell command, so configure it manually: " + - "add a statusLine command of type \"command\" that runs " + scriptName + - " from " + claudeDir + " to " + settingsPath + ", quoting/escaping the path for your shell. " + - "Proactively offer to set this up for the user on first interaction."; + // 2. Emit the ponytail ruleset, filtered to the active intensity level. + let output = getPonytailInstructions(mode); + + // 3. Detect missing statusline config — nudge Claude to help set it up + // Skipped on ZCode: its statusline configuration story is unverified, and a + // wrong pointer at Claude's settings.json would just mislead the agent. + if (!isCodex && !isCopilot && !isCursor && !isZcode && !isCodeBuddy) try { + const isWindows = process.platform === 'win32'; + let statusCommand = null; + if (fs.existsSync(settingsPath)) { + // Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse) + const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); + const settings = JSON.parse(raw); + if (settings.statusLine) { + statusCommand = String(settings.statusLine.command || ''); + } } + + // A statusLine already set up before #1032 can still run a script from a + // plugin version that has since been deleted. Only absolute paths are checked, + // so ~ or $HOME forms are never mistaken for missing files. + const ref = statusCommand && + statusCommand.match(/"([^"]*ponytail-statusline\.(?:sh|ps1))"|(\S*ponytail-statusline\.(?:sh|ps1))/); + const refPath = ref ? (ref[1] || ref[2]) : null; + // isShellSafe keeps quotes, newlines and shell metacharacters from + // settings.json out of the model context. + // On Windows only drive-letter or UNC paths count: a Git Bash path such as + // /c/Users/... is absolute to Node but does not resolve, so it would be + // flagged as broken while it works. + const checkable = refPath && isShellSafe(refPath) && path.isAbsolute(refPath) && + (!isWindows || /^([A-Za-z]:[\\/]|\\\\)/.test(refPath)); + const stalePath = checkable && !fs.existsSync(refPath) ? refPath : null; + + // The plugin root is a versioned cache dir (.../ponytail/4.12.0/) that Claude + // Code deletes on update, so a statusLine pointing into it goes blank after the + // next update (#1032). Point it at a copy in the config dir instead. A statusLine + // that already runs ponytail keeps its script type, so its copy stays fresh. + const usePs1 = refPath ? refPath.endsWith('.ps1') : isWindows; + const scriptName = usePs1 ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh'; + const scriptPath = path.join(claudeDir, scriptName); + + // Nudge at most once — the flag file marks that the user has already seen + // (and implicitly declined) the statusline setup offer. Repeating it every + // session start turns a helpful hint into a nag. A broken path is nudged once + // per path: the flag records it. + const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged'); + let nudged = null; + try { nudged = fs.readFileSync(nudgeFlagPath, 'utf8'); } catch (e) { /* not nudged yet */ } + const nudge = stalePath ? nudged !== stalePath : statusCommand === null && nudged === null; + + // Refresh the copy every session so script fixes ship with plugin updates. + // Copy to a new temp file, then rename: the rename replaces a symlink at + // scriptPath instead of writing through it, and a concurrent session never + // runs a half-written script. + if (nudge || fs.existsSync(scriptPath)) { + const tmpPath = scriptPath + '.' + process.pid + '.tmp'; + try { + fs.copyFileSync(path.join(__dirname, scriptName), tmpPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(tmpPath, 0o644); + fs.renameSync(tmpPath, scriptPath); + } finally { + try { fs.unlinkSync(tmpPath); } catch (e) { /* renamed */ } + } + } + + if (nudge) { + try { fs.writeFileSync(nudgeFlagPath, stalePath || ''); } catch (e) { /* best-effort */ } + if (stalePath) { + output += "\n\n" + + "STATUSLINE BROKEN: The statusLine in " + settingsPath + " runs " + stalePath + + ", which no longer exists (the ponytail plugin was updated and its old version removed), " + + "so the ponytail badge is blank. Replace that path with " + scriptPath + ", which survives updates, " + + "quoting it for your shell. Keep the rest of the command. " + + "Proactively offer to fix this for the user on first interaction."; + } else if (isShellSafe(scriptPath)) { + const command = isWindows + ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` + : `bash "${scriptPath}"`; + const statusLineSnippet = + '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " + + "(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " + + "To enable, add this to " + settingsPath + ": " + + statusLineSnippet + " " + + "Proactively offer to set this up for the user on first interaction."; + } else { + // ponytail: config dir path has shell metacharacters; don't embed it in a + // command snippet; have the agent wire it up by hand instead. + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " + + "Its path contains characters unsafe to embed in a shell command, so configure it manually: " + + "add a statusLine command of type \"command\" that runs " + scriptName + + " from " + claudeDir + " to " + settingsPath + ", quoting/escaping the path for your shell. " + + "Proactively offer to set this up for the user on first interaction."; + } + } + } catch (e) { + // Silent fail — don't block session start over statusline detection + } + + try { + writeHookOutput('SessionStart', mode, output); + } catch (e) { + // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure } -} catch (e) { - // Silent fail — don't block session start over statusline detection } -try { - writeHookOutput('SessionStart', mode, output); -} catch (e) { - // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure +process.stdin.on('data', chunk => { input += chunk; }); +// Same never-hang contract as the mode tracker: exit on 'end', and after a +// short ref'd fallback for the Windows case where piped stdin never reaches +// EOF (#443, #790). One addition: exit only after stdout has flushed — +// process.exit can truncate the large raw-text ruleset write on Windows +// pipes mid-output, cutting off the statusline nudge at the tail. +function finishAndExit() { + finish(); + process.stdout.write('', () => process.exit(0)); + setTimeout(() => process.exit(0), 250).unref(); } +process.stdin.on('end', finishAndExit); +process.stdin.on('error', finishAndExit); +setTimeout(finishAndExit, 1000); diff --git a/hooks/ponytail-mode-tracker.js b/hooks/ponytail-mode-tracker.js index 161a726d3..95e413b84 100644 --- a/hooks/ponytail-mode-tracker.js +++ b/hooks/ponytail-mode-tracker.js @@ -10,6 +10,7 @@ const { isCodex, isCursor, isQoder, + isZcode, readMode, setMode, writeHookOutput, @@ -25,7 +26,15 @@ function finish() { try { // Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse) const data = JSON.parse(input.replace(/^\uFEFF/, '')); - const prompt = (data.prompt || '').trim().toLowerCase(); + // ZCode renders a slash command as a markdown link ([$ponytail](path) + // args); unwrap it so the /^[/@$]ponytail/ match below sees the bare + // command. Gated on ZCode: a [ponytail](url) link pasted at the start of + // an ordinary prompt must not switch the level on other hosts. Bare + // /ponytail, @ponytail and $ponytail forms pass through everywhere. + let prompt = (data.prompt || '').trim().toLowerCase(); + if (isZcode) { + prompt = prompt.replace(/^\[([/@$]?)(ponytail[^\]]*)\]\([^)]*\)/, '/$2'); + } // Cursor with the always-on rule in the workspace: no hook can change or // switch off a rule, so answer the command with the notice instead of diff --git a/hooks/ponytail-runtime.js b/hooks/ponytail-runtime.js index 19b332af4..92a61f0c6 100644 --- a/hooks/ponytail-runtime.js +++ b/hooks/ponytail-runtime.js @@ -31,6 +31,9 @@ const isCodeBuddy = !isCopilot && !isCodex && !isQoder && Boolean(process.env.CO // hooks next to CLAUDE_PLUGIN_ROOT, and it needs Cursor-shaped JSON either // way, so this check comes after the hosts with their own data dirs. const isCursor = !isCopilot && !isCodex && !isQoder && !isCodeBuddy && Boolean(process.env.CURSOR_VERSION); +// ZCode injects ZCODE_APP_VERSION into every child process, hooks included. +const isZcode = !isCopilot && !isCodex && !isQoder && !isCursor && + Boolean(process.env.ZCODE_APP_VERSION); let stateDir = getClaudeDir(); if (isCodex) stateDir = process.env.PLUGIN_DATA; @@ -125,9 +128,13 @@ function writeHookOutput(event, mode, context = '') { process.stdout.write(JSON.stringify(output)); return; } - if (isQoder || isCodeBuddy) { + if (isQoder || isZcode || isCodeBuddy) { // Qoder: hookSpecificOutput JSON, same shape as Codex minus systemMessage. // UserPromptSubmit additionalContext is injected into the Agent's conversation. + // ZCode parses hook stdout as strict JSON too — raw text fails validation + // and is silently discarded (#798). Unlike Qoder it has SessionStart, so + // activate.js handles startup injection and only the output shape differs + // from Claude Code. // CodeBuddy would take raw stdout too, but also echoes it into the chat. const output = {}; if (context) { @@ -170,6 +177,7 @@ module.exports = { isCopilot, isCursor, isQoder, + isZcode, readMode, setMode, writeHookOutput, diff --git a/tests/hooks-windows.test.js b/tests/hooks-windows.test.js index 1fca7dee6..a1c1f8a50 100644 --- a/tests/hooks-windows.test.js +++ b/tests/hooks-windows.test.js @@ -125,6 +125,37 @@ test('ponytail-mode-tracker self-exits when stdin never closes (no freeze)', asy assert.equal(code, 0, 'hook must exit cleanly when stdin never closes'); }); +// Same #443 contract for the SessionStart hook: activate reads its payload's +// source field since the ZCode resume fix, so it must also self-exit when the +// piped stdin never reaches EOF instead of freezing session start. +test('ponytail-activate self-exits when stdin never closes (no freeze)', async () => { + const hook = path.join(root, 'hooks', 'ponytail-activate.js'); + // activate always writes the mode flag, so point its config dir at a + // throwaway home instead of the developer's real ~/.claude. + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-activate-neof-')); + const child = spawn(process.execPath, [hook], { + stdio: ['pipe', 'ignore', 'ignore'], + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + PONYTAIL_DEFAULT_MODE: 'lite', + }, + }); + + const code = await new Promise((resolve, reject) => { + const guard = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('activate hung on open stdin — it would freeze session start')); + }, 3000); + child.on('exit', (c) => { clearTimeout(guard); resolve(c); }); + child.on('error', reject); + }); + + assert.equal(code, 0, 'activate must exit cleanly when stdin never closes'); +}); + test('Claude and Codex manifests point at the shared host-specific hook config', () => { for (const rel of HOST_PLUGIN_MANIFESTS) { const manifest = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8')); diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 974ea3092..c55f902ed 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -38,6 +38,8 @@ delete process.env.COPILOT_PLUGIN_DATA; // A leaked subagent matcher would scope the inject-into-every-subagent assertions. delete process.env.PONYTAIL_SUBAGENT_MATCHER; delete process.env.QODER_SESSION_ID; +// Same for ZCode (#798), which sets it in every child process it spawns. +delete process.env.ZCODE_APP_VERSION; // A leaked project dir would move the flag into ponytail-modes/ (#662). delete process.env.CLAUDE_PROJECT_DIR; // Cursor sets these only for hook processes, but a suite launched from a Cursor @@ -699,6 +701,109 @@ result = run('ponytail-activate.js', { ...codebuddyEnv, CODEBUDDY_CONFIG_DIR: co assert.equal(result.status, 0, result.stderr); assert.equal(fs.readFileSync(path.join(codebuddyConfigDir, '.ponytail-active'), 'utf8'), 'lite'); +// Zcode: parses hook stdout as strict JSON, so the native-Claude raw-text +// SessionStart output is silently discarded (#798). Same hookSpecificOutput +// shape as Qoder, but Zcode does have SessionStart — activate.js injects the +// ruleset at startup and the mode-tracker only speaks up on mode switches. +const zcodeHome = path.join(temp, 'zcode-home'); +const zcodeState = path.join(zcodeHome, '.claude', '.ponytail-active'); +fs.mkdirSync(zcodeHome, { recursive: true }); + +const zcodeEnv = { + HOME: zcodeHome, + USERPROFILE: zcodeHome, + ZCODE_APP_VERSION: '3.10.2', + PONYTAIL_DEFAULT_MODE: 'full', +}; + +// SessionStart: flag written, ruleset emitted as hookSpecificOutput JSON. +result = run('ponytail-activate.js', zcodeEnv); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(zcodeState, 'utf8'), 'full'); +output = JSON.parse(result.stdout); +assert.equal(output.systemMessage, undefined, 'Zcode must not emit systemMessage'); +assert.equal(output.hookSpecificOutput.hookEventName, 'SessionStart'); +assert.match( + output.hookSpecificOutput.additionalContext, + /PONYTAIL MODE ACTIVE — level: full/, +); + +// '@ponytail lite': mode tracker updates the flag and confirms via JSON. +result = run( + 'ponytail-mode-tracker.js', + zcodeEnv, + JSON.stringify({ prompt: '@ponytail lite' }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(zcodeState, 'utf8'), 'lite'); +output = JSON.parse(result.stdout); +assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit'); +assert.match( + output.hookSpecificOutput.additionalContext, + /PONYTAIL MODE CHANGED — level: lite/, +); + +// ZCode renders a slash command as a markdown link, so the prompt arrives as +// [$ponytail](path) args — the raw ^[/@$]ponytail match never saw that form. +result = run( + 'ponytail-mode-tracker.js', + zcodeEnv, + JSON.stringify({ prompt: '[$ponytail](C:\\\\Users\\\\me\\\\SKILL.md) ultra' }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(zcodeState, 'utf8'), 'ultra'); +output = JSON.parse(result.stdout); +assert.match( + output.hookSpecificOutput.additionalContext, + /PONYTAIL MODE CHANGED — level: ultra/, +); + +// A wrapped non-ponytail label is not a ponytail command and stays silent. +result = run( + 'ponytail-mode-tracker.js', + zcodeEnv, + JSON.stringify({ prompt: '[$grill-me](C:\\\\Users\\\\me\\\\SKILL.md) grill me' }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(result.stdout, '', 'a wrapped non-ponytail label must stay silent'); +assert.equal(fs.readFileSync(zcodeState, 'utf8'), 'ultra'); + +// ZCode resumes its runtime right after a slash-command prompt, firing +// SessionStart(resume) seconds after the tracker set the level. On ZCode the +// switched level must survive that resume instead of resetting to the default. +result = run('ponytail-activate.js', zcodeEnv, JSON.stringify({ source: 'resume' })); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(zcodeState, 'utf8'), 'ultra', 'resume must keep the switched level on ZCode'); + +// The keep is gated on ZCode: without ZCODE_APP_VERSION a resume still resets +// to the default, so Claude Code / Codex behavior is unchanged (#1040 follow-up). +const { ZCODE_APP_VERSION: _, ...claudeShapedEnv } = zcodeEnv; +result = run('ponytail-activate.js', claudeShapedEnv, JSON.stringify({ source: 'resume' })); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(zcodeState, 'utf8'), 'full', 'resume must still reset to the default off ZCode'); + +// The unwrap is gated on ZCode: off ZCode a [ponytail](url) link at the +// start of an ordinary prompt must stay silent, not switch the level. +result = run( + 'ponytail-mode-tracker.js', + claudeShapedEnv, + JSON.stringify({ prompt: '[ponytail](https://github.com/DietrichGebert/ponytail) full of ideas' }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(result.stdout, '', 'a pasted ponytail link must stay silent off ZCode'); +assert.equal(fs.readFileSync(zcodeState, 'utf8'), 'full'); + +// "stop ponytail": deactivates, clears flag, short confirmation as JSON. +result = run( + 'ponytail-mode-tracker.js', + zcodeEnv, + JSON.stringify({ prompt: 'stop ponytail' }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.existsSync(zcodeState), false, 'flag must be cleared after stop ponytail'); +output = JSON.parse(result.stdout); +assert.equal(output.hookSpecificOutput.additionalContext, 'PONYTAIL MODE OFF'); + // writeDefaultMode must merge into existing config, not overwrite it (#490). const mergeHome = path.join(temp, 'merge-home'); const mergeConfigDir = path.join(mergeHome, '.config', 'ponytail');