Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/

Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`.

Claude Code / Codex subagent scoping (opt-in): `PONYTAIL_SUBAGENT_MATCHER` limits the ponytail ruleset to specific subagent types. Set it to an unanchored, case-insensitive regex tested against the subagent's `agent_type` (e.g. `explore|general`). Use `^…$` for an exact match; plugin agent types look like `plugin:name`. Unset = inject into all subagents (default).

Cursor, Windsurf, Cline, GitHub Copilot (editor), Aider, Kiro, Zed, CodeWhale, Swival: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/)).

Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
Expand Down
56 changes: 53 additions & 3 deletions hooks/ponytail-subagent.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
// 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.
//
// Scoping: by default injects into all subagents. To limit to specific agent
// types, set PONYTAIL_SUBAGENT_MATCHER to a regex (e.g. "explore|general").
// The hook reads agent_type from stdin and skips injection when it doesn't
// match. This is opt-in to preserve backward compatibility (#506).

const { getPonytailInstructions } = require('./ponytail-instructions');
const { readMode, writeHookOutput } = require('./ponytail-runtime');
Expand All @@ -15,8 +20,53 @@ if (!mode || mode === 'off') {
process.exit(0);
}

// ponytail: opt-in scoping via env var. Off by default = all subagents.
// Set PONYTAIL_SUBAGENT_MATCHER to a regex matching the agent types to inject.
// Upgrade path: if many users set this, promote to config.json.
const matcher = process.env.PONYTAIL_SUBAGENT_MATCHER;
let matcherRe = null;
try {
writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode));
} catch (e) {
// Silent fail — a stdout error at hook exit must not surface as a hook failure.
if (matcher) matcherRe = new RegExp(matcher, 'i');
} catch (_) {
// Invalid regex must not crash the hook — fall back to injecting everywhere.
}

function inject() {
try {
writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode));
} catch (e) {
// Silent fail — a stdout error at hook exit must not surface as a hook failure.
}
}

// No matcher: keep the original synchronous, stdin-independent behavior. On
// Windows the PowerShell `if {}` wrapper can swallow stdin so 'end' never fires
// (#443), so the default path must not depend on reading stdin.
if (!matcherRe) {
inject();
process.exit(0);
}

// Matcher set: read agent_type from stdin, skip injection only on a definite
// non-match. Fail OPEN (inject) on unparseable input, stdin error, or timeout.
let input = '';
let done = false;

function finish() {
if (done) return;
done = true;
try {
const data = JSON.parse(input.replace(/^\uFEFF/, ''));
const agentType = (data.agent_type || '').trim();
if (agentType && !matcherRe.test(agentType)) process.exit(0);
} catch (_) {
// Can't parse input or missing agent_type — inject to be safe.
}
inject();
}

process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', finish);
// Mirror mode-tracker's never-block contract: recover on error/timeout too.
process.stdin.on('error', () => { finish(); process.exit(0); });
setTimeout(() => { finish(); process.exit(0); }, 1000).unref();
88 changes: 54 additions & 34 deletions tests/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ function run(script, env, input = '') {
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.PLUGIN_DATA;
delete process.env.COPILOT_PLUGIN_DATA;
// A leaked matcher would scope the inject-into-every-subagent assertions.
delete process.env.PONYTAIL_SUBAGENT_MATCHER;

const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-'));
// Runs on normal exit and on assertion-throw exit; force makes it idempotent.
Expand Down Expand Up @@ -215,46 +217,64 @@ assert.equal(output.systemMessage, 'PONYTAIL:FULL');
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);

// writeDefaultMode must merge into existing config, not overwrite it (#490).
const mergeHome = path.join(temp, 'merge-home');
const mergeConfigDir = path.join(mergeHome, '.config', 'ponytail');
fs.mkdirSync(mergeConfigDir, { recursive: true });
const mergeConfigPath = path.join(mergeConfigDir, 'config.json');
fs.writeFileSync(mergeConfigPath, JSON.stringify({ defaultMode: 'full', customSetting: 42 }, null, 2));

const prevXdg = process.env.XDG_CONFIG_HOME;
process.env.XDG_CONFIG_HOME = path.join(mergeHome, '.config');
try {
writeDefaultMode('ultra');
const merged = JSON.parse(fs.readFileSync(mergeConfigPath, 'utf8'));
assert.equal(merged.defaultMode, 'ultra', 'writeDefaultMode must update defaultMode');
assert.equal(merged.customSetting, 42, 'writeDefaultMode must preserve existing config fields');
} finally {
if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = prevXdg;
}
// SubagentStart matcher (issue #506): PONYTAIL_SUBAGENT_MATCHER filters by agent_type.
const matcherHome = path.join(temp, 'matcher-home');
const matcherFlag = path.join(matcherHome, '.claude', '.ponytail-active');
fs.mkdirSync(path.dirname(matcherFlag), { recursive: true });
fs.writeFileSync(matcherFlag, 'ultra');
const matcherEnv = { HOME: matcherHome, USERPROFILE: matcherHome };

// Matching agent_type → inject.
result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general' },
JSON.stringify({ agent_type: 'general' }));
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput, 'should inject when agent_type matches');

// Non-matching agent_type → exit silently.
result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general' },
JSON.stringify({ agent_type: 'explore' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '', 'should skip injection when agent_type does not match');

// No matcher env → inject regardless of agent_type.
result = run('ponytail-subagent.js', matcherEnv,
JSON.stringify({ agent_type: 'explore' }));
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput, 'should inject when no matcher is set');

// Invalid regex → must not crash the hook; fail open (inject).
result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: '[' },
JSON.stringify({ agent_type: 'explore' }));
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput, 'invalid matcher regex should inject, not crash');

// #329: `/ponytail default <mode>` persists the default to config (survives
// restart), while a plain switch stays session-scoped and never touches config.
const defHome = path.join(temp, 'default-cmd-home');
const defEnv = { HOME: defHome, USERPROFILE: defHome, XDG_CONFIG_HOME: path.join(defHome, '.config') };
const defConfig = path.join(defHome, '.config', 'ponytail', 'config.json');
const defFlag = path.join(defHome, '.claude', '.ponytail-active');
// Matcher set but agent_type absent → inject to be safe.
result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general' },
JSON.stringify({}));
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput, 'absent agent_type should inject to be safe');

result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default lite' }));
// Case-insensitive, unanchored match → inject ('General-purpose' matches 'general').
result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general|plan' },
JSON.stringify({ agent_type: 'General-purpose' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', '/ponytail default must persist the default');
assert.equal(fs.existsSync(defFlag), false, '/ponytail default must not change the session mode');
output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput, 'matcher should be case-insensitive and unanchored');

// A plain switch is transient: sets the session flag, leaves the default alone.
result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail ultra' }));
// Anchored regex → exact match only; a superset agent_type is rejected.
result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: '^general$' },
JSON.stringify({ agent_type: 'general-purpose' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(defFlag, 'utf8'), 'ultra', 'plain switch must set the session mode');
assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', 'plain switch must not persist the default');
assert.equal(result.stdout, '', 'anchored matcher must not match a superset agent_type');

// review is not a valid default (#377) — the command is ignored, config unchanged.
result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default review' }));
// No matcher + empty stdin → default path injects without waiting on stdin (#443).
result = run('ponytail-subagent.js', matcherEnv, '');
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', 'review must not be accepted as a default');
output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput, 'no-matcher path must not depend on stdin');

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