Skip to content

Commit 70d0065

Browse files
devohmycodeDietrich Gebert
andauthored
fix: keep the live ponytail level across context compaction (#1074)
SessionStart also fires on compact, and the activate hook always reset the flag to the default level. A session switched to /ponytail ultra fell back to full after compaction, and a session stopped with "stop ponytail" was switched back on. Codex fires the same compact source. The activate hook now reads the source from the JSON Claude Code and Codex pipe on stdin, and keeps the live level (or stays off) on compact. Startup, resume and clear still start at the default level. Like the mode tracker, it never waits on stdin for more than a second and falls back to the default level (#443). The hook entry itself is unchanged: Codex trusts a hook by a hash of its matcher and command, and skips a changed hook until the user approves it again, so a new or edited entry would have dropped the ruleset there. Co-authored-by: Dietrich Gebert <dietrichgebert@gmail.com>
1 parent fa36f34 commit 70d0065

3 files changed

Lines changed: 204 additions & 145 deletions

File tree

‎hooks/ponytail-activate.js‎

Lines changed: 157 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -20,153 +20,181 @@ const {
2020
isCodex,
2121
isCopilot,
2222
isCursor,
23+
readMode,
2324
setMode,
2425
writeHookOutput,
2526
} = require('./ponytail-runtime');
2627

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

30-
const mode = getDefaultMode();
31-
32-
// "off" mode — skip activation entirely, don't write flag or emit rules
33-
if (mode === 'off') {
34-
clearMode();
35-
process.exit(0);
36-
}
31+
function activate(mode) {
32+
// "off" mode — skip activation entirely, don't write flag or emit rules
33+
if (mode === 'off') {
34+
clearMode();
35+
process.exit(0);
36+
}
3737

38-
// Cursor with the always-on rule in the workspace: the rule already carries the
39-
// ruleset and would contradict any other level, so leave the flag alone and
40-
// hand the model a one-line notice instead of a second copy (#817).
41-
if (isCursor) {
42-
const rule = cursorRulePath();
43-
if (rule) {
44-
try {
45-
writeHookOutput('SessionStart', mode, cursorRuleNotice(rule));
46-
} catch (e) {
47-
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
38+
// Cursor with the always-on rule in the workspace: the rule already carries the
39+
// ruleset and would contradict any other level, so leave the flag alone and
40+
// hand the model a one-line notice instead of a second copy (#817).
41+
if (isCursor) {
42+
const rule = cursorRulePath();
43+
if (rule) {
44+
try {
45+
writeHookOutput('SessionStart', mode, cursorRuleNotice(rule));
46+
} catch (e) {
47+
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
48+
}
49+
process.exit(0);
4850
}
49-
process.exit(0);
5051
}
51-
}
5252

53-
// 1. Write flag file
54-
try {
55-
setMode(mode);
56-
} catch (e) {
57-
// Silent fail -- flag is best-effort, don't block the hook
58-
}
53+
// 1. Write flag file
54+
try {
55+
setMode(mode);
56+
} catch (e) {
57+
// Silent fail -- flag is best-effort, don't block the hook
58+
}
59+
60+
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
61+
let output = getPonytailInstructions(mode);
62+
63+
// 2b. Codebase map: what already exists, so "reuse first" costs no search. Fail open: a map
64+
// that cannot be built must never block or slow the session start.
65+
if (mode !== 'review' && process.env.PONYTAIL_MAP !== '0') try {
66+
const map = execFileSync(process.execPath, [path.join(__dirname, 'ponytail-map.js')], {
67+
encoding: 'utf8', timeout: 1000, maxBuffer: 64 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
68+
}).trimEnd();
69+
if (map) output += '\n\n' + map;
70+
} catch (e) { /* no map */ }
5971

60-
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
61-
let output = getPonytailInstructions(mode);
62-
63-
// 2b. Codebase map: what already exists, so "reuse first" costs no search. Fail open: a map
64-
// that cannot be built must never block or slow the session start.
65-
if (mode !== 'review' && process.env.PONYTAIL_MAP !== '0') try {
66-
const map = execFileSync(process.execPath, [path.join(__dirname, 'ponytail-map.js')], {
67-
encoding: 'utf8', timeout: 1000, maxBuffer: 64 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
68-
}).trimEnd();
69-
if (map) output += '\n\n' + map;
70-
} catch (e) { /* no map */ }
71-
72-
// 3. Detect missing statusline config — nudge Claude to help set it up
73-
if (!isCodex && !isCopilot && !isCursor && !isCodeBuddy) try {
74-
const isWindows = process.platform === 'win32';
75-
let statusCommand = null;
76-
if (fs.existsSync(settingsPath)) {
77-
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
78-
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
79-
const settings = JSON.parse(raw);
80-
if (settings.statusLine) {
81-
statusCommand = String(settings.statusLine.command || '');
72+
// 3. Detect missing statusline config — nudge Claude to help set it up
73+
if (!isCodex && !isCopilot && !isCursor && !isCodeBuddy) try {
74+
const isWindows = process.platform === 'win32';
75+
let statusCommand = null;
76+
if (fs.existsSync(settingsPath)) {
77+
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
78+
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
79+
const settings = JSON.parse(raw);
80+
if (settings.statusLine) {
81+
statusCommand = String(settings.statusLine.command || '');
82+
}
8283
}
83-
}
8484

85-
// A statusLine already set up before #1032 can still run a script from a
86-
// plugin version that has since been deleted. Only absolute paths are checked,
87-
// so ~ or $HOME forms are never mistaken for missing files.
88-
const ref = statusCommand &&
89-
statusCommand.match(/"([^"]*ponytail-statusline\.(?:sh|ps1))"|(\S*ponytail-statusline\.(?:sh|ps1))/);
90-
const refPath = ref ? (ref[1] || ref[2]) : null;
91-
// isShellSafe keeps quotes, newlines and shell metacharacters from
92-
// settings.json out of the model context.
93-
// On Windows only drive-letter or UNC paths count: a Git Bash path such as
94-
// /c/Users/... is absolute to Node but does not resolve, so it would be
95-
// flagged as broken while it works.
96-
const checkable = refPath && isShellSafe(refPath) && path.isAbsolute(refPath) &&
97-
(!isWindows || /^([A-Za-z]:[\\/]|\\\\)/.test(refPath));
98-
const stalePath = checkable && !fs.existsSync(refPath) ? refPath : null;
99-
100-
// The plugin root is a versioned cache dir (.../ponytail/4.12.0/) that Claude
101-
// Code deletes on update, so a statusLine pointing into it goes blank after the
102-
// next update (#1032). Point it at a copy in the config dir instead. A statusLine
103-
// that already runs ponytail keeps its script type, so its copy stays fresh.
104-
const usePs1 = refPath ? refPath.endsWith('.ps1') : isWindows;
105-
const scriptName = usePs1 ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh';
106-
const scriptPath = path.join(claudeDir, scriptName);
107-
108-
// Nudge at most once — the flag file marks that the user has already seen
109-
// (and implicitly declined) the statusline setup offer. Repeating it every
110-
// session start turns a helpful hint into a nag. A broken path is nudged once
111-
// per path: the flag records it.
112-
const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged');
113-
let nudged = null;
114-
try { nudged = fs.readFileSync(nudgeFlagPath, 'utf8'); } catch (e) { /* not nudged yet */ }
115-
const nudge = stalePath ? nudged !== stalePath : statusCommand === null && nudged === null;
116-
117-
// Refresh the copy every session so script fixes ship with plugin updates.
118-
// Copy to a new temp file, then rename: the rename replaces a symlink at
119-
// scriptPath instead of writing through it, and a concurrent session never
120-
// runs a half-written script.
121-
if (nudge || fs.existsSync(scriptPath)) {
122-
const tmpPath = scriptPath + '.' + process.pid + '.tmp';
123-
try {
124-
fs.copyFileSync(path.join(__dirname, scriptName), tmpPath, fs.constants.COPYFILE_EXCL);
125-
fs.chmodSync(tmpPath, 0o644);
126-
fs.renameSync(tmpPath, scriptPath);
127-
} finally {
128-
try { fs.unlinkSync(tmpPath); } catch (e) { /* renamed */ }
85+
// A statusLine already set up before #1032 can still run a script from a
86+
// plugin version that has since been deleted. Only absolute paths are checked,
87+
// so ~ or $HOME forms are never mistaken for missing files.
88+
const ref = statusCommand &&
89+
statusCommand.match(/"([^"]*ponytail-statusline\.(?:sh|ps1))"|(\S*ponytail-statusline\.(?:sh|ps1))/);
90+
const refPath = ref ? (ref[1] || ref[2]) : null;
91+
// isShellSafe keeps quotes, newlines and shell metacharacters from
92+
// settings.json out of the model context.
93+
// On Windows only drive-letter or UNC paths count: a Git Bash path such as
94+
// /c/Users/... is absolute to Node but does not resolve, so it would be
95+
// flagged as broken while it works.
96+
const checkable = refPath && isShellSafe(refPath) && path.isAbsolute(refPath) &&
97+
(!isWindows || /^([A-Za-z]:[\\/]|\\\\)/.test(refPath));
98+
const stalePath = checkable && !fs.existsSync(refPath) ? refPath : null;
99+
100+
// The plugin root is a versioned cache dir (.../ponytail/4.12.0/) that Claude
101+
// Code deletes on update, so a statusLine pointing into it goes blank after the
102+
// next update (#1032). Point it at a copy in the config dir instead. A statusLine
103+
// that already runs ponytail keeps its script type, so its copy stays fresh.
104+
const usePs1 = refPath ? refPath.endsWith('.ps1') : isWindows;
105+
const scriptName = usePs1 ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh';
106+
const scriptPath = path.join(claudeDir, scriptName);
107+
108+
// Nudge at most once — the flag file marks that the user has already seen
109+
// (and implicitly declined) the statusline setup offer. Repeating it every
110+
// session start turns a helpful hint into a nag. A broken path is nudged once
111+
// per path: the flag records it.
112+
const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged');
113+
let nudged = null;
114+
try { nudged = fs.readFileSync(nudgeFlagPath, 'utf8'); } catch (e) { /* not nudged yet */ }
115+
const nudge = stalePath ? nudged !== stalePath : statusCommand === null && nudged === null;
116+
117+
// Refresh the copy every session so script fixes ship with plugin updates.
118+
// Copy to a new temp file, then rename: the rename replaces a symlink at
119+
// scriptPath instead of writing through it, and a concurrent session never
120+
// runs a half-written script.
121+
if (nudge || fs.existsSync(scriptPath)) {
122+
const tmpPath = scriptPath + '.' + process.pid + '.tmp';
123+
try {
124+
fs.copyFileSync(path.join(__dirname, scriptName), tmpPath, fs.constants.COPYFILE_EXCL);
125+
fs.chmodSync(tmpPath, 0o644);
126+
fs.renameSync(tmpPath, scriptPath);
127+
} finally {
128+
try { fs.unlinkSync(tmpPath); } catch (e) { /* renamed */ }
129+
}
129130
}
130-
}
131131

132-
if (nudge) {
133-
try { fs.writeFileSync(nudgeFlagPath, stalePath || ''); } catch (e) { /* best-effort */ }
134-
if (stalePath) {
135-
output += "\n\n" +
136-
"STATUSLINE BROKEN: The statusLine in " + settingsPath + " runs " + stalePath +
137-
", which no longer exists (the ponytail plugin was updated and its old version removed), " +
138-
"so the ponytail badge is blank. Replace that path with " + scriptPath + ", which survives updates, " +
139-
"quoting it for your shell. Keep the rest of the command. " +
140-
"Proactively offer to fix this for the user on first interaction.";
141-
} else if (isShellSafe(scriptPath)) {
142-
const command = isWindows
143-
? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
144-
: `bash "${scriptPath}"`;
145-
const statusLineSnippet =
146-
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
147-
output += "\n\n" +
148-
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " +
149-
"(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " +
150-
"To enable, add this to " + settingsPath + ": " +
151-
statusLineSnippet + " " +
152-
"Proactively offer to set this up for the user on first interaction.";
153-
} else {
154-
// ponytail: config dir path has shell metacharacters; don't embed it in a
155-
// command snippet; have the agent wire it up by hand instead.
156-
output += "\n\n" +
157-
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " +
158-
"Its path contains characters unsafe to embed in a shell command, so configure it manually: " +
159-
"add a statusLine command of type \"command\" that runs " + scriptName +
160-
" from " + claudeDir + " to " + settingsPath + ", quoting/escaping the path for your shell. " +
161-
"Proactively offer to set this up for the user on first interaction.";
132+
if (nudge) {
133+
try { fs.writeFileSync(nudgeFlagPath, stalePath || ''); } catch (e) { /* best-effort */ }
134+
if (stalePath) {
135+
output += "\n\n" +
136+
"STATUSLINE BROKEN: The statusLine in " + settingsPath + " runs " + stalePath +
137+
", which no longer exists (the ponytail plugin was updated and its old version removed), " +
138+
"so the ponytail badge is blank. Replace that path with " + scriptPath + ", which survives updates, " +
139+
"quoting it for your shell. Keep the rest of the command. " +
140+
"Proactively offer to fix this for the user on first interaction.";
141+
} else if (isShellSafe(scriptPath)) {
142+
const command = isWindows
143+
? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
144+
: `bash "${scriptPath}"`;
145+
const statusLineSnippet =
146+
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
147+
output += "\n\n" +
148+
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " +
149+
"(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " +
150+
"To enable, add this to " + settingsPath + ": " +
151+
statusLineSnippet + " " +
152+
"Proactively offer to set this up for the user on first interaction.";
153+
} else {
154+
// ponytail: config dir path has shell metacharacters; don't embed it in a
155+
// command snippet; have the agent wire it up by hand instead.
156+
output += "\n\n" +
157+
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " +
158+
"Its path contains characters unsafe to embed in a shell command, so configure it manually: " +
159+
"add a statusLine command of type \"command\" that runs " + scriptName +
160+
" from " + claudeDir + " to " + settingsPath + ", quoting/escaping the path for your shell. " +
161+
"Proactively offer to set this up for the user on first interaction.";
162+
}
162163
}
164+
} catch (e) {
165+
// Silent fail — don't block session start over statusline detection
166+
}
167+
168+
try {
169+
writeHookOutput('SessionStart', mode, output);
170+
} catch (e) {
171+
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
163172
}
164-
} catch (e) {
165-
// Silent fail — don't block session start over statusline detection
166173
}
167174

168-
try {
169-
writeHookOutput('SessionStart', mode, output);
170-
} catch (e) {
171-
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
175+
// Compaction re-runs SessionStart inside the same conversation, so it keeps the
176+
// live level (or stays off after "stop ponytail") instead of resetting to the
177+
// default. Claude Code and Codex pass the trigger as `source` on stdin; reading
178+
// it there keeps the hook entry, and the trust Codex gave it, unchanged.
179+
let input = '';
180+
let done = false;
181+
182+
function finish() {
183+
if (done) return;
184+
done = true;
185+
let source = '';
186+
try {
187+
// Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
188+
source = JSON.parse(input.replace(/^\uFEFF/, '')).source;
189+
} catch (e) {
190+
// No payload, or an unparseable one: treat it as a fresh session
191+
}
192+
activate(source === 'compact' ? (readMode() || 'off') : getDefaultMode());
172193
}
194+
195+
process.stdin.on('data', chunk => { input += chunk; });
196+
// Never hang the session start (#443, #790): like the mode tracker, recover on a
197+
// stdin error or after a short ref'd fallback, and start at the default level.
198+
process.stdin.on('end', () => { finish(); process.exit(0); });
199+
process.stdin.on('error', () => { finish(); process.exit(0); });
200+
setTimeout(() => { finish(); process.exit(0); }, 1000);

‎tests/hooks-windows.test.js‎

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -107,23 +107,30 @@ test('every hook command points at a script that ships in hooks/', () => {
107107
// Issue #443: on Windows the UserPromptSubmit hook runs inside a PowerShell
108108
// `if {}` wrapper that can swallow the piped prompt JSON, so stdin 'end' never
109109
// fires. The hook must never wait on stdin forever — that freezes the whole
110-
// session. It has to self-exit even when stdin stays open and empty.
111-
test('ponytail-mode-tracker self-exits when stdin never closes (no freeze)', async () => {
112-
const hook = path.join(root, 'hooks', 'ponytail-mode-tracker.js');
113-
// stdin is a pipe we never write to or end, reproducing the deadlock.
114-
const child = spawn(process.execPath, [hook], { stdio: ['pipe', 'ignore', 'ignore'] });
115-
116-
const code = await new Promise((resolve, reject) => {
117-
const guard = setTimeout(() => {
118-
child.kill('SIGKILL');
119-
reject(new Error('hook hung on open stdin — it would freeze the session'));
120-
}, 3000);
121-
child.on('exit', (c) => { clearTimeout(guard); resolve(c); });
122-
child.on('error', reject);
110+
// session. It has to self-exit even when stdin stays open and empty. The
111+
// activate hook reads the SessionStart source from stdin too.
112+
for (const script of ['ponytail-mode-tracker.js', 'ponytail-activate.js']) {
113+
test(`${script} self-exits when stdin never closes (no freeze)`, async () => {
114+
const hook = path.join(root, 'hooks', script);
115+
// A throwaway home: the activate fallback writes the flag and the statusline copy.
116+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-stdin-'));
117+
const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: path.join(home, '.claude'), PONYTAIL_MAP: '0' };
118+
// stdin is a pipe we never write to or end, reproducing the deadlock.
119+
const child = spawn(process.execPath, [hook], { env, stdio: ['pipe', 'ignore', 'ignore'] });
120+
121+
const code = await new Promise((resolve, reject) => {
122+
const guard = setTimeout(() => {
123+
child.kill('SIGKILL');
124+
reject(new Error('hook hung on open stdin — it would freeze the session'));
125+
}, 3000);
126+
child.on('exit', (c) => { clearTimeout(guard); resolve(c); });
127+
child.on('error', reject);
128+
});
129+
fs.rmSync(home, { recursive: true, force: true });
130+
131+
assert.equal(code, 0, 'hook must exit cleanly when stdin never closes');
123132
});
124-
125-
assert.equal(code, 0, 'hook must exit cleanly when stdin never closes');
126-
});
133+
}
127134

128135
test('Claude and Codex manifests point at the shared host-specific hook config', () => {
129136
for (const rel of HOST_PLUGIN_MANIFESTS) {

‎tests/hooks.test.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,4 +935,28 @@ assert.equal(
935935
assert.equal(fs.readFileSync(bareFlag, 'utf8'), 'full', 'an off default still switches on at full');
936936
}
937937

938+
// Compaction re-runs SessionStart inside the same conversation: it must keep the
939+
// live level, and keep ponytail off after "stop ponytail", not reset to the default.
940+
{
941+
const compactHome = path.join(temp, 'compact-home');
942+
const compactFlag = path.join(compactHome, '.claude', '.ponytail-active');
943+
const compactEnv = { HOME: compactHome, USERPROFILE: compactHome };
944+
const start = (source) => run('ponytail-activate.js', compactEnv, JSON.stringify({ source }));
945+
const compact = () => start('compact');
946+
947+
start('startup');
948+
run('ponytail-mode-tracker.js', compactEnv, JSON.stringify({ prompt: '/ponytail ultra' }));
949+
assert.match(compact().stdout, /PONYTAIL MODE ACTIVE — level: ultra/);
950+
assert.equal(fs.readFileSync(compactFlag, 'utf8'), 'ultra', 'compaction must keep the live level');
951+
952+
run('ponytail-mode-tracker.js', compactEnv, JSON.stringify({ prompt: 'stop ponytail' }));
953+
assert.equal(compact().stdout, '', 'compaction must not switch a stopped session back on');
954+
assert.equal(fs.existsSync(compactFlag), false);
955+
956+
// Every other source still starts at the default level.
957+
for (const source of ['startup', 'resume', 'clear']) {
958+
assert.match(start(source).stdout, /PONYTAIL MODE ACTIVE — level: full/, `${source} must start at the default level`);
959+
}
960+
}
961+
938962
console.log('hook compatibility checks passed');

0 commit comments

Comments
 (0)