@@ -20,153 +20,181 @@ const {
2020 isCodex,
2121 isCopilot,
2222 isCursor,
23+ readMode,
2324 setMode,
2425 writeHookOutput,
2526} = require ( './ponytail-runtime' ) ;
2627
2728const claudeDir = getClaudeDir ( ) ;
2829const 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 ( / " ( [ ^ " ] * p o n y t a i l - s t a t u s l i n e \. (?: s h | p s 1 ) ) " | ( \S * p o n y t a i l - s t a t u s l i n e \. (?: s h | p s 1 ) ) / ) ;
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 - Z a - 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 ( / " ( [ ^ " ] * p o n y t a i l - s t a t u s l i n e \. (?: s h | p s 1 ) ) " | ( \S * p o n y t a i l - s t a t u s l i n e \. (?: s h | p s 1 ) ) / ) ;
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 - Z a - 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 ) ;
0 commit comments