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
286 changes: 157 additions & 129 deletions hooks/ponytail-activate.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,153 +20,181 @@ const {
isCodex,
isCopilot,
isCursor,
readMode,
setMode,
writeHookOutput,
} = require('./ponytail-runtime');

const claudeDir = getClaudeDir();
const settingsPath = path.join(claudeDir, 'settings.json');

const mode = getDefaultMode();

// "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') {
clearMode();
process.exit(0);
}
function activate(mode) {
// "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') {
clearMode();
process.exit(0);
}

// 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
// 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);
}
process.exit(0);
}
}

// 1. Write flag file
try {
setMode(mode);
} catch (e) {
// Silent fail -- flag is best-effort, don't block the hook
}
// 1. Write flag file
try {
setMode(mode);
} catch (e) {
// Silent fail -- flag is best-effort, don't block the hook
}

// 2. Emit the ponytail ruleset, filtered to the active intensity level.
let output = getPonytailInstructions(mode);

// 2b. Codebase map: what already exists, so "reuse first" costs no search. Fail open: a map
// that cannot be built must never block or slow the session start.
if (mode !== 'review' && process.env.PONYTAIL_MAP !== '0') try {
const map = execFileSync(process.execPath, [path.join(__dirname, 'ponytail-map.js')], {
encoding: 'utf8', timeout: 1000, maxBuffer: 64 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
}).trimEnd();
if (map) output += '\n\n' + map;
} catch (e) { /* no map */ }

// 2. Emit the ponytail ruleset, filtered to the active intensity level.
let output = getPonytailInstructions(mode);

// 2b. Codebase map: what already exists, so "reuse first" costs no search. Fail open: a map
// that cannot be built must never block or slow the session start.
if (mode !== 'review' && process.env.PONYTAIL_MAP !== '0') try {
const map = execFileSync(process.execPath, [path.join(__dirname, 'ponytail-map.js')], {
encoding: 'utf8', timeout: 1000, maxBuffer: 64 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
}).trimEnd();
if (map) output += '\n\n' + map;
} catch (e) { /* no map */ }

// 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 || '');
// 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 || '');
}
}
}

// 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 */ }
// 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.";
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
// Compaction re-runs SessionStart inside the same conversation, so it keeps the
// live level (or stays off after "stop ponytail") instead of resetting to the
// default. Claude Code and Codex pass the trigger as `source` on stdin; reading
// it there keeps the hook entry, and the trust Codex gave it, unchanged.
let input = '';
let done = false;

function finish() {
if (done) return;
done = true;
let source = '';
try {
// Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
source = JSON.parse(input.replace(/^\uFEFF/, '')).source;
} catch (e) {
// No payload, or an unparseable one: treat it as a fresh session
}
activate(source === 'compact' ? (readMode() || 'off') : getDefaultMode());
}

process.stdin.on('data', chunk => { input += chunk; });
// Never hang the session start (#443, #790): like the mode tracker, recover on a
// stdin error or after a short ref'd fallback, and start at the default level.
process.stdin.on('end', () => { finish(); process.exit(0); });
process.stdin.on('error', () => { finish(); process.exit(0); });
setTimeout(() => { finish(); process.exit(0); }, 1000);
39 changes: 23 additions & 16 deletions tests/hooks-windows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,23 +107,30 @@ test('every hook command points at a script that ships in hooks/', () => {
// Issue #443: on Windows the UserPromptSubmit hook runs inside a PowerShell
// `if {}` wrapper that can swallow the piped prompt JSON, so stdin 'end' never
// fires. The hook must never wait on stdin forever — that freezes the whole
// session. It has to self-exit even when stdin stays open and empty.
test('ponytail-mode-tracker self-exits when stdin never closes (no freeze)', async () => {
const hook = path.join(root, 'hooks', 'ponytail-mode-tracker.js');
// stdin is a pipe we never write to or end, reproducing the deadlock.
const child = spawn(process.execPath, [hook], { stdio: ['pipe', 'ignore', 'ignore'] });

const code = await new Promise((resolve, reject) => {
const guard = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('hook hung on open stdin — it would freeze the session'));
}, 3000);
child.on('exit', (c) => { clearTimeout(guard); resolve(c); });
child.on('error', reject);
// session. It has to self-exit even when stdin stays open and empty. The
// activate hook reads the SessionStart source from stdin too.
for (const script of ['ponytail-mode-tracker.js', 'ponytail-activate.js']) {
test(`${script} self-exits when stdin never closes (no freeze)`, async () => {
const hook = path.join(root, 'hooks', script);
// A throwaway home: the activate fallback writes the flag and the statusline copy.
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-stdin-'));
const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: path.join(home, '.claude'), PONYTAIL_MAP: '0' };
// stdin is a pipe we never write to or end, reproducing the deadlock.
const child = spawn(process.execPath, [hook], { env, stdio: ['pipe', 'ignore', 'ignore'] });

const code = await new Promise((resolve, reject) => {
const guard = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('hook hung on open stdin — it would freeze the session'));
}, 3000);
child.on('exit', (c) => { clearTimeout(guard); resolve(c); });
child.on('error', reject);
});
fs.rmSync(home, { recursive: true, force: true });

assert.equal(code, 0, 'hook must exit cleanly when stdin never closes');
});

assert.equal(code, 0, 'hook 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) {
Expand Down
24 changes: 24 additions & 0 deletions tests/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -935,4 +935,28 @@ assert.equal(
assert.equal(fs.readFileSync(bareFlag, 'utf8'), 'full', 'an off default still switches on at full');
}

// Compaction re-runs SessionStart inside the same conversation: it must keep the
// live level, and keep ponytail off after "stop ponytail", not reset to the default.
{
const compactHome = path.join(temp, 'compact-home');
const compactFlag = path.join(compactHome, '.claude', '.ponytail-active');
const compactEnv = { HOME: compactHome, USERPROFILE: compactHome };
const start = (source) => run('ponytail-activate.js', compactEnv, JSON.stringify({ source }));
const compact = () => start('compact');

start('startup');
run('ponytail-mode-tracker.js', compactEnv, JSON.stringify({ prompt: '/ponytail ultra' }));
assert.match(compact().stdout, /PONYTAIL MODE ACTIVE — level: ultra/);
assert.equal(fs.readFileSync(compactFlag, 'utf8'), 'ultra', 'compaction must keep the live level');

run('ponytail-mode-tracker.js', compactEnv, JSON.stringify({ prompt: 'stop ponytail' }));
assert.equal(compact().stdout, '', 'compaction must not switch a stopped session back on');
assert.equal(fs.existsSync(compactFlag), false);

// Every other source still starts at the default level.
for (const source of ['startup', 'resume', 'clear']) {
assert.match(start(source).stdout, /PONYTAIL MODE ACTIVE — level: full/, `${source} must start at the default level`);
}
}

console.log('hook compatibility checks passed');
Loading