chill-agent 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/ponytail.md +30 -0
- package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -3
- package/.cline-plugin/plugin.js +6 -0
- package/.clinerules/ponytail.md +30 -0
- package/.codex-plugin/plugin.json +4 -3
- package/.cursor/rules/ponytail.mdc +36 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.cursor-plugin/plugin.json +2 -2
- package/.devin-plugin/plugin.json +2 -2
- package/.grok-plugin/marketplace.json +16 -0
- package/.kimi-plugin/plugin.json +11 -29
- package/.kiro/steering/ponytail.md +35 -0
- package/.omp-plugin/marketplace.json +15 -0
- package/.openclaw/skills/ponytail/SKILL.md +47 -0
- package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
- package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
- package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
- package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
- package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
- package/.opencode/command/ponytail-audit.md +5 -0
- package/.opencode/command/ponytail-debt.md +5 -0
- package/.opencode/command/ponytail-gain.md +5 -0
- package/.opencode/command/ponytail-help.md +5 -0
- package/.opencode/command/ponytail-review.md +5 -0
- package/.opencode/command/ponytail.md +5 -0
- package/.opencode/plugins/index.js +4 -0
- package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
- package/.opencode/plugins/ponytail.mjs +206 -0
- package/.qoder-plugin/plugin.json +12 -26
- package/.windsurf/rules/ponytail.md +30 -0
- package/AGENTS.md +32 -0
- package/__init__.py +228 -0
- package/assets/antislop-banner.png +0 -0
- package/assets/band/band.png +0 -0
- package/assets/band/board.png +0 -0
- package/assets/benchmark-3model.svg +21 -0
- package/assets/benchmark-agentic.svg +62 -0
- package/assets/codex-icon.png +0 -0
- package/assets/compare/code/after.webp +0 -0
- package/assets/compare/code/before.webp +0 -0
- package/assets/compare/text/after.webp +0 -0
- package/assets/compare/text/before.webp +0 -0
- package/assets/compare/ui/w-all.webp +0 -0
- package/assets/compare/ui/w-antislop.webp +0 -0
- package/assets/compare/ui/w-design.webp +0 -0
- package/assets/compare/ui/without.webp +0 -0
- package/assets/logo-dark.png +0 -0
- package/assets/logo-dark.svg +115 -0
- package/assets/logo-greenpt-dark.svg +27 -0
- package/assets/logo-greenpt.svg +27 -0
- package/assets/logo.png +0 -0
- package/assets/photoreunion-logo-dark.svg +4 -0
- package/assets/photoreunion-logo-light.svg +4 -0
- package/assets/profile.png +0 -0
- package/assets/retriever-logo-dark.svg +4 -0
- package/assets/retriever-logo-light.svg +4 -0
- package/assets/stories-banner.png +0 -0
- package/assets/v5/audit-es.png +0 -0
- package/assets/v5/audit-ja.png +0 -0
- package/assets/v5/audit-ko.png +0 -0
- package/assets/v5/audit-zh-CN.png +0 -0
- package/assets/v5/audit.png +0 -0
- package/assets/v5/beforeafter-es.png +0 -0
- package/assets/v5/beforeafter-ja.png +0 -0
- package/assets/v5/beforeafter-ko.png +0 -0
- package/assets/v5/beforeafter-zh-CN.png +0 -0
- package/assets/v5/beforeafter.png +0 -0
- package/assets/v5/chart-es.png +0 -0
- package/assets/v5/chart-ja.png +0 -0
- package/assets/v5/chart-ko.png +0 -0
- package/assets/v5/chart-zh-CN.png +0 -0
- package/assets/v5/chart.png +0 -0
- package/assets/v5/hero-es.jpg +0 -0
- package/assets/v5/hero-ja.jpg +0 -0
- package/assets/v5/hero-ko.jpg +0 -0
- package/assets/v5/hero-zh-CN.jpg +0 -0
- package/assets/v5/hero.jpg +0 -0
- package/assets/v5/ladder-es.png +0 -0
- package/assets/v5/ladder-ja.png +0 -0
- package/assets/v5/ladder-ko.png +0 -0
- package/assets/v5/ladder-zh-CN.png +0 -0
- package/assets/v5/ladder.png +0 -0
- package/assets/v5/review-es.png +0 -0
- package/assets/v5/review-ja.png +0 -0
- package/assets/v5/review-ko.png +0 -0
- package/assets/v5/review-zh-CN.png +0 -0
- package/assets/v5/review.png +0 -0
- package/assets/v5/tests-es.png +0 -0
- package/assets/v5/tests-ja.png +0 -0
- package/assets/v5/tests-ko.png +0 -0
- package/assets/v5/tests-zh-CN.png +0 -0
- package/assets/v5/tests.png +0 -0
- package/assets/waitlist-banner-es.png +0 -0
- package/assets/waitlist-banner-ja.png +0 -0
- package/assets/waitlist-banner-ko.png +0 -0
- package/assets/waitlist-banner-zh-CN.png +0 -0
- package/assets/waitlist-banner.png +0 -0
- package/benchmarks/.env.example +2 -0
- package/benchmarks/agentic/README.md +201 -0
- package/benchmarks/agentic/answer.py +288 -0
- package/benchmarks/agentic/compare.py +93 -0
- package/benchmarks/agentic/complete.py +178 -0
- package/benchmarks/agentic/judge.py +198 -0
- package/benchmarks/agentic/run.py +543 -0
- package/benchmarks/agentic/tasks.py +1544 -0
- package/benchmarks/arms/baseline.js +2 -0
- package/benchmarks/arms/caveman-SKILL.md +67 -0
- package/benchmarks/arms/caveman.js +8 -0
- package/benchmarks/arms/ponytail.js +8 -0
- package/benchmarks/behavior.js +54 -0
- package/benchmarks/behavior.yaml +40 -0
- package/benchmarks/correctness.js +291 -0
- package/benchmarks/loc.js +17 -19
- package/benchmarks/promptfooconfig.gemini.yaml +32 -0
- package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
- package/benchmarks/promptfooconfig.yaml +41 -0
- package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
- package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
- package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
- package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
- package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
- package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
- package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
- package/benchmarks/results/2026-06-18-agentic.md +219 -0
- package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
- package/benchmarks/results/2026-10-07-agentic.md +186 -0
- package/cli/index.mjs +241 -0
- package/cli/lib/banner.mjs +21 -0
- package/cli/lib/install.mjs +498 -0
- package/cli/lib/settings.mjs +48 -0
- package/cli/package-lock.json +87 -0
- package/cli/package.json +42 -0
- package/cli/scripts/smoke-test.mjs +95 -0
- package/cli/scripts/smoke-worker.mjs +411 -0
- package/cli/scripts/sync-skills.mjs +81 -0
- package/commands/ponytail-audit.toml +2 -0
- package/commands/ponytail-debt.toml +2 -0
- package/commands/ponytail-gain.toml +2 -0
- package/commands/ponytail-help.toml +2 -0
- package/commands/ponytail-review.toml +2 -0
- package/commands/ponytail.toml +2 -0
- package/examples/email-validation.md +156 -0
- package/examples/group-by.md +35 -0
- package/examples/infinite-scroll.md +58 -0
- package/examples/number-formatting.md +37 -0
- package/examples/rate-limit.md +272 -0
- package/examples/react-countdown.md +390 -0
- package/examples/url-params.md +41 -0
- package/gemini-extension.json +2 -2
- package/hooks/band/board.ts +560 -0
- package/hooks/band/logo.ts +3 -0
- package/hooks/band/metrics.ts +311 -0
- package/hooks/band/register.tsx +495 -0
- package/hooks/band/types.d.ts +34 -0
- package/hooks/chill-statusline.ps1 +1 -1
- package/hooks/claude-band.json +6 -0
- package/hooks/claude-codex-hooks.json +34 -4
- package/hooks/copilot-hooks.json +12 -2
- package/hooks/cursor-hooks.json +2 -2
- package/hooks/ponytail-activate.js +201 -0
- package/hooks/ponytail-band-import.js +274 -0
- package/hooks/ponytail-config.js +175 -0
- package/hooks/ponytail-instructions.js +98 -0
- package/hooks/ponytail-map.js +111 -0
- package/hooks/ponytail-mode-tracker.js +216 -0
- package/hooks/ponytail-runtime.js +182 -0
- package/hooks/ponytail-statusline.ps1 +24 -0
- package/hooks/ponytail-statusline.sh +18 -0
- package/hooks/ponytail-subagent.js +94 -0
- package/hooks/qoder-hooks.json +19 -4
- package/i18n/README.ko.md +230 -0
- package/package.json +17 -6
- package/pi-extension/index.js +254 -0
- package/pi-extension/package.json +8 -0
- package/pi-extension/test/extension.test.js +367 -0
- package/pi-extension/test/helpers.test.js +179 -0
- package/plugin.yaml +21 -12
- package/rules/antislop.mdc +18 -0
- package/scripts/build-openclaw-skills.js +60 -0
- package/scripts/check-repo.mjs +389 -0
- package/scripts/check-rule-copies.js +74 -0
- package/scripts/check-versions.js +77 -0
- package/scripts/cursor-hooks.js +145 -0
- package/scripts/generate-chillslop.mjs +42 -0
- package/scripts/generate-examples.mjs +149 -0
- package/scripts/generate-plugin-doors.mjs +108 -0
- package/scripts/publish-openclaw-skills.js +75 -0
- package/scripts/skill-index.mjs +81 -0
- package/scripts/sync-ponytail.mjs +65 -0
- package/scripts/uninstall.js +92 -0
- package/skills/antislop/SKILL.md +700 -0
- package/skills/antislop/VERSION +1 -0
- package/skills/antislop-code/SKILL.md +128 -0
- package/skills/antislop-copywriting/SKILL.md +407 -0
- package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-human/SKILL.md +204 -0
- package/skills/antislop-human/contrast-check.py +168 -0
- package/skills/antislop-human/contrast-mcp.py +121 -0
- package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
- package/skills/antislop-human/references/WCAG.md +127 -0
- package/skills/antislop-layoutmobile/SKILL.md +170 -0
- package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-ui/SKILL.md +344 -0
- package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
- package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
- package/skills/plugin.json +5 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills/ponytail-audit/SKILL.md +103 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +52 -0
- package/skills/ponytail-help/SKILL.md +76 -0
- package/skills/ponytail-review/SKILL.md +101 -0
- package/src/package.json +3 -0
- package/tests/activation-map.test.js +63 -0
- package/tests/band-import.test.js +111 -0
- package/tests/band.test.tsx +520 -0
- package/tests/behavior.test.js +80 -0
- package/tests/commands.test.js +94 -0
- package/tests/copilot-plugin.test.js +35 -0
- package/tests/correctness.test.js +245 -0
- package/tests/cursor-hooks.test.js +343 -0
- package/tests/gemini-extension.test.js +91 -0
- package/tests/grok-plugin.test.js +39 -0
- package/tests/hermes-plugin.test.js +403 -0
- package/tests/hooks-windows.test.js +205 -0
- package/tests/hooks.test.js +1005 -0
- package/tests/kimi-plugin.test.js +25 -0
- package/tests/loc.test.js +18 -0
- package/tests/map.test.js +30 -0
- package/tests/openclaw-skills.test.js +26 -0
- package/tests/opencode-plugin.test.js +340 -0
- package/tests/package-scripts.test.js +35 -0
- package/tests/package.test.js +23 -0
- package/tests/qoder-plugin.test.js +84 -0
- package/tests/uninstall.test.js +202 -0
- /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
- /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
- /package/tests/{rules.test.js → rules.test.mjs} +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ponytail — Claude Code SessionStart activation hook (also Codex, Copilot,
|
|
3
|
+
// Grok, CodeBuddy and Cursor sessionStart)
|
|
4
|
+
//
|
|
5
|
+
// Runs on every session start:
|
|
6
|
+
// 1. Writes flag file at $CLAUDE_CONFIG_DIR/.ponytail-active (defaults to ~/.claude; statusline reads this)
|
|
7
|
+
// 2. Emits ponytail ruleset as hidden SessionStart context
|
|
8
|
+
// 3. Detects missing statusline config and emits setup nudge
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
const { getDefaultMode, getClaudeDir, isShellSafe } = require('./ponytail-config');
|
|
14
|
+
const { getPonytailInstructions } = require('./ponytail-instructions');
|
|
15
|
+
const {
|
|
16
|
+
clearMode,
|
|
17
|
+
cursorRuleNotice,
|
|
18
|
+
cursorRulePath,
|
|
19
|
+
isCodeBuddy,
|
|
20
|
+
isCodex,
|
|
21
|
+
isCopilot,
|
|
22
|
+
isCursor,
|
|
23
|
+
readMode,
|
|
24
|
+
setMode,
|
|
25
|
+
writeHookOutput,
|
|
26
|
+
} = require('./ponytail-runtime');
|
|
27
|
+
|
|
28
|
+
const claudeDir = getClaudeDir();
|
|
29
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
30
|
+
|
|
31
|
+
function activate(mode) {
|
|
32
|
+
// "off" mode — skip activation entirely, don't write flag or emit rules
|
|
33
|
+
if (mode === 'off') {
|
|
34
|
+
clearMode();
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Cursor with the always-on rule in the workspace: the rule already carries the
|
|
39
|
+
// ruleset and would contradict any other level, so leave the flag alone and
|
|
40
|
+
// hand the model a one-line notice instead of a second copy (#817).
|
|
41
|
+
if (isCursor) {
|
|
42
|
+
const rule = cursorRulePath();
|
|
43
|
+
if (rule) {
|
|
44
|
+
try {
|
|
45
|
+
writeHookOutput('SessionStart', mode, cursorRuleNotice(rule));
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
|
|
48
|
+
}
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 1. Write flag file
|
|
54
|
+
try {
|
|
55
|
+
setMode(mode);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// Silent fail -- flag is best-effort, don't block the hook
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
|
|
61
|
+
let output = getPonytailInstructions(mode);
|
|
62
|
+
|
|
63
|
+
// 2b. Codebase map: what already exists, so "reuse first" costs no search. Fail open: the map
|
|
64
|
+
// gets 3 s, well inside the 15 s hook limit, so a stalled map never costs the rules (#1079, #1157).
|
|
65
|
+
// shortcut: a map slower than 3 s is still dropped; a cached per-repo map would lift that if it shows up.
|
|
66
|
+
if (mode !== 'review' && process.env.PONYTAIL_MAP !== '0') try {
|
|
67
|
+
const map = execFileSync(process.execPath, [path.join(__dirname, 'ponytail-map.js')], {
|
|
68
|
+
encoding: 'utf8', timeout: 3000, maxBuffer: 64 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
|
|
69
|
+
}).trimEnd();
|
|
70
|
+
if (map) output += '\n\n' + map;
|
|
71
|
+
} catch (e) { /* no map */ }
|
|
72
|
+
|
|
73
|
+
// 3. Detect missing statusline config — nudge Claude to help set it up
|
|
74
|
+
if (!isCodex && !isCopilot && !isCursor && !isCodeBuddy) try {
|
|
75
|
+
const isWindows = process.platform === 'win32';
|
|
76
|
+
let statusCommand = null;
|
|
77
|
+
if (fs.existsSync(settingsPath)) {
|
|
78
|
+
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
|
|
79
|
+
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
|
|
80
|
+
const settings = JSON.parse(raw);
|
|
81
|
+
if (settings.statusLine) {
|
|
82
|
+
statusCommand = String(settings.statusLine.command || '');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A statusLine already set up before #1032 can still run a script from a
|
|
87
|
+
// plugin version that has since been deleted. Only absolute paths are checked,
|
|
88
|
+
// so ~ or $HOME forms are never mistaken for missing files.
|
|
89
|
+
const ref = statusCommand &&
|
|
90
|
+
statusCommand.match(/"([^"]*ponytail-statusline\.(?:sh|ps1))"|(\S*ponytail-statusline\.(?:sh|ps1))/);
|
|
91
|
+
const refPath = ref ? (ref[1] || ref[2]) : null;
|
|
92
|
+
// isShellSafe keeps quotes, newlines and shell metacharacters from
|
|
93
|
+
// settings.json out of the model context.
|
|
94
|
+
// On Windows only drive-letter or UNC paths count: a Git Bash path such as
|
|
95
|
+
// /c/Users/... is absolute to Node but does not resolve, so it would be
|
|
96
|
+
// flagged as broken while it works.
|
|
97
|
+
const checkable = refPath && isShellSafe(refPath) && path.isAbsolute(refPath) &&
|
|
98
|
+
(!isWindows || /^([A-Za-z]:[\\/]|\\\\)/.test(refPath));
|
|
99
|
+
const stalePath = checkable && !fs.existsSync(refPath) ? refPath : null;
|
|
100
|
+
|
|
101
|
+
// The plugin root is a versioned cache dir (.../ponytail/4.12.0/) that Claude
|
|
102
|
+
// Code deletes on update, so a statusLine pointing into it goes blank after the
|
|
103
|
+
// next update (#1032). Point it at a copy in the config dir instead. A statusLine
|
|
104
|
+
// that already runs ponytail keeps its script type, so its copy stays fresh.
|
|
105
|
+
const usePs1 = refPath ? refPath.endsWith('.ps1') : isWindows;
|
|
106
|
+
const scriptName = usePs1 ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh';
|
|
107
|
+
const scriptPath = path.join(claudeDir, scriptName);
|
|
108
|
+
|
|
109
|
+
// Nudge at most once — the flag file marks that the user has already seen
|
|
110
|
+
// (and implicitly declined) the statusline setup offer. Repeating it every
|
|
111
|
+
// session start turns a helpful hint into a nag. A broken path is nudged once
|
|
112
|
+
// per path: the flag records it.
|
|
113
|
+
const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged');
|
|
114
|
+
let nudged = null;
|
|
115
|
+
try { nudged = fs.readFileSync(nudgeFlagPath, 'utf8'); } catch (e) { /* not nudged yet */ }
|
|
116
|
+
const nudge = stalePath ? nudged !== stalePath : statusCommand === null && nudged === null;
|
|
117
|
+
|
|
118
|
+
// Refresh the copy every session so script fixes ship with plugin updates.
|
|
119
|
+
// Copy to a new temp file, then rename: the rename replaces a symlink at
|
|
120
|
+
// scriptPath instead of writing through it, and a concurrent session never
|
|
121
|
+
// runs a half-written script.
|
|
122
|
+
if (nudge || fs.existsSync(scriptPath)) {
|
|
123
|
+
const tmpPath = scriptPath + '.' + process.pid + '.tmp';
|
|
124
|
+
try {
|
|
125
|
+
fs.copyFileSync(path.join(__dirname, scriptName), tmpPath, fs.constants.COPYFILE_EXCL);
|
|
126
|
+
fs.chmodSync(tmpPath, 0o644);
|
|
127
|
+
fs.renameSync(tmpPath, scriptPath);
|
|
128
|
+
} finally {
|
|
129
|
+
try { fs.unlinkSync(tmpPath); } catch (e) { /* renamed */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (nudge) {
|
|
134
|
+
try { fs.writeFileSync(nudgeFlagPath, stalePath || ''); } catch (e) { /* best-effort */ }
|
|
135
|
+
if (stalePath) {
|
|
136
|
+
output += "\n\n" +
|
|
137
|
+
"STATUSLINE BROKEN: The statusLine in " + settingsPath + " runs " + stalePath +
|
|
138
|
+
", which no longer exists (the ponytail plugin was updated and its old version removed), " +
|
|
139
|
+
"so the ponytail badge is blank. Replace that path with " + scriptPath + ", which survives updates, " +
|
|
140
|
+
"quoting it for your shell. Keep the rest of the command. " +
|
|
141
|
+
"Proactively offer to fix this for the user on first interaction.";
|
|
142
|
+
} else if (isShellSafe(scriptPath)) {
|
|
143
|
+
const command = isWindows
|
|
144
|
+
? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
|
|
145
|
+
: `bash "${scriptPath}"`;
|
|
146
|
+
const statusLineSnippet =
|
|
147
|
+
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
|
|
148
|
+
output += "\n\n" +
|
|
149
|
+
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " +
|
|
150
|
+
"(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " +
|
|
151
|
+
"To enable, add this to " + settingsPath + ": " +
|
|
152
|
+
statusLineSnippet + " " +
|
|
153
|
+
"Proactively offer to set this up for the user on first interaction.";
|
|
154
|
+
} else {
|
|
155
|
+
// ponytail: config dir path has shell metacharacters; don't embed it in a
|
|
156
|
+
// command snippet; have the agent wire it up by hand instead.
|
|
157
|
+
output += "\n\n" +
|
|
158
|
+
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " +
|
|
159
|
+
"Its path contains characters unsafe to embed in a shell command, so configure it manually: " +
|
|
160
|
+
"add a statusLine command of type \"command\" that runs " + scriptName +
|
|
161
|
+
" from " + claudeDir + " to " + settingsPath + ", quoting/escaping the path for your shell. " +
|
|
162
|
+
"Proactively offer to set this up for the user on first interaction.";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// Silent fail — don't block session start over statusline detection
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
writeHookOutput('SessionStart', mode, output);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Compaction re-runs SessionStart inside the same conversation, so it keeps the
|
|
177
|
+
// live level (or stays off after "stop ponytail") instead of resetting to the
|
|
178
|
+
// default. Claude Code and Codex pass the trigger as `source` on stdin; reading
|
|
179
|
+
// it there keeps the hook entry, and the trust Codex gave it, unchanged.
|
|
180
|
+
let input = '';
|
|
181
|
+
let done = false;
|
|
182
|
+
|
|
183
|
+
function finish() {
|
|
184
|
+
if (done) return;
|
|
185
|
+
done = true;
|
|
186
|
+
let source = '';
|
|
187
|
+
try {
|
|
188
|
+
// Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
|
|
189
|
+
source = JSON.parse(input.replace(/^\uFEFF/, '')).source;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// No payload, or an unparseable one: treat it as a fresh session
|
|
192
|
+
}
|
|
193
|
+
activate(source === 'compact' ? (readMode() || 'off') : getDefaultMode());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
197
|
+
// Never hang the session start (#443, #790): like the mode tracker, recover on a
|
|
198
|
+
// stdin error or after a short ref'd fallback, and start at the default level.
|
|
199
|
+
process.stdin.on('end', () => { finish(); process.exit(0); });
|
|
200
|
+
process.stdin.on('error', () => { finish(); process.exit(0); });
|
|
201
|
+
setTimeout(() => { finish(); process.exit(0); }, 1000);
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ponytail — imports past Claude Code sessions into the band's task ledger, so the board can
|
|
3
|
+
// compare them: cost, agent time, tokens, turns, name and model. Started in the background by
|
|
4
|
+
// `/ponytail band import`; also runs by hand. Sessions already in the ledger are left alone.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
const { StringDecoder } = require('string_decoder');
|
|
11
|
+
const { getClaudeDir } = require('./ponytail-config');
|
|
12
|
+
|
|
13
|
+
// The band's columns (CSV_HEADER in hooks/band/metrics.ts; a test keeps the two equal).
|
|
14
|
+
const CSV_HEADER = 'key,started,project,name,cost_usd,turns,agent_minutes,tokens_total,tokens_output,reuse_pct,reused,new,callers_open,tests,level,model';
|
|
15
|
+
|
|
16
|
+
// USD per million tokens from https://platform.claude.com/docs/en/about-claude/pricing, checked
|
|
17
|
+
// 2026-10-17: [input, 5-minute cache write, 1-hour cache write, cache read, output]. Opus 5.5 and
|
|
18
|
+
// Fable 5.1 also match Claude Code's own totals in real transcripts. A model missing here, or a
|
|
19
|
+
// fast-mode reply, gets no estimate: an empty cost beats a guessed one.
|
|
20
|
+
const OPUS = [5, 6.25, 10, 0.5, 25];
|
|
21
|
+
const PRICES = {
|
|
22
|
+
'claude-fable-5-1': [10, 12.5, 20, 0.25, 50],
|
|
23
|
+
'claude-fable-5': [10, 12.5, 20, 1, 50],
|
|
24
|
+
'claude-opus-5-5': [4, 5, 8, 0.2, 20],
|
|
25
|
+
'claude-opus-5': OPUS,
|
|
26
|
+
'claude-opus-4-8': OPUS,
|
|
27
|
+
'claude-opus-4-7': OPUS,
|
|
28
|
+
'claude-opus-4-6': OPUS,
|
|
29
|
+
'claude-opus-4-5': OPUS,
|
|
30
|
+
'claude-sonnet-5-5': [2, 2.5, 4, 0.1, 10],
|
|
31
|
+
'claude-sonnet-5': [2, 2.5, 4, 0.2, 10],
|
|
32
|
+
'claude-sonnet-4-6': [3, 3.75, 6, 0.3, 15],
|
|
33
|
+
'claude-sonnet-4-5': [3, 3.75, 6, 0.3, 15],
|
|
34
|
+
'claude-haiku-5-5': [0.1, 0.125, 0.2, 0.01, 0.5],
|
|
35
|
+
'claude-haiku-4-5': [1, 1.25, 2, 0.1, 5],
|
|
36
|
+
};
|
|
37
|
+
// Haiku 5.5 bills a request whose prompt (input, cache reads and writes) is over 100,000 tokens higher.
|
|
38
|
+
const HAIKU_55_LONG = [0.5, 0.625, 1, 0.05, 2.5];
|
|
39
|
+
const WEB_SEARCH = 10 / 1000;
|
|
40
|
+
const IDLE_MS = 30 * 60000;
|
|
41
|
+
const modelId = m => m.replace(/\[.*\]$/, '').replace(/-\d{8}$/, '');
|
|
42
|
+
|
|
43
|
+
// One reply's price from its usage, or null when the price is not known.
|
|
44
|
+
function replyCost(model, u) {
|
|
45
|
+
let p = PRICES[modelId(model)];
|
|
46
|
+
if (!p || u.speed === 'fast') return null;
|
|
47
|
+
const cc = u.cache_creation || {};
|
|
48
|
+
const split = cc.ephemeral_5m_input_tokens !== undefined || cc.ephemeral_1h_input_tokens !== undefined;
|
|
49
|
+
const w5 = split ? cc.ephemeral_5m_input_tokens || 0 : u.cache_creation_input_tokens || 0;
|
|
50
|
+
const w1 = split ? cc.ephemeral_1h_input_tokens || 0 : 0;
|
|
51
|
+
const input = u.input_tokens || 0;
|
|
52
|
+
const read = u.cache_read_input_tokens || 0;
|
|
53
|
+
if (modelId(model) === 'claude-haiku-5-5' && input + read + w5 + w1 > 100000) p = HAIKU_55_LONG;
|
|
54
|
+
const searches = (u.server_tool_use && u.server_tool_use.web_search_requests) || 0;
|
|
55
|
+
return (input * p[0] + w5 * p[1] + w1 * p[2] + read * p[3] + (u.output_tokens || 0) * p[4]) / 1e6 + searches * WEB_SEARCH;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const human = r => r.type === 'user' && (r.turnOrigin === 'human' || (r.origin && r.origin.kind === 'human'));
|
|
59
|
+
const promptText = r => {
|
|
60
|
+
const c = r.message && r.message.content;
|
|
61
|
+
const t = typeof c === 'string' ? c : Array.isArray(c) ? (c.find(b => b && b.type === 'text') || {}).text || '' : '';
|
|
62
|
+
return t.trim();
|
|
63
|
+
};
|
|
64
|
+
const ms = r => (r.timestamp ? Date.parse(r.timestamp) : NaN);
|
|
65
|
+
const toolResult = r => (r.toolResult !== undefined ? r.toolResult
|
|
66
|
+
: Array.isArray(r.message && r.message.content) && r.message.content.some(b => b && b.type === 'tool_result'));
|
|
67
|
+
|
|
68
|
+
// Only what the numbers need from one record: a long session's transcript can pass 500 MB.
|
|
69
|
+
function slim(r) {
|
|
70
|
+
const m = r.message || {};
|
|
71
|
+
const c = human(r) ? m.content : undefined;
|
|
72
|
+
const text = typeof c === 'string' ? c.slice(0, 500)
|
|
73
|
+
: Array.isArray(c) ? c.filter(b => b && b.type === 'text').slice(0, 1).map(b => ({ type: 'text', text: String(b.text).slice(0, 500) })) : undefined;
|
|
74
|
+
return {
|
|
75
|
+
type: r.type, timestamp: r.timestamp, turnOrigin: r.turnOrigin, origin: r.origin && { kind: r.origin.kind },
|
|
76
|
+
isMeta: r.isMeta, toolResult: toolResult(r),
|
|
77
|
+
sessionId: r.sessionId, customTitle: r.customTitle, totalCostUSD: r.totalCostUSD,
|
|
78
|
+
message: { id: m.id, model: m.model, usage: m.usage, content: text },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A JSONL file line by line, so no transcript is too big to read (one string stops at 512 MB).
|
|
83
|
+
function readRecords(file) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const take = l => { if (l.startsWith('{')) try { out.push(slim(JSON.parse(l))); } catch (e) {} };
|
|
86
|
+
const fd = fs.openSync(file, 'r');
|
|
87
|
+
const buf = Buffer.alloc(1 << 20);
|
|
88
|
+
const dec = new StringDecoder('utf8');
|
|
89
|
+
let rest = '';
|
|
90
|
+
try {
|
|
91
|
+
for (let n; (n = fs.readSync(fd, buf, 0, buf.length, null)) > 0;) {
|
|
92
|
+
const parts = (rest + dec.write(buf.subarray(0, n))).split('\n');
|
|
93
|
+
rest = parts.pop();
|
|
94
|
+
parts.forEach(take);
|
|
95
|
+
}
|
|
96
|
+
take(rest + dec.end());
|
|
97
|
+
} finally {
|
|
98
|
+
fs.closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One session's numbers from its transcript records and its subagents' records. Null for a session
|
|
104
|
+
// nobody typed into (benchmarks, `claude -p`, scripts) or one without a model reply.
|
|
105
|
+
function sessionStats(records, subagentRecords = [], sessionId = '') {
|
|
106
|
+
const prompts = records.filter(human);
|
|
107
|
+
if (prompts.length === 0) return null;
|
|
108
|
+
|
|
109
|
+
// Usage repeats on every record of one message: count each message id once, subagents included.
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const replies = {};
|
|
112
|
+
let tokens = 0;
|
|
113
|
+
let output = 0;
|
|
114
|
+
let estimate = 0;
|
|
115
|
+
const reply = r => r.type === 'assistant' && r.message && r.message.model && r.message.model !== '<synthetic>';
|
|
116
|
+
for (const r of records) if (reply(r)) replies[r.message.model] = (replies[r.message.model] || 0) + 1;
|
|
117
|
+
for (const r of [...records, ...subagentRecords]) {
|
|
118
|
+
if (!reply(r)) continue;
|
|
119
|
+
const { id, model, usage } = r.message;
|
|
120
|
+
if (!usage || !id || seen.has(id)) continue;
|
|
121
|
+
seen.add(id);
|
|
122
|
+
tokens += (usage.input_tokens || 0) + (usage.output_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
|
|
123
|
+
output += usage.output_tokens || 0;
|
|
124
|
+
const c = replyCost(model, usage);
|
|
125
|
+
estimate = estimate === null || c === null ? null : estimate + c;
|
|
126
|
+
}
|
|
127
|
+
const models = Object.keys(replies);
|
|
128
|
+
if (models.length === 0) return null;
|
|
129
|
+
|
|
130
|
+
// A turn starts with any new message: a typed prompt, a slash command (not marked as typed) or a
|
|
131
|
+
// background notification. It lasts until the model's last reply or tool result before the next
|
|
132
|
+
// one, so time waiting between messages, even days before a session is reopened, never counts.
|
|
133
|
+
let turns = 0;
|
|
134
|
+
let agentMs = 0;
|
|
135
|
+
const starts = r => r.type === 'user' && !toolResult(r) && !r.isMeta;
|
|
136
|
+
const at = records.map((r, i) => (starts(r) ? i : -1)).filter(i => i >= 0);
|
|
137
|
+
for (let k = 0; k < at.length; k++) {
|
|
138
|
+
const work = records.slice(at[k] + 1, k + 1 < at.length ? at[k + 1] : records.length)
|
|
139
|
+
.filter(r => r.type === 'assistant' || (r.type === 'user' && toolResult(r)));
|
|
140
|
+
if (!work.some(r => r.type === 'assistant')) continue;
|
|
141
|
+
turns++;
|
|
142
|
+
// A pause of over 30 minutes inside a turn is someone not answering a permission prompt, not work.
|
|
143
|
+
// shortcut: a single tool run over 30 minutes counts as zero; raise IDLE_MS if long builds go missing.
|
|
144
|
+
let prev = ms(records[at[k]]);
|
|
145
|
+
for (const t of work.map(ms).filter(Number.isFinite)) {
|
|
146
|
+
if (Number.isFinite(prev) && t > prev && t - prev <= IDLE_MS) agentMs += t - prev;
|
|
147
|
+
prev = Math.max(Number.isFinite(prev) ? prev : t, t);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (turns === 0) return null;
|
|
151
|
+
|
|
152
|
+
// Two sources, and each can only miss calls: Claude Code's own total covers just its last
|
|
153
|
+
// process run (a reopened session can say $0), the estimate misses side calls the transcript
|
|
154
|
+
// does not log (titles, compaction, internal Haiku). So the larger one wins.
|
|
155
|
+
// shortcut: without an own total the estimate runs 0-15% low in real sessions; nothing better is logged.
|
|
156
|
+
const last = (a, f) => { for (let i = a.length - 1; i >= 0; i--) if (f(a[i])) return i; return -1; };
|
|
157
|
+
const costAt = last(records, r => r.type === 'cost-state' && (!sessionId || r.sessionId === sessionId));
|
|
158
|
+
const own = costAt >= 0 && last(records, r => r.type === 'assistant') < costAt ? records[costAt].totalCostUSD : null;
|
|
159
|
+
const known = [own, estimate].filter(v => typeof v === 'number' && Number.isFinite(v) && v > 0);
|
|
160
|
+
const cost = known.length ? Math.max(...known) : null;
|
|
161
|
+
const titled = records.filter(r => r.type === 'custom-title' && typeof r.customTitle === 'string' && r.customTitle.trim());
|
|
162
|
+
const first = prompts.map(promptText).find(t => t && !t.startsWith('<') && !t.startsWith('/')) || '';
|
|
163
|
+
const started = records.map(ms).find(Number.isFinite);
|
|
164
|
+
return {
|
|
165
|
+
startedMs: started,
|
|
166
|
+
name: (titled.length ? titled[titled.length - 1].customTitle.trim() : first.split('\n')[0]).slice(0, 60),
|
|
167
|
+
model: models.sort((a, b) => replies[b] - replies[a])[0],
|
|
168
|
+
cost,
|
|
169
|
+
own,
|
|
170
|
+
estimate,
|
|
171
|
+
turns,
|
|
172
|
+
agentMs,
|
|
173
|
+
tokens,
|
|
174
|
+
output,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Same stamp and quoting as the band (hooks/band/metrics.ts), so the board reads both alike.
|
|
179
|
+
const two = n => String(n).padStart(2, '0');
|
|
180
|
+
const stamp = t => { const d = new Date(t); return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`; };
|
|
181
|
+
const field = v => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
182
|
+
|
|
183
|
+
function csvRow(sessionId, project, s) {
|
|
184
|
+
return [`${sessionId.slice(0, 8)}-${s.startedMs}`, stamp(s.startedMs), project, s.name, s.cost === null ? '' : s.cost.toFixed(2), s.turns,
|
|
185
|
+
(s.agentMs / 60000).toFixed(1), s.tokens, s.output, '', '', '', '', '', 'import', s.model].map(field).join(',');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// New rows go in after the ledger's own: a newer header replaces an older one, and a session
|
|
189
|
+
// already recorded anywhere in the ledger (same session id prefix) is never imported twice.
|
|
190
|
+
const sessionOf = row => row.slice(0, 9);
|
|
191
|
+
function mergeRows(existing, rows, have = new Set()) {
|
|
192
|
+
const kept = existing.split('\n').filter(l => l !== '' && !l.startsWith('key,'));
|
|
193
|
+
const added = rows.filter(r => !have.has(sessionOf(r)) && !kept.some(l => sessionOf(l) === sessionOf(r)));
|
|
194
|
+
return { text: [CSV_HEADER, ...kept, ...added].join('\n') + '\n', added: added.length };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const TEMP = [os.tmpdir(), '/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/'];
|
|
198
|
+
// A worktree counts for its main repo: the desktop app's <repo>/.claude/worktrees/<name> even
|
|
199
|
+
// after it is gone, and any git worktree through its common .git dir.
|
|
200
|
+
function projectOf(cwd, cache) {
|
|
201
|
+
if (!cache.has(cwd)) {
|
|
202
|
+
let root = cwd.split(/[\\/]\.claude[\\/]worktrees[\\/]/)[0];
|
|
203
|
+
if (fs.existsSync(root)) {
|
|
204
|
+
const git = spawnSync('git', ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' });
|
|
205
|
+
if (git.status === 0 && /[\\/]\.git$/.test(git.stdout.trim())) root = path.dirname(git.stdout.trim());
|
|
206
|
+
}
|
|
207
|
+
cache.set(cwd, path.basename(root.replace(/[\\/]+$/, '')));
|
|
208
|
+
}
|
|
209
|
+
return cache.get(cwd);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ledgerDir() {
|
|
213
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'ponytail', 'band');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function run() {
|
|
217
|
+
const root = path.join(getClaudeDir(), 'projects');
|
|
218
|
+
const byProject = new Map();
|
|
219
|
+
const roots = new Map();
|
|
220
|
+
let dirs = [];
|
|
221
|
+
try { dirs = fs.readdirSync(root); } catch (e) { return { sessions: 0, added: 0 }; }
|
|
222
|
+
let sessions = 0;
|
|
223
|
+
for (const dir of dirs) {
|
|
224
|
+
let files = [];
|
|
225
|
+
try { files = fs.readdirSync(path.join(root, dir)).filter(f => f.endsWith('.jsonl')); } catch (e) { continue; }
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
const p = path.join(root, dir, file);
|
|
228
|
+
// The working dir sits in the first records; scratch and temp dirs are never real projects.
|
|
229
|
+
let head = '';
|
|
230
|
+
try { const fd = fs.openSync(p, 'r'); const b = Buffer.alloc(65536); head = b.toString('utf8', 0, fs.readSync(fd, b, 0, b.length, 0)); fs.closeSync(fd); } catch (e) { continue; }
|
|
231
|
+
const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(head);
|
|
232
|
+
if (!m) continue;
|
|
233
|
+
const cwd = JSON.parse(`"${m[1]}"`);
|
|
234
|
+
if (TEMP.some(t => cwd.startsWith(t)) || cwd === '/tmp') continue;
|
|
235
|
+
// A session's first prompt is typed by a person; automated runs (benchmarks, `claude -p`) never are.
|
|
236
|
+
if (head.includes('"type":"user"') && !/"turnOrigin":"human"|"origin":\{"kind":"human"/.test(head)) continue;
|
|
237
|
+
const id = file.slice(0, -'.jsonl'.length);
|
|
238
|
+
let records;
|
|
239
|
+
try { records = readRecords(p); } catch (e) { continue; }
|
|
240
|
+
const sub = path.join(root, dir, id, 'subagents');
|
|
241
|
+
let subRecords = [];
|
|
242
|
+
try { subRecords = fs.readdirSync(sub).filter(f => /^agent-.*\.jsonl$/.test(f)).flatMap(f => readRecords(path.join(sub, f))); } catch (e) {}
|
|
243
|
+
const stats = sessionStats(records, subRecords, id);
|
|
244
|
+
if (!stats || !Number.isFinite(stats.startedMs)) continue;
|
|
245
|
+
sessions++;
|
|
246
|
+
const project = projectOf(cwd, roots);
|
|
247
|
+
if (!byProject.has(project)) byProject.set(project, []);
|
|
248
|
+
byProject.get(project).push(csvRow(id, project, stats));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const dir = ledgerDir();
|
|
252
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
253
|
+
const have = new Set();
|
|
254
|
+
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.csv'))) {
|
|
255
|
+
try { fs.readFileSync(path.join(dir, f), 'utf8').split('\n').forEach(l => l && !l.startsWith('key,') && have.add(sessionOf(l))); } catch (e) {}
|
|
256
|
+
}
|
|
257
|
+
let added = 0;
|
|
258
|
+
for (const [project, rows] of byProject) {
|
|
259
|
+
const file = path.join(dir, `${project.replace(/[^\w.-]/g, '_')}.csv`);
|
|
260
|
+
let existing = '';
|
|
261
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch (e) {}
|
|
262
|
+
const merged = mergeRows(existing, rows, have);
|
|
263
|
+
if (merged.added > 0) fs.writeFileSync(file, merged.text);
|
|
264
|
+
added += merged.added;
|
|
265
|
+
}
|
|
266
|
+
return { sessions, added };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (require.main === module) {
|
|
270
|
+
const { sessions, added } = run();
|
|
271
|
+
process.stdout.write(`ponytail band import: ${sessions} past sessions found, ${added} new rows in the board.\n`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { CSV_HEADER, PRICES, replyCost, sessionStats, csvRow, mergeRows, readRecords, run };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ponytail — shared configuration resolver
|
|
3
|
+
//
|
|
4
|
+
// Resolution order for default mode:
|
|
5
|
+
// 1. PONYTAIL_DEFAULT_MODE environment variable
|
|
6
|
+
// 2. Config file defaultMode field:
|
|
7
|
+
// - $XDG_CONFIG_HOME/ponytail/config.json (any platform, if set)
|
|
8
|
+
// - ~/.config/ponytail/config.json (macOS / Linux fallback)
|
|
9
|
+
// - %APPDATA%\ponytail\config.json (Windows fallback)
|
|
10
|
+
// 3. 'full'
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MODE = 'full';
|
|
17
|
+
const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
|
|
18
|
+
const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra'];
|
|
19
|
+
|
|
20
|
+
function normalizeMode(mode) {
|
|
21
|
+
if (typeof mode !== 'string') return null;
|
|
22
|
+
const normalized = mode.trim().toLowerCase();
|
|
23
|
+
return RUNTIME_MODES.includes(normalized) ? normalized : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeConfigMode(mode) {
|
|
27
|
+
if (typeof mode !== 'string') return null;
|
|
28
|
+
const normalized = mode.trim().toLowerCase();
|
|
29
|
+
return VALID_MODES.includes(normalized) ? normalized : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizePersistedMode(mode) {
|
|
33
|
+
return normalizeMode(mode) || normalizeConfigMode(mode);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "stop ponytail" / "normal mode" turn ponytail off, but only as a standalone
|
|
37
|
+
// command. Matching the phrase anywhere in the message turned it off mid-task
|
|
38
|
+
// for ordinary requests like "add a normal mode toggle" — so require the whole
|
|
39
|
+
// message to be the command, ignoring case and trailing punctuation.
|
|
40
|
+
function isDeactivationCommand(text) {
|
|
41
|
+
const t = String(text || '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
|
|
42
|
+
return t === 'stop ponytail' || t === 'normal mode';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ponytail: only embed the plugin install path in a statusline shell command when
|
|
46
|
+
// it's made of ordinary path characters. An allowlist beats escaping every shell's
|
|
47
|
+
// metacharacters; a hostile clone path (quotes, &, $, backtick, ;, etc.) falls back
|
|
48
|
+
// to manual setup instead. Allows : \ / for normal Windows and POSIX paths. Full
|
|
49
|
+
// per-shell escaper only if a real need appears.
|
|
50
|
+
function isShellSafe(p) {
|
|
51
|
+
return typeof p === 'string' && /^[A-Za-z0-9 _.\-:/\\~]+$/.test(p);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getConfigDir() {
|
|
55
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
56
|
+
return path.join(process.env.XDG_CONFIG_HOME, 'ponytail');
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
return path.join(
|
|
60
|
+
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
|
61
|
+
'ponytail'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return path.join(os.homedir(), '.config', 'ponytail');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getConfigPath() {
|
|
68
|
+
return path.join(getConfigDir(), 'config.json');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getClaudeDir() {
|
|
72
|
+
// ponytail: CLAUDE_CONFIG_DIR overrides ~/.claude, matching Claude Code.
|
|
73
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getQoderDir() {
|
|
77
|
+
return process.env.QODER_CONFIG_DIR || path.join(os.homedir(), '.qoder');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getDefaultMode() {
|
|
81
|
+
// 1. Environment variable (highest priority)
|
|
82
|
+
// ponytail: a default must be a runtime level (off/lite/full/ultra); review is
|
|
83
|
+
// a session-only mode, never a valid default (#377). Normalize here so values
|
|
84
|
+
// with whitespace or mixed casing still resolve correctly.
|
|
85
|
+
const envMode = normalizeMode(process.env.PONYTAIL_DEFAULT_MODE);
|
|
86
|
+
if (envMode) {
|
|
87
|
+
return envMode;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Config file
|
|
91
|
+
try {
|
|
92
|
+
const configPath = getConfigPath();
|
|
93
|
+
// Strip UTF-8 BOM (common on Windows-saved files) so JSON.parse doesn't choke
|
|
94
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
95
|
+
const configMode = normalizeMode(config.defaultMode);
|
|
96
|
+
if (configMode) {
|
|
97
|
+
return configMode;
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Config file doesn't exist or is invalid — fall through
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. Default
|
|
104
|
+
return DEFAULT_MODE;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Silence the pi "Ponytail loaded" startup toast while keeping ponytail active.
|
|
108
|
+
// PONYTAIL_QUIET_STARTUP=1 (or any truthy value; 0/false/empty mean "show it")
|
|
109
|
+
// takes precedence, else config.quietStartup === true. Mirrors getHideStatus.
|
|
110
|
+
function getQuietStartup() {
|
|
111
|
+
const env = process.env.PONYTAIL_QUIET_STARTUP;
|
|
112
|
+
if (env !== undefined) {
|
|
113
|
+
const v = env.trim().toLowerCase();
|
|
114
|
+
return v !== '' && v !== '0' && v !== 'false' && v !== 'no';
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const config = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8').replace(/^\uFEFF/, ''));
|
|
118
|
+
return config.quietStartup === true;
|
|
119
|
+
} catch (_) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Hide the status-bar indicator while keeping ponytail active (#324).
|
|
125
|
+
// PONYTAIL_HIDE_STATUS=1 (or any truthy value; 0/false/empty mean "don't hide")
|
|
126
|
+
// takes precedence, else config.hideStatus === true.
|
|
127
|
+
function getHideStatus() {
|
|
128
|
+
const env = process.env.PONYTAIL_HIDE_STATUS;
|
|
129
|
+
if (env !== undefined) {
|
|
130
|
+
const v = env.trim().toLowerCase();
|
|
131
|
+
return v !== '' && v !== '0' && v !== 'false' && v !== 'no';
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const config = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8').replace(/^\uFEFF/, ''));
|
|
135
|
+
return config.hideStatus === true;
|
|
136
|
+
} catch (_) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeDefaultMode(mode) {
|
|
142
|
+
// ponytail: only a runtime level can be a default; review is session-only (#377).
|
|
143
|
+
const normalized = normalizeMode(mode);
|
|
144
|
+
if (!normalized) return null;
|
|
145
|
+
|
|
146
|
+
const configPath = getConfigPath();
|
|
147
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
148
|
+
let config = {};
|
|
149
|
+
try {
|
|
150
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
151
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
|
|
152
|
+
} catch (_) {}
|
|
153
|
+
config.defaultMode = normalized;
|
|
154
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
155
|
+
return normalized;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
DEFAULT_MODE,
|
|
160
|
+
VALID_MODES,
|
|
161
|
+
RUNTIME_MODES,
|
|
162
|
+
getDefaultMode,
|
|
163
|
+
getConfigDir,
|
|
164
|
+
getConfigPath,
|
|
165
|
+
getClaudeDir,
|
|
166
|
+
getQoderDir,
|
|
167
|
+
getHideStatus,
|
|
168
|
+
getQuietStartup,
|
|
169
|
+
isShellSafe,
|
|
170
|
+
normalizeMode,
|
|
171
|
+
normalizeConfigMode,
|
|
172
|
+
normalizePersistedMode,
|
|
173
|
+
isDeactivationCommand,
|
|
174
|
+
writeDefaultMode,
|
|
175
|
+
};
|