diff --git a/hooks/claude-codex-hooks.json b/hooks/claude-codex-hooks.json index 804ce4a..441ae11 100644 --- a/hooks/claude-codex-hooks.json +++ b/hooks/claude-codex-hooks.json @@ -14,6 +14,19 @@ ] } ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-subagent.js\"; exit 0", + "commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-subagent.js\" }", + "timeout": 5, + "statusMessage": "Loading ponytail mode..." + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/hooks/ponytail-runtime.js b/hooks/ponytail-runtime.js index 5af5d4b..2aa61a3 100644 --- a/hooks/ponytail-runtime.js +++ b/hooks/ponytail-runtime.js @@ -21,6 +21,15 @@ function clearMode() { try { fs.unlinkSync(statePath); } catch (e) {} } +// Live mode written by activate/mode-tracker. Absent flag = ponytail off. +function readMode() { + try { + return fs.readFileSync(statePath, 'utf8').trim() || null; + } catch (e) { + return null; + } +} + function writeHookOutput(event, mode, context = '') { if (isCopilot) { // Copilot reads additionalContext on SessionStart; ignores output elsewhere. @@ -39,6 +48,13 @@ function writeHookOutput(event, mode, context = '') { process.stdout.write(JSON.stringify(output)); return; } + // Native Claude: SessionStart accepts raw stdout, but SubagentStart needs the + // hookSpecificOutput JSON form or the context is dropped. + if (event === 'SubagentStart') { + process.stdout.write(JSON.stringify( + { hookSpecificOutput: { hookEventName: event, additionalContext: context } })); + return; + } process.stdout.write(context); } @@ -46,6 +62,7 @@ module.exports = { clearMode, isCodex, isCopilot, + readMode, setMode, writeHookOutput, }; diff --git a/hooks/ponytail-subagent.js b/hooks/ponytail-subagent.js new file mode 100644 index 0000000..96cd10b --- /dev/null +++ b/hooks/ponytail-subagent.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// ponytail — Claude Code SubagentStart hook +// +// SessionStart context is parent-thread only and never reaches subagents, so +// without this every Task-spawned agent runs ponytail-unaware (issue #252). +// When ponytail mode is active, inject the same ruleset into each subagent. + +const { getPonytailInstructions } = require('./ponytail-instructions'); +const { readMode, writeHookOutput } = require('./ponytail-runtime'); + +const mode = readMode(); + +// Absent flag or off → ponytail isn't active; inject nothing. +if (!mode || mode === 'off') { + process.exit(0); +} + +try { + writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode)); +} catch (e) { + // Silent fail — a stdout error at hook exit must not surface as a hook failure. +} diff --git a/tests/hooks.test.js b/tests/hooks.test.js index d17e65f..53465dc 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -26,9 +26,14 @@ function run(script, env, input = '') { }); } -// Keep the base env clean so the default-dir checks are deterministic; the -// CLAUDE_CONFIG_DIR case sets it explicitly. +// Keep the base env clean so the default-dir / native-Claude checks are +// deterministic; the CLAUDE_CONFIG_DIR and codex/copilot cases set these +// explicitly where needed. run() spreads process.env, so a PLUGIN_DATA / +// COPILOT_PLUGIN_DATA leaked from the dev or CI shell would otherwise steer +// writeHookOutput into the wrong branch and mis-fire the native assertions. delete process.env.CLAUDE_CONFIG_DIR; +delete process.env.PLUGIN_DATA; +delete process.env.COPILOT_PLUGIN_DATA; const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-')); // Runs on normal exit and on assertion-throw exit; force makes it idempotent. @@ -168,4 +173,40 @@ assert.equal( output = JSON.parse(result.stdout); assert.deepEqual(output, {}); +// SubagentStart hook: when ponytail mode is active it injects the ruleset into +// each subagent (issue #252). Native Claude must get the hookSpecificOutput JSON +// form, not raw stdout, or the context is dropped. +const subHome = path.join(temp, 'sub-home'); +const subFlag = path.join(subHome, '.claude', '.ponytail-active'); +fs.mkdirSync(path.dirname(subFlag), { recursive: true }); +const subEnv = { HOME: subHome, USERPROFILE: subHome }; + +fs.writeFileSync(subFlag, 'full'); +result = run('ponytail-subagent.js', subEnv); +assert.equal(result.status, 0, result.stderr); +output = JSON.parse(result.stdout); +assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart'); +assert.match( + output.hookSpecificOutput.additionalContext, + /PONYTAIL MODE ACTIVE — level: full/, +); + +// No flag → ponytail off → inject nothing (empty stdout, no failure). +fs.unlinkSync(subFlag); +result = run('ponytail-subagent.js', subEnv); +assert.equal(result.status, 0, result.stderr); +assert.equal(result.stdout, '', 'SubagentStart must stay silent when ponytail is off'); + +// Codex shares claude-codex-hooks.json, so SubagentStart is reachable under Codex +// too — assert the codex branch emits the badge plus hookSpecificOutput. +const subCodex = path.join(temp, 'sub-codex'); +fs.mkdirSync(subCodex, { recursive: true }); +fs.writeFileSync(path.join(subCodex, '.ponytail-active'), 'full'); +result = run('ponytail-subagent.js', { HOME: subHome, USERPROFILE: subHome, PLUGIN_DATA: subCodex }); +assert.equal(result.status, 0, result.stderr); +output = JSON.parse(result.stdout); +assert.equal(output.systemMessage, 'PONYTAIL:FULL'); +assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart'); +assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/); + console.log('hook compatibility checks passed');