# Ponytail Plugin — Deep-dive: Claude Code a Pi Coding Agent **Zdroj:** https://github.com/DietrichGebert/ponytail **Verze:** 4.8.3 | **Licence:** MIT **Analýza vyčtená přímo ze zdrojových kódů** --- ## 1. Claude Code — jak se plugin zapojuje ### 1.1 Manifest (`.claude-plugin/plugin.json`) ```json { "name": "ponytail", "version": "4.8.3", "description": "Lazy senior dev mode...", "author": { "name": "Dietrich Gebert", "url": "..." }, "hooks": "./hooks/claude-codex-hooks.json" } ``` **Jediné klíčové pole:** `hooks` → ukazuje na JSON soubor s definicemi lifecycle hook bindings. Claude Code při načtení pluginu přečte tento manifest a zaregistruje hooky. ### 1.2 Hook bindings (`hooks/claude-codex-hooks.json`) ```json { "hooks": { "SessionStart": [ { "matcher": "startup|resume|clear|compact", "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"; exit 0", "timeout": 5, "statusMessage": "Loading ponytail mode..." } ] } ], "SubagentStart": [ { "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-subagent.js\"; exit 0", "timeout": 5 } ] } ], "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"; exit 0", "timeout": 5 } ] } ] } } ``` **Tři hook body, každý spouští Node.js skript:** | Hook | Kdy se spouští | Co dělá | |------|---------------|---------| | `SessionStart` | Při `startup`, `resume`, `clear`, `compact` | Aktivuje mód, zapíše flag file, emituje instrukce do system promptu | | `SubagentStart` | Při spuštění subagentu (Task tool) | Předá ponytail kontext subagentu | | `UserPromptSubmit` | Při každém user promptu | Detekuje `/ponytail` commandy, přepíná mód | **Důležité:** `exit 0` na konci každého commandu — hook musí skončit úspěšně, jinak Claude považuje celý hook za failed. --- ### 1.3 SessionStart hook (`ponytail-activate.js`) — 91 řádků **Flow:** 1. **Načte default mód** z `ponytail-config.js` (env var > config file > `'full'`) 2. **Pokud `off`:** vymaže flag file, vypíše `'OK'` (nebo prázdný string pro Codex), exit 3. **Zapíše flag file** `~/.claude/.ponytail-active` s aktuálním módem 4. **Načte instrukce** z `SKILL.md` přes `ponytail-instructions.js`, filtruje podle módu 5. **Detekuje statusline** — kontroluje `~/.claude/settings.json` zda existuje `statusLine` config; pokud ne, připojí do outputu nudge pro uživatele 6. **Vypíše instrukce na stdout** — Claude Code stdout zachytí a **připojí k system promptu** **Klíčový kód (zkráceně):** ```javascript const mode = getDefaultMode(); // 'full' default if (mode === 'off') { clearMode(); writeHookOutput('SessionStart', 'off', isCodex ? '' : 'OK'); process.exit(0); } setMode(mode); // zapíše ~/.claude/.ponytail-active let output = getPonytailInstructions(mode); // načte SKILL.md, filtruje // Detekce chybějícího statusline if (!hasStatusline) { output += "\n\nSTATUSLINE SETUP NEEDED: ..."; } writeHookOutput('SessionStart', mode, output); // → stdout → Claude system prompt ``` **Stdout capture:** Claude spustí skript jako shell command, přečte stdout a **připojí obsah k system promptu** jako hidden context. Uživatel to nevidí, ale LLM to vidí jako instrukce. --- ### 1.4 SubagentStart hook (`ponytail-subagent.js`) — 22 řádků **Proč existuje:** SessionStart context je **parent-thread only** a nikdy nedosáhne subagentů (issue #252 v ponytail repo). Bez tohoto hooku by každý Task-spawned agent běžel bez ponytail pravidel. **Flow:** 1. Přečte mód z flag file (`readMode()`) 2. Pokud `off` nebo chybí flag — exit, nic neinjectuje 3. Načte instrukce pro daný mód 4. Vypíše **JSON s `hookSpecificOutput`** — Claude SubagentStart vyžaduje tento formát ```javascript const mode = readMode(); if (!mode || mode === 'off') process.exit(0); writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode)); ``` **Rozdíl oproti SessionStart:** SubagentStart **musí** vracet JSON: ```json {"hookSpecificOutput": {"hookEventName": "SubagentStart", "additionalContext": "...instrukce..."}} ``` Pokud by vrátil raw text (jako SessionStart), Claude by kontext zahodil. --- ### 1.5 UserPromptSubmit hook (`ponytail-mode-tracker.js`) — 55 řádků **Flow:** 1. Čte **stdin jako JSON** (`{"prompt": "..."}`) — Claude předává user prompt hookům přes stdin 2. Parsuje prompt, hledá `/ponytail [lite|full|ultra|off]` nebo `/ponytail-review` 3. Při matchi zapíše/maže flag file a emituje potvrzení 4. Detekuje deaktivační commandy: `"stop ponytail"` nebo `"normal mode"` (celá zpráva, ne substring) ```javascript process.stdin.on('end', () => { const data = JSON.parse(input.replace(/^\uFEFF/, '')); // strip BOM const prompt = (data.prompt || '').trim().toLowerCase(); if (/^[/@$]ponytail/.test(prompt)) { const parts = prompt.split(/\s+/); const cmd = parts[0].replace(/^[@$]/, '/'); const arg = parts[1] || ''; if (cmd === '/ponytail-review') mode = 'review'; else if (cmd === '/ponytail') { if (arg === 'lite') mode = 'lite'; else if (arg === 'full') mode = 'full'; else if (arg === 'ultra') mode = 'ultra'; else if (arg === 'off') mode = 'off'; else mode = getDefaultMode(); } if (mode && mode !== 'off') { setMode(mode); writeHookOutput('UserPromptSubmit', mode, 'PONYTAIL MODE CHANGED — level: ' + mode); } else if (mode === 'off') { clearMode(); writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF'); } } }); ``` **Důležité:** `UserPromptSubmit` hook běží **před** zpracováním promptu LLM. Pokud detekuje `/ponytail` command, zapíše flag file a emituje potvrzení — ale samotný `/ponytail` text pak jde do LLM jako normální prompt. LLM vidí potvrzení změny módu a reaguje na něj. --- ### 1.6 Runtime (`ponytail-runtime.js`) — 68 řádků **Flag file pattern:** - **Cesta:** `~/.claude/.ponytail-active` (nebo `$CLAUDE_CONFIG_DIR/.ponytail-active`) - **Obsah:** prostý text — `'lite'`, `'full'`, `'ultra'`, `'review'`, nebo chybí = off **Output formáty podle runtime:** ```javascript const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA); const isCodex = !isCopilot && Boolean(process.env.PLUGIN_DATA); function writeHookOutput(event, mode, context) { if (isCopilot) { // Copilot: JSON s additionalContext na SessionStart process.stdout.write(JSON.stringify( event === 'SessionStart' && context ? { additionalContext: context } : {})); return; } if (isCodex) { // Codex: JSON se systemMessage + hookSpecificOutput const output = { systemMessage: `PONYTAIL:${mode.toUpperCase()}` }; if (context) output.hookSpecificOutput = { hookEventName: event, additionalContext: context }; process.stdout.write(JSON.stringify(output)); return; } // Native Claude: if (event === 'SubagentStart') { // SubagentStart vyžaduje JSON formát process.stdout.write(JSON.stringify( { hookSpecificOutput: { hookEventName: event, additionalContext: context } })); return; } // SessionStart / UserPromptSubmit: raw stdout process.stdout.write(context); } ``` **Detekce runtime:** - `PLUGIN_DATA` env var = Codex CLI - `COPILOT_PLUGIN_DATA` env var = GitHub Copilot - Nic z toho = native Claude Code --- ### 1.7 Instrukce builder (`ponytail-instructions.js`) — 94 řádků **Jedna pravda pro všechny platformy:** 1. Čte `skills/ponytail/SKILL.md` (relativní cesta `../skills/ponytail/SKILL.md`) 2. Odstraní YAML frontmatter (`---...---`) 3. **Filtruje podle módu:** - Tabulky: řádky začínající `| **mode** |` se ponechají jen pro aktivní mód - Worked examples: řádky `- mode: ...` se ponechají jen pro aktivní mód - Ostatní řádky (obecná pravidla) se ponechají vždy 4. Pro `review` mód vrací jiný text: `'PONYTAIL MODE ACTIVE — level: review. Behavior defined by /ponytail-review skill.'` 5. Fallback: pokud `SKILL.md` neexistuje, generuje inline instrukce (`getFallbackInstructions()`) ```javascript function getPonytailInstructions(mode) { const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE; if (INDEPENDENT_MODES.has(configuredMode)) { // 'review' return 'PONYTAIL MODE ACTIVE — level: ' + configuredMode + '. Behavior defined by /ponytail-' + configuredMode + ' skill.'; } try { return 'PONYTAIL MODE ACTIVE — level: ' + effectiveMode + '\n\n' + filterSkillBodyForMode(fs.readFileSync(SKILL_PATH, 'utf8'), effectiveMode); } catch (e) { return getFallbackInstructions(effectiveMode); // fallback když SKILL.md chybí } } ``` --- ### 1.8 Konfigurace (`ponytail-config.js`) — 122 řádků **Resolution order default módu:** 1. `PONYTAIL_DEFAULT_MODE` env var 2. `$XDG_CONFIG_HOME/ponytail/config.json` nebo `~/.config/ponytail/config.json` 3. `'full'` **Validní módy:** `off`, `lite`, `full`, `ultra`, `review` **Deaktivace:** `isDeactivationCommand(text)` matchuje celou zprávu `"stop ponytail"` nebo `"normal mode"` (ignoruje case a trailing punctuation). Důvod: dřívější verze matchovaly substring, což deaktivovalo mód u běžných requestů jako "add a normal mode toggle". --- ## 2. Pi Coding Agent — jak se plugin zapojuje ### 2.1 Plugin entry point (`pi-extension/index.js`) — 189 řádků, ESM ```javascript import { createRequire } from "node:module"; const require = createRequire(import.meta.url); // Import sdílených modulů z hooks/ const { getDefaultMode, normalizePersistedMode, ... } = require("../hooks/ponytail-config.js"); const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js"); export default function ponytailExtension(pi) { let currentMode = DEFAULT_MODE; let configuredDefaultMode = getDefaultMode(); let isActive = false; let lastCtx = null; // ... } ``` **Pi používá stejné sdílené moduly jako Claude** (`ponytail-config.js`, `ponytail-instructions.js`) — jedna pravda pro obě platformy. --- ### 2.2 Command registrace Pi má **explicitní API** pro commandy — nikoli hooky jako Claude: ```javascript pi.registerCommand("ponytail", { description: "Set or report Ponytail mode", handler: async (args, ctx) => { const parsed = parsePonytailCommand(args, configuredDefaultMode); if (parsed.type === "status") { ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info"); return; } if (parsed.type === "set-default") { writeDefaultMode(parsed.mode); // zapíše do ~/.config/ponytail/config.json configuredDefaultMode = getDefaultMode(); return; } if (parsed.type === "set-mode") { setMode(parsed.mode, ctx); // změní currentMode, zapíše do session entries return; } } }); ``` **Podporované argumenty:** - `/ponytail` → toggle (off → full, jinak default) - `/ponytail lite|full|ultra|off` → set mód - `/ponytail status` → zobrazí current + default - `/ponytail default ` → nastaví default mód do config file **Skill aliasy:** ```javascript pi.registerCommand("ponytail-review", { handler: (_args, ctx) => sendAlias("/skill:ponytail-review", "", ctx) }); // + ponytail-audit, ponytail-gain, ponytail-debt, ponytail-help ``` `sendAlias()` posílá zprávu jako user message — buď okamžitě (pokud agent idle) nebo jako follow-up (pokud agent běží). --- ### 2.3 Event hooks (`pi.on()`) Pi používá **event-driven API** místo Claude lifecycle hooks: | Event | Kdy se spouští | Co dělá | |-------|---------------|---------| | `input` | Při každém user inputu | Detekuje `"stop ponytail"` / `"normal mode"`, deaktivuje | | `session_start` | Při startu session | Načte mód z session historie nebo defaultu, sync status bar | | `agent_start` | Když agent začne pracovat | `isActive = true`, sync status bar | | `agent_end` | Když agent skončí | `isActive = false`, sync status bar | | `before_agent_start` | **Před LLM voláním** | **Injectuje instrukce do systemPrompt** | **Klíčový hook — `before_agent_start`:** ```javascript pi.on("before_agent_start", async (event) => { if (!currentMode || currentMode === "off") return; return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode)}` }; }); ``` **Rozdíl oproti Claude:** Pi **explicitně modifikuje** `systemPrompt` a **vrací ho** jako return value. Claude zachytává stdout a připojuje ho interně. --- ### 2.4 Persistencia módu v Pi Pi **nepoužívá flag file** jako Claude. Místo toho: 1. **Session entries:** `pi.appendEntry("ponytail-mode", { mode: normalized })` — zapisuje mód do session logu 2. **Session restore:** `resolveSessionMode(entries, fallback)` — při `session_start` čte historii entries typu `"ponytail-mode"` od konce a najde poslední uložený mód 3. **Default mód:** `getDefaultMode()` ze sdíleného configu (env var > `~/.config/ponytail/config.json` > `'full'`) ```javascript export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) { if (!Array.isArray(entries)) return fallback; for (let i = entries.length - 1; i >= 0; i -= 1) { const entry = entries[i]; if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue; const mode = normalizePersistedMode(entry?.data?.mode); if (mode) return mode; } return fallback; } ``` **Proč ne flag file?** Pi agent má vlastní session management (`sessionManager.getBranch()`, `sessionManager.getEntries()`). Plugin využívá nativní Pi API místo externího souboru. --- ### 2.5 Status bar ```javascript function syncStatus(ctx) { if (!c?.ui?.setStatus || !c.ui.theme?.fg) return; const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" }; const icon = levelIcons[currentMode] || ""; const label = currentMode.toUpperCase(); const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○"); c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label)); } ``` Pi má **nativní status bar API** (`ui.setStatus`). Claude nemá — proto potřebuje externí bash skript (`ponytail-statusline.sh`) který se musí ručně nakonfigurovat v `settings.json`. --- ## 3. Srovnání: Claude Code vs Pi | Aspekt | Claude Code | Pi Coding Agent | |--------|-------------|-----------------| | **Plugin API** | Manifest JSON + lifecycle hooks | `pi.registerCommand()` + `pi.on()` events | | **Prompt injection** | Stdout capture — skript píše na stdout, Claude připojí k system promptu | `before_agent_start` event — explicitní modifikace `systemPrompt` | | **Subagent support** | `SubagentStart` hook — explicitní předání kontextu | Závisí na Pi interním chování (není explicitní SubagentStart) | | **Command parsing** | `UserPromptSubmit` hook parsuje stdin JSON, detekuje `/ponytail` | `pi.registerCommand("ponytail", ...)` — nativní command API | | **Persistencia módu** | Flag file `~/.claude/.ponytail-active` | Session entries (`pi.appendEntry`) + config file | | **Status indikace** | Externí bash skript (`ponytail-statusline.sh`) konfigurovaný v `settings.json` | Nativní `ui.setStatus()` API | | **Deaktivace** | `"stop ponytail"` / `"normal mode"` via `UserPromptSubmit` hook | `pi.on("input", ...)` detekuje stejné fráze | | **Output formát** | Raw stdout (SessionStart) nebo JSON (SubagentStart) | Return value `{ systemPrompt: "..." }` | | **Runtime detekce** | Env vars (`PLUGIN_DATA`, `COPILOT_PLUGIN_DATA`) | Není potřeba — Pi má vlastní API | | **Sdílené kódy** | `ponytail-config.js`, `ponytail-instructions.js` | Stejné + `ponytail-config.js`, `ponytail-instructions.js` | --- ## 4. Klíčové technické detaily ### 4.1 Stdout vs explicitní API **Claude:** Hook skripty jsou **externí procesy**. Claude spustí `node script.js`, přečte stdout a interně připojí k promptu. Skript nemá přímý přístup k Claude interním strukturám. **Pi:** Plugin běží **v rámci Pi procesu** jako ESM modul. Má přímý přístup k `pi` objektu a může volat `pi.on()`, `pi.registerCommand()`, modifikovat `systemPrompt`. ### 4.2 Flag file vs session entries **Claude:** Používá **souborový flag** (`~/.claude/.ponytail-active`) protože: - Hook skripty běží jako samostatné procesy — nemají přístup ke Claude session state - Flag file je jediný způsob, jak předat stav mezi SessionStart a SubagentStart hooky - Jednoduché, robustní, nezávislé na DB **Pi:** Používá **session entries** (`pi.appendEntry`) protože: - Pi má nativní session management API - Entries se persistují v rámci session a jsou dostupné při restore - Lepší integrace s Pi architekturou ### 4.3 BOM handling Oba systémy řeší UTF-8 BOM (Byte Order Mark) který některé editory přidávají na začátek souborů: ```javascript // Claude: v mode-tracker.js (stdin JSON) const data = JSON.parse(input.replace(/^\uFEFF/, '')); // Claude: v activate.js (settings.json) const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); ``` BOM breakuje `JSON.parse()` — proto se explicitně stripuje. ### 4.4 Silent fail philosophy Celý plugin je navržený s **silent fail** přístupem: ```javascript try { setMode(mode); } catch (e) { // Silent fail -- flag is best-effort, don't block the hook } try { writeHookOutput('SessionStart', mode, output); } catch (e) { // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure } ``` Důvod: hook failure by zastavila celou session. Plugin je best-effort — když selže, Claude běží normálně bez ponytail. ### 4.5 Shell safety ```javascript function isShellSafe(p) { return typeof p === 'string' && /^[A-Za-z0-9 _.\-:/\\~]+$/.test(p); } ``` Před vložením cesty pluginu do shell commandu (statusline config) se kontroluje, že cesta neobsahuje shell metacharacters. Pokud ano, nudge se změní na manuální setup místo embeddovaného command snippetu.