chill-agent 1.5.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/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
- package/.claude-plugin/plugin.json +8 -3
- package/.cline-plugin/plugin.js +6 -0
- package/.codex-plugin/plugin.json +4 -3
- package/.cursor-plugin/marketplace.json +16 -0
- package/.cursor-plugin/plugin.json +2 -2
- package/.devin-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +11 -29
- package/.omp-plugin/marketplace.json +15 -0
- package/.openclaw/skills/ponytail/SKILL.md +2 -7
- package/.openclaw/skills/ponytail-audit/SKILL.md +3 -6
- package/.openclaw/skills/ponytail-debt/SKILL.md +3 -4
- package/.openclaw/skills/ponytail-gain/SKILL.md +3 -4
- package/.openclaw/skills/ponytail-help/SKILL.md +3 -3
- package/.openclaw/skills/ponytail-review/SKILL.md +3 -6
- package/.opencode/plugins/ponytail.mjs +1 -1
- package/.qoder-plugin/plugin.json +12 -26
- package/AGENTS.md +32 -19
- package/__init__.py +1 -1
- 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/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/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-band-import.js +274 -0
- package/hooks/ponytail-config.js +175 -0
- package/hooks/ponytail-map.js +111 -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 +12 -7
- 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/publish-openclaw-skills.js +75 -0
- package/scripts/skill-index.mjs +81 -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/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/gemini-extension.test.js +2 -2
- package/tests/grok-plugin.test.js +1 -1
- package/tests/hermes-plugin.test.js +1 -1
- package/tests/hooks-windows.test.js +205 -0
- package/tests/kimi-plugin.test.js +1 -1
- package/tests/map.test.js +30 -0
- package/tests/package-scripts.test.js +35 -0
- package/tests/package.test.js +23 -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,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
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ponytail — codebase map: what already exists, so "reuse first" costs no search.
|
|
3
|
+
//
|
|
4
|
+
// Lists the top-level functions, classes and exports of the project's source files, one line
|
|
5
|
+
// per folder, inside a character budget. No model, no dependencies: `git ls-files` (or a short
|
|
6
|
+
// directory walk) plus a regex per language. Tests, generated and vendored files are skipped;
|
|
7
|
+
// shared code (utils, lib, services, components, hooks) is listed first when the budget is tight.
|
|
8
|
+
//
|
|
9
|
+
// ponytail: regex, not a parser. Misses nested or dynamic definitions; a tree-sitter index is the
|
|
10
|
+
// upgrade if the map ever needs to be exact.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execFileSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const BUDGET = Number(process.env.PONYTAIL_MAP_CHARS || 2000);
|
|
17
|
+
const MAX_FILES = 4000;
|
|
18
|
+
const SRC = /\.(py|js|jsx|mjs|cjs|ts|tsx|go|rs|rb|java|kt|php|swift|cs)$/;
|
|
19
|
+
const SKIP_DIR = /(^|\/)(node_modules|\.git|dist|build|out|vendor|venv|\.venv|__pycache__|coverage|\.next|target|migrations|versions|fixtures|tests?|__tests__|spec)(\/|$)/;
|
|
20
|
+
const SKIP_FILE = /(^|\/)(test_[^/]*|[^/]*(_test|\.test|\.spec|\.gen|\.min|\.d)\.[a-z]+)$/;
|
|
21
|
+
const NAME = /^(?:type\s+)?(?:[\w$]+\s+as\s+)?([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)$/;
|
|
22
|
+
const SAFE_PATH = /^[\w.@+$()[\]\/-]+$/;
|
|
23
|
+
const SHARED = /(util|helper|lib|common|shared|service|component|hook|core|model|schema|api)/i;
|
|
24
|
+
|
|
25
|
+
const PATTERNS = {
|
|
26
|
+
py: [/^(?:async\s+)?def\s+([A-Za-z]\w*)/gm, /^class\s+([A-Za-z]\w*)/gm],
|
|
27
|
+
js: [/^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm,
|
|
28
|
+
/^export\s*\{([^}]*)\}/gm],
|
|
29
|
+
go: [/^func\s+(?:\([^)]*\)\s*)?([A-Z]\w*)/gm, /^type\s+([A-Z]\w*)/gm],
|
|
30
|
+
rs: [/^pub\s+(?:async\s+)?(?:fn|struct|enum|trait|type)\s+(\w+)/gm],
|
|
31
|
+
rb: [/^\s*(?:def|class|module)\s+([A-Za-z][\w.]*)/gm],
|
|
32
|
+
jvm: [/^\s*(?:public\s+)?(?:static\s+)?(?:final\s+)?(?:class|interface|enum|record)\s+(\w+)/gm],
|
|
33
|
+
};
|
|
34
|
+
const LANG = { py: 'py', js: 'js', jsx: 'js', mjs: 'js', cjs: 'js', ts: 'js', tsx: 'js', go: 'go', rs: 'rs',
|
|
35
|
+
rb: 'rb', java: 'jvm', kt: 'jvm', swift: 'jvm', cs: 'jvm', php: 'jvm' };
|
|
36
|
+
|
|
37
|
+
function listFiles(root) {
|
|
38
|
+
try {
|
|
39
|
+
// 64 MiB: in repos with many tracked files `git ls-files` overflows Node's 1 MiB
|
|
40
|
+
// default, and the catch below reads that as "not a git repo" and falls back to a
|
|
41
|
+
// walk that ignores .gitignore. The list is capped at MAX_FILES * 5 lines anyway.
|
|
42
|
+
const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, maxBuffer: 64 * 1024 * 1024 })
|
|
43
|
+
.split('\n').filter(Boolean).slice(0, MAX_FILES * 5);
|
|
44
|
+
if (tracked.length) return tracked;
|
|
45
|
+
} catch (e) { /* not a git repo: walk below */ }
|
|
46
|
+
{
|
|
47
|
+
// No git, or an untracked folder inside a parent repo (git ls-files is then empty, not an error).
|
|
48
|
+
const out = [];
|
|
49
|
+
(function walk(dir, depth) {
|
|
50
|
+
if (depth > 6 || out.length > MAX_FILES * 5) return;
|
|
51
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
if (ent.name.startsWith('.')) continue;
|
|
53
|
+
const rel = path.relative(root, path.join(dir, ent.name)).replace(/\\/g, '/');
|
|
54
|
+
if (ent.isDirectory()) { if (!SKIP_DIR.test(rel + '/')) walk(path.join(dir, ent.name), depth + 1); }
|
|
55
|
+
else out.push(rel);
|
|
56
|
+
}
|
|
57
|
+
})(root, 0);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function namesIn(file, text) {
|
|
63
|
+
const lang = LANG[path.extname(file).slice(1)];
|
|
64
|
+
const names = [];
|
|
65
|
+
for (const re of PATTERNS[lang] || []) {
|
|
66
|
+
for (const m of text.matchAll(re)) {
|
|
67
|
+
for (const n of m[1].split(',').map((s) => (s.trim().match(NAME) || [])[1]).filter(Boolean)) {
|
|
68
|
+
if (!n.startsWith('_') && !names.includes(n)) names.push(n);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return names;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildMap(root = process.cwd()) {
|
|
76
|
+
const files = listFiles(root)
|
|
77
|
+
.filter((f) => SAFE_PATH.test(f) && SRC.test(f) && !SKIP_DIR.test(f) && !SKIP_FILE.test(f))
|
|
78
|
+
.slice(0, MAX_FILES)
|
|
79
|
+
.sort((a, b) => (SHARED.test(b) - SHARED.test(a)) || a.localeCompare(b));
|
|
80
|
+
// One line per directory: names are enough to know something exists; grep finds the file.
|
|
81
|
+
const dirs = new Map();
|
|
82
|
+
for (const f of files) {
|
|
83
|
+
let text;
|
|
84
|
+
try {
|
|
85
|
+
if (!fs.lstatSync(path.join(root, f)).isFile()) continue;
|
|
86
|
+
text = fs.readFileSync(path.join(root, f), 'utf8').slice(0, 65536);
|
|
87
|
+
} catch (e) { continue; }
|
|
88
|
+
const names = namesIn(f, text);
|
|
89
|
+
if (!names.length) continue;
|
|
90
|
+
const dir = path.dirname(f) === '.' ? '.' : path.dirname(f);
|
|
91
|
+
dirs.set(dir, [...(dirs.get(dir) || []), names]);
|
|
92
|
+
}
|
|
93
|
+
const lines = [];
|
|
94
|
+
let used = 0;
|
|
95
|
+
let skipped = 0;
|
|
96
|
+
for (const [dir, perFile] of dirs) {
|
|
97
|
+
// The first exports say what a file is; in a folder of many files (a UI kit) one name each.
|
|
98
|
+
const names = [...new Set(perFile.flatMap((n) => n.slice(0, perFile.length > 8 ? 1 : 3)))];
|
|
99
|
+
const line = `${dir}/: ${names.slice(0, 30).join(', ')}${names.length > 30 ? ', ...' : ''}`;
|
|
100
|
+
if (used + line.length > BUDGET) { skipped++; continue; }
|
|
101
|
+
lines.push(line);
|
|
102
|
+
used += line.length + 1;
|
|
103
|
+
}
|
|
104
|
+
if (!lines.length) return '';
|
|
105
|
+
return 'Codebase map (what already exists; reuse it, read a file only when you need its details):\n' +
|
|
106
|
+
lines.join('\n') + (skipped ? `\n(${skipped} more folders not listed)` : '');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { buildMap };
|
|
110
|
+
|
|
111
|
+
if (require.main === module) process.stdout.write(buildMap(process.argv[2] || process.cwd()) + '\n');
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ponytail — Claude Code SubagentStart hook
|
|
3
|
+
//
|
|
4
|
+
// SessionStart context is parent-thread only and never reaches subagents, so
|
|
5
|
+
// without this every Task-spawned agent runs ponytail-unaware (issue #252).
|
|
6
|
+
// When ponytail mode is active, inject the same ruleset into each subagent.
|
|
7
|
+
//
|
|
8
|
+
// Scoping (opt-in, issue #506): set PONYTAIL_SUBAGENT_MATCHER to a regex and
|
|
9
|
+
// the ruleset is injected only into subagents whose agent_type matches. The
|
|
10
|
+
// regex is unanchored and case-insensitive — "explore|general" matches either,
|
|
11
|
+
// "^general$" is exact. Unset means inject into every subagent, as before.
|
|
12
|
+
|
|
13
|
+
const { getPonytailInstructions } = require('./ponytail-instructions');
|
|
14
|
+
const { readMode, writeHookOutput } = require('./ponytail-runtime');
|
|
15
|
+
const vm = require('vm');
|
|
16
|
+
|
|
17
|
+
const mode = readMode();
|
|
18
|
+
|
|
19
|
+
// Absent flag or off → ponytail isn't active; inject nothing.
|
|
20
|
+
if (!mode || mode === 'off') {
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function inject() {
|
|
25
|
+
try {
|
|
26
|
+
writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode));
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// Silent fail — a stdout error at hook exit must not surface as a hook failure.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A bad regex must never crash the hook; treat it as "no matcher" and inject.
|
|
33
|
+
let matcherRe = null;
|
|
34
|
+
try {
|
|
35
|
+
if (process.env.PONYTAIL_SUBAGENT_MATCHER) {
|
|
36
|
+
matcherRe = new RegExp(process.env.PONYTAIL_SUBAGENT_MATCHER, 'i');
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
matcherRe = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// No matcher → keep the original synchronous, stdin-independent path. On Windows
|
|
43
|
+
// the PowerShell `if {}` wrapper can swallow the piped JSON so stdin 'end' never
|
|
44
|
+
// fires (#443); the default path must not wait on stdin or it would stall every
|
|
45
|
+
// subagent spawn.
|
|
46
|
+
if (!matcherRe) {
|
|
47
|
+
inject();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Matcher set → read agent_type from stdin and skip only on a definite
|
|
52
|
+
// mismatch. Missing/unparseable agent_type, a stdin error, or the timeout all
|
|
53
|
+
// fail open (inject), so scoping never silently drops the persona.
|
|
54
|
+
let input = '';
|
|
55
|
+
let done = false;
|
|
56
|
+
|
|
57
|
+
function finish() {
|
|
58
|
+
if (done) return;
|
|
59
|
+
done = true;
|
|
60
|
+
|
|
61
|
+
let agentType = '';
|
|
62
|
+
try {
|
|
63
|
+
// Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
|
|
64
|
+
agentType = String(JSON.parse(input.replace(/^\uFEFF/, '')).agent_type || '').trim();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Unparseable payload — fall through and inject to be safe.
|
|
67
|
+
}
|
|
68
|
+
// .test() is synchronous, so a backtracking-heavy matcher like (a+)+$ would
|
|
69
|
+
// block the event loop and the fallback timer below could never fire (#658).
|
|
70
|
+
// Run it under a vm timeout; a timeout fails open like every other doubt.
|
|
71
|
+
let matches = true;
|
|
72
|
+
try {
|
|
73
|
+
if (agentType) {
|
|
74
|
+
matches = vm.runInNewContext('re.test(s)', { re: matcherRe, s: agentType }, { timeout: 100 });
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
matches = true;
|
|
78
|
+
}
|
|
79
|
+
if (!matches) {
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
inject();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
86
|
+
// Exit on 'end' (not just finish()) so the ref'd fallback timer below can't
|
|
87
|
+
// add its full 1000ms to the normal fast path.
|
|
88
|
+
process.stdin.on('end', () => { finish(); process.exit(0); });
|
|
89
|
+
// Never block the session (#443): recover on stdin error or a short fallback.
|
|
90
|
+
// The fallback stays ref'd: on Windows a stuck ref'd stdin keeps the loop
|
|
91
|
+
// alive and an unref'd timer is never scheduled, so the hook hung to the
|
|
92
|
+
// external watchdog instead of exiting at 1s (#790).
|
|
93
|
+
process.stdin.on('error', () => { finish(); process.exit(0); });
|
|
94
|
+
setTimeout(() => { finish(); process.exit(0); }, 1000);
|