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
13 changes: 13 additions & 0 deletions hooks/ponytail-instructions.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,21 @@ function getPonytailInstructions(mode) {
}
}

// Subagents get the condensed fallback (not the full SKILL.md body) to keep
// Task-worker context small. Independent modes keep their short pointer string.
function getSubagentInstructions(mode) {
const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE;

if (INDEPENDENT_MODES.has(configuredMode)) {
return 'PONYTAIL MODE ACTIVE — level: ' + configuredMode + '. Behavior defined by /ponytail-' + configuredMode + ' skill.';
}

return getFallbackInstructions(normalizeMode(configuredMode) || DEFAULT_MODE);
}

module.exports = {
filterSkillBodyForMode,
getFallbackInstructions,
getPonytailInstructions,
getSubagentInstructions,
};
5 changes: 3 additions & 2 deletions hooks/ponytail-subagent.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// regex is unanchored and case-insensitive — "explore|general" matches either,
// "^general$" is exact. Unset means inject into every subagent, as before.

const { getPonytailInstructions } = require('./ponytail-instructions');
const { getSubagentInstructions } = require('./ponytail-instructions');
const { readMode, writeHookOutput } = require('./ponytail-runtime');

const mode = readMode();
Expand All @@ -22,7 +22,8 @@ if (!mode || mode === 'off') {

function inject() {
try {
writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode));
// Subagents get the condensed fallback, not the full SKILL.md (issue #597).
writeHookOutput('SubagentStart', mode, getSubagentInstructions(mode));
} catch (e) {
// Silent fail — a stdout error at hook exit must not surface as a hook failure.
}
Expand Down
73 changes: 63 additions & 10 deletions tests/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,38 @@ const root = path.join(__dirname, '..');
// paths pass, paths carrying shell metacharacters are rejected so they never get
// embedded in a shell command.
const { DEFAULT_MODE, getDefaultMode, isShellSafe, writeDefaultMode } = require('../hooks/ponytail-config');
const {
getFallbackInstructions,
getPonytailInstructions,
getSubagentInstructions,
} = require('../hooks/ponytail-instructions');
const condensedFullInstructions = getSubagentInstructions('full');
const fallbackFullInstructions = getFallbackInstructions('full');
const canonicalFullInstructions = getPonytailInstructions('full');

assert.equal(
condensedFullInstructions,
fallbackFullInstructions,
'SubagentStart should use the condensed fallback instructions',
);

assert.notEqual(
condensedFullInstructions,
canonicalFullInstructions,
'SubagentStart should not use the full SKILL.md instructions',
);

assert.ok(
condensedFullInstructions.length < canonicalFullInstructions.length,
'condensed instructions should be shorter than the full instructions',
);

assert.equal(
getSubagentInstructions('review'),
getPonytailInstructions('review'),
'independent subagent modes should keep their short pointer instructions',
);

assert.equal(isShellSafe('C:\\Users\\x\\.claude\\plugins\\ponytail\\hooks\\ponytail-statusline.ps1'), true);
assert.equal(isShellSafe('/home/u/.claude/plugins/ponytail/hooks/ponytail-statusline.sh'), true);
assert.equal(isShellSafe('/tmp/a"&calc.exe&"/x.sh'), false);
Expand Down Expand Up @@ -231,9 +263,10 @@ result = run('ponytail-subagent.js', subEnv);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(
assert.equal(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
getFallbackInstructions('full'),
'SubagentStart should inject the condensed fallback payload',
);

// No flag → ponytail off → inject nothing (empty stdout, no failure).
Expand All @@ -253,7 +286,11 @@ output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:FULL');
assert.equal(output.additionalContext, undefined, 'Codex must not emit additionalContext at top level (#573)');
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
assert.equal(
output.hookSpecificOutput.additionalContext,
getFallbackInstructions('full'),
'Codex SubagentStart should inject the condensed fallback payload',
);

// SubagentStart scoping (issue #506): PONYTAIL_SUBAGENT_MATCHER limits the
// injection to agent types whose name matches the regex. Unset keeps the
Expand All @@ -274,7 +311,11 @@ result = run(
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
assert.equal(
output.hookSpecificOutput.additionalContext,
getFallbackInstructions('full'),
'a matched subagent should receive the condensed fallback payload',
);

// agent_type the matcher rejects → stay silent.
result = run(
Expand Down Expand Up @@ -303,7 +344,11 @@ result = run(
);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
assert.equal(
output.hookSpecificOutput.additionalContext,
getFallbackInstructions('full'),
'missing agent_type should fail open with the condensed fallback payload',
);

// Invalid regex → must not crash; fall back to injecting everywhere.
result = run(
Expand All @@ -312,16 +357,23 @@ result = run(
JSON.stringify({ agent_type: 'anything' }),
);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.equal(
output.hookSpecificOutput.additionalContext,
getFallbackInstructions('full'),
'an invalid matcher should fail open with the condensed fallback payload',
);

// The default (no matcher) path must not depend on stdin: even with stdin
// closed empty it injects synchronously, preserving the #252 behavior on
// Windows where the piped JSON can be swallowed (#443).
result = run('ponytail-subagent.js', scopeEnv, '');
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
assert.equal(
output.hookSpecificOutput.additionalContext,
getFallbackInstructions('full'),
'the default no-matcher path should inject the condensed fallback payload',
);

// Qoder: no SessionStart event, so UserPromptSubmit does double duty —
// it activates the default mode on first prompt (writes flag), then injects
Expand Down Expand Up @@ -388,9 +440,10 @@ result = run('ponytail-subagent.js', qoderEnv);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(
assert.equal(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
getFallbackInstructions('full'),
'Qoder SubagentStart should inject the condensed fallback payload',
);
// writeDefaultMode must merge into existing config, not overwrite it (#490).
const mergeHome = path.join(temp, 'merge-home');
Expand Down