Skip to content
Closed
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
4 changes: 4 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/agent-portability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<skill>`. 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/<name> --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`. |
Expand Down
285 changes: 164 additions & 121 deletions hooks/ponytail-activate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
11 changes: 10 additions & 1 deletion hooks/ponytail-mode-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
isCodex,
isCursor,
isQoder,
isZcode,
readMode,
setMode,
writeHookOutput,
Expand All @@ -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
Expand Down
Loading