Files
nanobot-runtime/results/2026-06-28_ponytail-claude-pi-deepdive.md
2026-06-30 05:32:46 +02:00

494 lines
18 KiB
Markdown

# 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 <mode>` → 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.