chill-agent 1.5.0 → 1.6.1
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/bin/chill-agent.js +6 -6
- package/bin/chill-agent.mjs +7 -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/tests/{manifests.test.js → manifests.test.mjs} +0 -0
- /package/tests/{rules.test.js → rules.test.mjs} +0 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import { boardHtml, tasksFrom } from './board'
|
|
5
|
+
import type { Band } from './types'
|
|
6
|
+
import type { Col } from './metrics'
|
|
7
|
+
import {
|
|
8
|
+
GREP_DEFS,
|
|
9
|
+
NO_TOKENS,
|
|
10
|
+
addTokens,
|
|
11
|
+
addedLines,
|
|
12
|
+
callsIn,
|
|
13
|
+
defsIn,
|
|
14
|
+
elapsed,
|
|
15
|
+
fmtTokens,
|
|
16
|
+
hits,
|
|
17
|
+
inProject,
|
|
18
|
+
isCode,
|
|
19
|
+
isSource,
|
|
20
|
+
isTestCommand,
|
|
21
|
+
mainModel,
|
|
22
|
+
markdown,
|
|
23
|
+
metricsSvg,
|
|
24
|
+
MODEL_PATTERN,
|
|
25
|
+
parseDefs,
|
|
26
|
+
percent,
|
|
27
|
+
resume,
|
|
28
|
+
snapshot,
|
|
29
|
+
testCounts,
|
|
30
|
+
sessionTitle,
|
|
31
|
+
taskKey,
|
|
32
|
+
testsLabel,
|
|
33
|
+
TITLE_RECORD,
|
|
34
|
+
totalTokens,
|
|
35
|
+
upsertCsv,
|
|
36
|
+
webhookRequest,
|
|
37
|
+
} from './metrics'
|
|
38
|
+
|
|
39
|
+
const LEVELS = ['off', 'lite', 'full', 'ultra']
|
|
40
|
+
const EMPTY: Band = {
|
|
41
|
+
level: 'off',
|
|
42
|
+
model: '',
|
|
43
|
+
cost: 0,
|
|
44
|
+
spent: 0,
|
|
45
|
+
turns: 0,
|
|
46
|
+
turnCosts: [],
|
|
47
|
+
tokens: NO_TOKENS,
|
|
48
|
+
busyMs: 0,
|
|
49
|
+
name: '',
|
|
50
|
+
project: '',
|
|
51
|
+
session: '',
|
|
52
|
+
transcript: '',
|
|
53
|
+
reused: [],
|
|
54
|
+
created: [],
|
|
55
|
+
removed: [],
|
|
56
|
+
openCallers: [],
|
|
57
|
+
tests: { state: 'none' },
|
|
58
|
+
details: false,
|
|
59
|
+
}
|
|
60
|
+
// The counters of one task: what a new session or /clear starts over.
|
|
61
|
+
const TASK = {
|
|
62
|
+
spent: 0,
|
|
63
|
+
turns: 0,
|
|
64
|
+
turnCosts: [],
|
|
65
|
+
tokens: NO_TOKENS,
|
|
66
|
+
busyMs: 0,
|
|
67
|
+
name: '',
|
|
68
|
+
model: '',
|
|
69
|
+
reused: [],
|
|
70
|
+
created: [],
|
|
71
|
+
removed: [],
|
|
72
|
+
openCallers: [],
|
|
73
|
+
tests: { state: 'none' as const },
|
|
74
|
+
}
|
|
75
|
+
const band = atom({ plugin: 'ponytail', key: 'band' } as const, EMPTY)
|
|
76
|
+
// State kept from an older version of the band lacks newer fields.
|
|
77
|
+
const load = async ($: EngineInterface): Promise<Band> => ({ ...EMPTY, ...(await read($, band)) })
|
|
78
|
+
|
|
79
|
+
const union = (a: string[], b: string[]) => [...new Set([...a, ...b])]
|
|
80
|
+
const base = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? path
|
|
81
|
+
|
|
82
|
+
// Windows sets USERPROFILE, not HOME.
|
|
83
|
+
const home = async ($: EngineInterface) => (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
|
|
84
|
+
const claudeDir = async ($: EngineInterface) => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await home($)}/.claude`
|
|
85
|
+
|
|
86
|
+
// `/ponytail band` (the mode tracker) leaves "off" here to hide the band. Absent flag = shown.
|
|
87
|
+
async function bandHidden($: EngineInterface): Promise<boolean> {
|
|
88
|
+
try {
|
|
89
|
+
return (await $.fs.read(`${await claudeDir($)}/.ponytail-band`)).trim() === 'off'
|
|
90
|
+
} catch {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The level ponytail's own hooks write. Absent flag = off.
|
|
96
|
+
// shortcut: the shared flag the statusline reads, so two sessions in different repos show the last switch; read the per-project flag if that matters.
|
|
97
|
+
async function readLevel($: EngineInterface): Promise<string> {
|
|
98
|
+
const dir = await claudeDir($)
|
|
99
|
+
try {
|
|
100
|
+
const level = (await $.fs.read(`${dir}/.ponytail-active`)).trim()
|
|
101
|
+
return LEVELS.includes(level) ? level : 'off'
|
|
102
|
+
} catch {
|
|
103
|
+
return 'off'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The same commands a person types, so ponytail's own skills and hooks stay the only logic.
|
|
108
|
+
const run = ($: EngineInterface, command: string, args = '') => $.command.run({ command, args })
|
|
109
|
+
|
|
110
|
+
type Options = { readonly [field: string]: unknown }
|
|
111
|
+
|
|
112
|
+
// The session's sidebar name. grep, because $.fs.read refuses files over 4 MiB and transcripts grow past that.
|
|
113
|
+
// shortcut: without grep (Windows) only a transcript under 4 MiB is read; findstr would lift that.
|
|
114
|
+
async function readTitle($: EngineInterface, transcript: string): Promise<string | undefined> {
|
|
115
|
+
if (transcript === '') return undefined
|
|
116
|
+
const grep = await $.process.run(['grep', '-F', TITLE_RECORD, transcript]).catch(() => undefined)
|
|
117
|
+
const text = grep !== undefined && grep.exitCode <= 1 ? grep.stdout : await $.fs.read(transcript).catch(() => '')
|
|
118
|
+
return sessionTitle(text)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The model that wrote most of the session's replies, so the board can tell models apart.
|
|
122
|
+
async function readModel($: EngineInterface, transcript: string): Promise<string> {
|
|
123
|
+
if (transcript === '') return ''
|
|
124
|
+
const grep = await $.process.run(['grep', '-o', MODEL_PATTERN, transcript]).catch(() => undefined)
|
|
125
|
+
return grep?.exitCode === 0 ? mainModel(grep.stdout) : ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Where Claude Code keeps the session's transcript: on managed, Team and Enterprise machines its cc-plugin-sec-default skips every classic.* hook, so no hook hands the path over.
|
|
129
|
+
// First the folder its rule names, then every project folder: it hashes names past 200 characters, and /cd, worktrees and /resume can put the file elsewhere.
|
|
130
|
+
// shortcut: with persistence off there is no file, so every turn checks every folder, read the path from the engine once it offers one.
|
|
131
|
+
async function findTranscript($: EngineInterface, session: string): Promise<string> {
|
|
132
|
+
const projects = `${await claudeDir($)}/projects`
|
|
133
|
+
const exists = (file: string) => $.fs.exists(file).catch(() => false)
|
|
134
|
+
const root = await $.session.root().catch(() => '')
|
|
135
|
+
const named = `${projects}/${root.replace(/[^a-zA-Z0-9]/g, '-')}/${session}.jsonl`
|
|
136
|
+
if (root !== '' && (await exists(named))) return named
|
|
137
|
+
const dirs = (await $.fs.list(projects).catch(() => [])).filter(d => d.kind === 'dir')
|
|
138
|
+
// One at a time: thousands of checks in flight at once are slower than in turn.
|
|
139
|
+
for (const dir of dirs) {
|
|
140
|
+
const file = `${projects}/${dir.name}/${session}.jsonl`
|
|
141
|
+
if (await exists(file)) return file
|
|
142
|
+
}
|
|
143
|
+
return ''
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Where the task ledgers and the board live.
|
|
147
|
+
// shortcut: ~/.config only (XDG respected); Windows would want %APPDATA%.
|
|
148
|
+
async function ledgerDir($: EngineInterface) {
|
|
149
|
+
return `${(await $.env.get('XDG_CONFIG_HOME')) ?? `${await home($)}/.config`}/ponytail/band`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ledgerFile = (dir: string, project: string) => `${dir}/${project.replace(/[^\w.-]/g, '_')}.csv`
|
|
153
|
+
|
|
154
|
+
// After every turn: the task's row in a CSV per project, and the same row to a webhook if one is set.
|
|
155
|
+
async function record($: EngineInterface, b: Band, options: Options) {
|
|
156
|
+
const startedAt = (await $.session.usage()).startedAt
|
|
157
|
+
const row = snapshot(b, startedAt)
|
|
158
|
+
if (options.ledger !== false && b.project !== '') {
|
|
159
|
+
const dir = await ledgerDir($)
|
|
160
|
+
const file = ledgerFile(dir, b.project)
|
|
161
|
+
const existing = await $.fs.read(file).catch(() => '')
|
|
162
|
+
// $.fs.write creates the directory: no mkdir, which Windows does not have.
|
|
163
|
+
await $.fs.write(file, upsertCsv(existing, row))
|
|
164
|
+
// The board is a plain file a refresh rereads, so it is rebuilt with every row.
|
|
165
|
+
await writeBoard($, dir)
|
|
166
|
+
}
|
|
167
|
+
const url = typeof options.webhookUrl === 'string' ? options.webhookUrl.trim() : ''
|
|
168
|
+
if (url !== '') {
|
|
169
|
+
const token = typeof options.webhookToken === 'string' ? options.webhookToken.trim() : ''
|
|
170
|
+
const request = webhookRequest(url, token, options.sendNames !== false, row)
|
|
171
|
+
if ('refused' in request) {
|
|
172
|
+
$.ui.status(`ponytail band: ${request.refused}`)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
// Not awaited: a slow endpoint must never hold up the turn.
|
|
176
|
+
void $.http
|
|
177
|
+
.fetch(request.url, request.init)
|
|
178
|
+
.then(r => $.ui.status(r.ok ? undefined : `ponytail band: webhook answered ${r.status}`))
|
|
179
|
+
.catch(() => $.ui.status('ponytail band: webhook unreachable'))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Builds board.html from every project's ledger.
|
|
184
|
+
async function writeBoard($: EngineInterface, dir: string) {
|
|
185
|
+
const entries = await $.fs.list(dir).catch(() => [])
|
|
186
|
+
const texts = await Promise.all(entries.filter(f => f.kind === 'file' && f.name.endsWith('.csv')).map(f => $.fs.read(`${dir}/${f.name}`).catch(() => '')))
|
|
187
|
+
const file = `${dir}/board.html`
|
|
188
|
+
await $.fs.write(file, boardHtml(texts.flatMap(tasksFrom), await $.clock.now()))
|
|
189
|
+
return file
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Builds the board fresh and opens it in the browser.
|
|
193
|
+
async function openBoard($: EngineInterface) {
|
|
194
|
+
const file = await writeBoard($, await ledgerDir($))
|
|
195
|
+
const windows = (await $.env.get('OS')) === 'Windows_NT'
|
|
196
|
+
const mac = !windows && (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
|
|
197
|
+
await $.process.run(windows ? ['cmd', '/c', 'start', '', file] : [mac ? 'open' : 'xdg-open', file])
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Sets the band up for a session (level, project, task row, cost baseline); returns the definitions that exist now.
|
|
201
|
+
async function start($: EngineInterface): Promise<Map<string, string>> {
|
|
202
|
+
const grep = await $.process.run(['git', 'grep', '--untracked', '-nE', GREP_DEFS]).catch(() => undefined)
|
|
203
|
+
const level = await readLevel($)
|
|
204
|
+
const details = (await $.store.get('details')) === true
|
|
205
|
+
const repo = await $.session.repo().catch(() => null)
|
|
206
|
+
const project = base(repo?.root ?? (await $.session.cwd()))
|
|
207
|
+
const session = await $.session.id()
|
|
208
|
+
// The cost so far is the baseline for the last turn's own cost.
|
|
209
|
+
const usage = await $.session.usage().catch(() => undefined)
|
|
210
|
+
const csv = usage ? await $.fs.read(ledgerFile(await ledgerDir($), project)).catch(() => '') : ''
|
|
211
|
+
const key = usage ? taskKey(session, usage.startedAt) : ''
|
|
212
|
+
await update($, band, b => resume({ ...EMPTY, ...b, level, details, project, session, cost: usage?.cost?.usd ?? b.cost }, csv, key))
|
|
213
|
+
return grep?.exitCode === 0 ? parseDefs(grep.stdout) : new Map()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const register: Register = (on, options) => {
|
|
217
|
+
// Definitions that existed when the session started: what new code can reuse.
|
|
218
|
+
let known = new Map<string, string>()
|
|
219
|
+
|
|
220
|
+
on('session.start', async ($, e, next) => {
|
|
221
|
+
known = await start($)
|
|
222
|
+
return next(e)
|
|
223
|
+
}).catch(($, e, next) => next(e))
|
|
224
|
+
|
|
225
|
+
// /clear, /resume and /branch go on under another session id with an empty band and no session.start, so the band
|
|
226
|
+
// showed "off" until the first turn ended. Set it up again once one of them has moved the session.
|
|
227
|
+
// Only these commands: the engine labels a command's output with every plugin its hook chain ran through, so a hook on
|
|
228
|
+
// every command printed "ponytail+<plugin>:" before other plugins' commands.
|
|
229
|
+
// shortcut: after /clear, ponytail-activate.js may reset the flag to the default level after this, so a level switched
|
|
230
|
+
// before /clear can show until the first turn ends; a session skips a plugin's classic.SessionStart hook, the one later point.
|
|
231
|
+
on('command.run', { command: ['clear', 'resume', 'branch'] }, async ($, e, next) => {
|
|
232
|
+
const session = await $.session.id()
|
|
233
|
+
const result = await next(e)
|
|
234
|
+
if ((await $.session.id()) !== session) known = await start($).catch(() => known)
|
|
235
|
+
return result
|
|
236
|
+
}).catch(($, e, next) => next(e))
|
|
237
|
+
|
|
238
|
+
// /clear starts a new task: the counters start over, the cost baseline stays.
|
|
239
|
+
on('session.end', async ($, e, next) => {
|
|
240
|
+
if (e.reason === 'clear') await update($, band, b => ({ ...EMPTY, ...b, ...TASK }))
|
|
241
|
+
return next(e)
|
|
242
|
+
}).catch(($, e, next) => next(e))
|
|
243
|
+
|
|
244
|
+
// The first prompt names the task until the session has a sidebar name.
|
|
245
|
+
on('prompt.submit', async ($, e, next) => {
|
|
246
|
+
const text = e.text.trim()
|
|
247
|
+
if (e.origin.kind !== 'plugin' && text !== '' && !text.startsWith('/')) {
|
|
248
|
+
const name = (text.split('\n')[0] ?? '').slice(0, 60)
|
|
249
|
+
await update($, band, b => (b.name === '' ? { ...EMPTY, ...b, name } : b))
|
|
250
|
+
}
|
|
251
|
+
return next(e)
|
|
252
|
+
}).catch(($, e, next) => next(e))
|
|
253
|
+
|
|
254
|
+
on('tool.call', async ($, e, next) => {
|
|
255
|
+
if (e.tool === 'Bash') {
|
|
256
|
+
const ran = await next(e)
|
|
257
|
+
if (ran.deny === undefined && isTestCommand(e.command)) {
|
|
258
|
+
const tests = { state: ran.isError ? ('fail' as const) : ('pass' as const), ...testCounts(ran.text ?? '') }
|
|
259
|
+
await update($, band, b => ({ ...b, tests }))
|
|
260
|
+
}
|
|
261
|
+
return ran
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (e.tool !== 'Edit' && e.tool !== 'Write') return next(e)
|
|
265
|
+
|
|
266
|
+
const file = e.file_path
|
|
267
|
+
// A scratch script outside the project is never reused or called by it (#1300).
|
|
268
|
+
const repo = await $.session.repo().catch(() => null)
|
|
269
|
+
if (!inProject(file, repo?.root ?? (await $.session.cwd()))) return next(e)
|
|
270
|
+
const before = e.tool === 'Write' ? await $.fs.read(file).catch(() => '') : e.old_string
|
|
271
|
+
const after = e.tool === 'Write' ? e.content : e.new_string
|
|
272
|
+
const ran = await next(e)
|
|
273
|
+
if (ran.deny !== undefined || ran.isError) return ran
|
|
274
|
+
|
|
275
|
+
const oldDefs = defsIn(file, before)
|
|
276
|
+
const newDefs = defsIn(file, after)
|
|
277
|
+
const created = isSource(file) ? newDefs.filter(n => !oldDefs.includes(n)) : []
|
|
278
|
+
const removed = oldDefs.filter(n => !newDefs.includes(n))
|
|
279
|
+
const reused = isSource(file) ? callsIn(addedLines(before, after), known, newDefs) : []
|
|
280
|
+
|
|
281
|
+
await update($, band, b => ({
|
|
282
|
+
...b,
|
|
283
|
+
reused: union(b.reused, reused),
|
|
284
|
+
created: union(b.created, created),
|
|
285
|
+
removed: union(b.removed, removed),
|
|
286
|
+
tests: isCode(file) && b.tests.state !== 'none' ? { ...b.tests, state: 'stale' as const } : b.tests,
|
|
287
|
+
}))
|
|
288
|
+
|
|
289
|
+
return ran
|
|
290
|
+
}).catch(($, e, next) => next(e))
|
|
291
|
+
|
|
292
|
+
on('turn.complete', async ($, e, next) => {
|
|
293
|
+
const used = e.usage
|
|
294
|
+
if (e.agentId !== undefined) {
|
|
295
|
+
// A subagent's tokens count; its cost is already in the session cost the main turn reads.
|
|
296
|
+
if (used) await update($, band, b => ({ ...EMPTY, ...b, tokens: addTokens(b.tokens ?? NO_TOKENS, used) }))
|
|
297
|
+
return next(e)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const usage = await $.session.usage()
|
|
301
|
+
const turns = await $.session.turns()
|
|
302
|
+
const level = await readLevel($)
|
|
303
|
+
const { removed, transcript: saved } = await load($)
|
|
304
|
+
const session = await $.session.id()
|
|
305
|
+
// Here, not at start: the first turn writes the file, and a new id (/clear, /resume) or a move (/cd, a worktree) changes it.
|
|
306
|
+
const kept = base(saved) === `${session}.jsonl` && (await $.fs.exists(saved).catch(() => false))
|
|
307
|
+
const transcript = kept ? saved : await findTranscript($, session)
|
|
308
|
+
const title = await readTitle($, transcript)
|
|
309
|
+
const model = await readModel($, transcript)
|
|
310
|
+
|
|
311
|
+
// A removed or renamed definition that is not defined again but still called somewhere.
|
|
312
|
+
// shortcut: name match with git grep, so a same-named call in another module counts too.
|
|
313
|
+
const stillRemoved: string[] = []
|
|
314
|
+
const open: string[] = []
|
|
315
|
+
for (const name of removed) {
|
|
316
|
+
const grep = await $.process.run(['git', 'grep', '--untracked', '-nw', '-e', name]).catch(() => undefined)
|
|
317
|
+
const rows = hits(grep?.stdout ?? '')
|
|
318
|
+
if (rows.some(r => defsIn(r.file, r.text.trimStart()).includes(name))) continue
|
|
319
|
+
const call = new RegExp(`(?<![\\w$])${name.replace(/\$/g, '\\$')}\\s*\\(`)
|
|
320
|
+
const refs = rows.filter(r => call.test(r.text)).map(r => `${r.file}:${r.line}`)
|
|
321
|
+
if (refs.length > 0) {
|
|
322
|
+
stillRemoved.push(name)
|
|
323
|
+
open.push(...refs)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const b = await update($, band, x => {
|
|
328
|
+
const b = { ...EMPTY, ...x }
|
|
329
|
+
const cost = usage.cost?.usd ?? b.cost
|
|
330
|
+
// A cost below the baseline means the engine started its ledger over.
|
|
331
|
+
const turn = cost >= b.cost ? cost - b.cost : cost
|
|
332
|
+
return {
|
|
333
|
+
...b,
|
|
334
|
+
level,
|
|
335
|
+
transcript,
|
|
336
|
+
cost,
|
|
337
|
+
// Cost and turns are the session's own totals, so a reload or a second band never starts them over.
|
|
338
|
+
spent: usage.cost?.usd ?? b.spent + turn,
|
|
339
|
+
turns,
|
|
340
|
+
turnCosts: [...b.turnCosts, turn].slice(-20),
|
|
341
|
+
tokens: used ? addTokens(b.tokens, used) : b.tokens,
|
|
342
|
+
busyMs: b.busyMs + e.durationMs,
|
|
343
|
+
name: title ?? b.name,
|
|
344
|
+
model: model || b.model,
|
|
345
|
+
removed: stillRemoved,
|
|
346
|
+
openCallers: open,
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
await record($, b, options).catch(() => $.ui.status('ponytail band: could not write the task ledger'))
|
|
350
|
+
|
|
351
|
+
return next(e)
|
|
352
|
+
}).catch(($, e, next) => next(e))
|
|
353
|
+
|
|
354
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
355
|
+
if (e.props.hasSurvey || (await bandHidden($))) return next(e)
|
|
356
|
+
// Other mods share this slot: draw theirs below ours instead of replacing it.
|
|
357
|
+
// next(e) rejects only when nothing answers beneath (the test harness): the band still draws.
|
|
358
|
+
const below = await next(e).catch(() => undefined)
|
|
359
|
+
|
|
360
|
+
const b = await load($)
|
|
361
|
+
const now = await $.clock.now()
|
|
362
|
+
const startedAt = (await $.session.usage().catch(() => undefined))?.startedAt ?? now
|
|
363
|
+
const reuse = percent(b.reused.length, b.created.length)
|
|
364
|
+
const avg = b.turns > 0 ? b.spent / b.turns : 0
|
|
365
|
+
const last = b.turnCosts[b.turnCosts.length - 1]
|
|
366
|
+
|
|
367
|
+
const setLevel = async (level: string) => {
|
|
368
|
+
await update($, band, x => ({ ...x, level }))
|
|
369
|
+
await run($, 'ponytail:ponytail', level)
|
|
370
|
+
}
|
|
371
|
+
const toggle = async () => {
|
|
372
|
+
await update($, band, x => ({ ...x, details: !x.details }))
|
|
373
|
+
await $.store.set('details', !b.details)
|
|
374
|
+
}
|
|
375
|
+
const review = () => run($, 'ponytail:ponytail-review')
|
|
376
|
+
const audit = () => run($, 'ponytail:ponytail-audit')
|
|
377
|
+
const board = () => openBoard($).catch(() => $.ui.toast('ponytail band: could not open the board'))
|
|
378
|
+
const copy = async () => {
|
|
379
|
+
const done = await $.ui.copy({ text: markdown(snapshot(await load($), startedAt)), surface: e.surface })
|
|
380
|
+
$.ui.toast(done.isCopied ? 'Task copied as Markdown' : `Copy failed: ${done.reason}`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
384
|
+
// bodyColumns counts terminal cells; a graphical surface draws Buttons wider than their cells, so a row sized to it wraps there.
|
|
385
|
+
const width = e.surface === 'terminal' ? e.props.bodyColumns : undefined
|
|
386
|
+
const stack =(mine: NonNullable<typeof below>) => (below ? <Box flexDirection="column">{mine}{below}</Box> : mine)
|
|
387
|
+
|
|
388
|
+
const testsText =
|
|
389
|
+
b.tests.state === 'pass'
|
|
390
|
+
? `✓ ${b.tests.passed ?? ''} tests`
|
|
391
|
+
: b.tests.state === 'fail'
|
|
392
|
+
? `✗ ${b.tests.failed ?? ''} failed`
|
|
393
|
+
: b.tests.state === 'stale'
|
|
394
|
+
? 'no test since change'
|
|
395
|
+
: ''
|
|
396
|
+
const testsColor = b.tests.state === 'pass' ? 'success' : b.tests.state === 'fail' ? 'error' : undefined
|
|
397
|
+
const hasOpen = b.openCallers.length > 0
|
|
398
|
+
|
|
399
|
+
if (!b.details) {
|
|
400
|
+
return stack(
|
|
401
|
+
<Box flexDirection="row" alignItems="center" gap={2} width={width}>
|
|
402
|
+
{/* A click opens the four levels: one click, one turn, no stops on levels nobody wanted. */}
|
|
403
|
+
<Button key="level" plain onPress={toggle}>
|
|
404
|
+
<Text color={b.level === 'off' ? 'inactive' : 'suggestion'}>●</Text> {b.level}
|
|
405
|
+
</Button>
|
|
406
|
+
<Text>${b.spent.toFixed(2)}</Text>
|
|
407
|
+
{last !== undefined && <Text dimColor>last ${last.toFixed(2)}</Text>}
|
|
408
|
+
<Text dimColor>{elapsed(b.busyMs)}</Text>
|
|
409
|
+
{reuse !== undefined && <Text>{reuse}% reuse</Text>}
|
|
410
|
+
{hasOpen && <Text color="warning">⚠ {b.openCallers.length} callers open</Text>}
|
|
411
|
+
{testsText !== '' && (
|
|
412
|
+
<Text color={testsColor} dimColor={b.tests.state === 'stale'}>
|
|
413
|
+
{testsText}
|
|
414
|
+
</Text>
|
|
415
|
+
)}
|
|
416
|
+
<Box flexGrow={1} />
|
|
417
|
+
<Button key="review" plain label="Review" onPress={review} />
|
|
418
|
+
<Button key="audit" plain label="Audit" onPress={audit} />
|
|
419
|
+
<Button key="board" plain label="Board" onPress={board} />
|
|
420
|
+
<Button key="details" plain label="Details" onPress={toggle} />
|
|
421
|
+
</Box>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Only columns that say something; callers and tests appear once there is something to report.
|
|
426
|
+
const cols: Col[] = [
|
|
427
|
+
{ key: 'cost', label: 'cost', value: `$${b.spent.toFixed(2)}`, sub: last === undefined ? '' : `last $${last.toFixed(2)} · ⌀ $${avg.toFixed(2)}` },
|
|
428
|
+
{ key: 'tokens', label: 'tokens', value: fmtTokens(totalTokens(b.tokens)), sub: `${b.turns} ${b.turns === 1 ? 'turn' : 'turns'}` },
|
|
429
|
+
{ key: 'time', label: 'agent time', value: elapsed(b.busyMs), sub: b.turns > 0 ? `⌀ ${elapsed(b.busyMs / b.turns)} / turn` : '' },
|
|
430
|
+
]
|
|
431
|
+
if (reuse !== undefined) cols.push({ key: 'reuse', label: 'reuse', value: `${reuse}%`, sub: '' })
|
|
432
|
+
if (hasOpen) {
|
|
433
|
+
cols.push({ key: 'callers', label: 'callers', value: `${b.openCallers.length} open`, sub: b.openCallers.slice(0, 2).join(', '), tone: 'warning' })
|
|
434
|
+
}
|
|
435
|
+
if (b.tests.state !== 'none') {
|
|
436
|
+
cols.push({
|
|
437
|
+
key: 'tests',
|
|
438
|
+
label: 'tests',
|
|
439
|
+
value: testsLabel(b.tests),
|
|
440
|
+
sub: b.tests.state === 'pass' ? 'after last change' : b.tests.state === 'fail' ? 'last run' : 'code changed since',
|
|
441
|
+
tone: b.tests.state === 'pass' ? 'success' : b.tests.state === 'fail' ? 'error' : undefined,
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let metrics = (
|
|
446
|
+
<Box flexDirection="column">
|
|
447
|
+
<Text dimColor wrap="truncate-end">
|
|
448
|
+
{'─'.repeat(e.props.bodyColumns)}
|
|
449
|
+
</Text>
|
|
450
|
+
<Box flexDirection="row" gap={2}>
|
|
451
|
+
{cols.map(c => (
|
|
452
|
+
// Off the terminal the columns share the row; a width in cells would leave most of it empty and cut the text.
|
|
453
|
+
<Box key={c.key} flexDirection="column" {...(width === undefined ? { flexGrow: 1 } : { width: Math.max(10, Math.floor(width / cols.length) - 2) })}>
|
|
454
|
+
<Text dimColor>{c.label}</Text>
|
|
455
|
+
<Text bold color={c.tone}>
|
|
456
|
+
{c.value}
|
|
457
|
+
</Text>
|
|
458
|
+
<Text dimColor wrap="truncate-end">
|
|
459
|
+
{c.sub}
|
|
460
|
+
</Text>
|
|
461
|
+
</Box>
|
|
462
|
+
))}
|
|
463
|
+
</Box>
|
|
464
|
+
</Box>
|
|
465
|
+
)
|
|
466
|
+
// Text glyphs make broken lines in the desktop's proportional font; an SVG draws real ones.
|
|
467
|
+
if (e.surface === 'desktop') {
|
|
468
|
+
const { Svg } = $.ui.resolve(e)
|
|
469
|
+
metrics = <Svg source={metricsSvg(cols, b.turnCosts)} alt={cols.map(c => `${c.label} ${c.value}`).join(', ')} />
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return stack(
|
|
473
|
+
// The desktop's SVG draws its own line at the top, which would touch the buttons without the gap.
|
|
474
|
+
<Box flexDirection="column" width={width} gap={e.surface === 'desktop' ? 1 : 0}>
|
|
475
|
+
<Box flexDirection="row" alignItems="center" gap={1}>
|
|
476
|
+
{LEVELS.map(level => (
|
|
477
|
+
<Button
|
|
478
|
+
key={`level-${level}`}
|
|
479
|
+
label={level}
|
|
480
|
+
{...(level === b.level ? { variant: 'primary' as const } : { plain: true as const, dimColor: true })}
|
|
481
|
+
onPress={() => setLevel(level)}
|
|
482
|
+
/>
|
|
483
|
+
))}
|
|
484
|
+
<Box flexGrow={1} />
|
|
485
|
+
<Button key="copy" plain label="Copy" onPress={copy} />
|
|
486
|
+
<Button key="review" plain label="Review" onPress={review} />
|
|
487
|
+
<Button key="audit" plain label="Audit" onPress={audit} />
|
|
488
|
+
<Button key="board" plain label="Board" onPress={board} />
|
|
489
|
+
<Button key="details" plain label="Compact" onPress={toggle} />
|
|
490
|
+
</Box>
|
|
491
|
+
{metrics}
|
|
492
|
+
</Box>
|
|
493
|
+
)
|
|
494
|
+
}).catch(($, e, next) => next(e))
|
|
495
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type Tests = {
|
|
2
|
+
state: 'none' | 'pass' | 'fail' | 'stale'
|
|
3
|
+
passed?: number
|
|
4
|
+
failed?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
8
|
+
|
|
9
|
+
export type Band = {
|
|
10
|
+
level: string
|
|
11
|
+
model: string
|
|
12
|
+
cost: number
|
|
13
|
+
spent: number
|
|
14
|
+
turns: number
|
|
15
|
+
turnCosts: number[]
|
|
16
|
+
tokens: Tokens
|
|
17
|
+
busyMs: number
|
|
18
|
+
name: string
|
|
19
|
+
project: string
|
|
20
|
+
session: string
|
|
21
|
+
transcript: string
|
|
22
|
+
reused: string[]
|
|
23
|
+
created: string[]
|
|
24
|
+
removed: string[]
|
|
25
|
+
openCallers: string[]
|
|
26
|
+
tests: Tests
|
|
27
|
+
details: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare module 'claude-code' {
|
|
31
|
+
interface PluginState {
|
|
32
|
+
ponytail: { band: Band }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": 1,
|
|
3
2
|
"hooks": {
|
|
4
|
-
"
|
|
3
|
+
"SessionStart": [
|
|
5
4
|
{
|
|
6
|
-
"
|
|
7
|
-
"
|
|
5
|
+
"matcher": "startup|resume|clear|compact",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node -e \"require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT.split(String.fromCharCode(92)).join('/'), 'hooks/ponytail-activate.js'))\"",
|
|
10
|
+
"timeout": 15,
|
|
11
|
+
"statusMessage": "Loading ponytail mode..."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"SubagentStart": [
|
|
17
|
+
{
|
|
18
|
+
"hooks": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "node -e \"require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT.split(String.fromCharCode(92)).join('/'), 'hooks/ponytail-subagent.js'))\"",
|
|
22
|
+
"timeout": 15,
|
|
23
|
+
"statusMessage": "Loading ponytail mode..."
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"UserPromptSubmit": [
|
|
29
|
+
{
|
|
30
|
+
"hooks": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "node -e \"require(require('path').join(process.env.CLAUDE_PLUGIN_ROOT.split(String.fromCharCode(92)).join('/'), 'hooks/ponytail-mode-tracker.js'))\"",
|
|
34
|
+
"timeout": 15,
|
|
35
|
+
"statusMessage": "Tracking ponytail mode..."
|
|
36
|
+
}
|
|
37
|
+
]
|
|
8
38
|
}
|
|
9
39
|
]
|
|
10
40
|
}
|
package/hooks/copilot-hooks.json
CHANGED
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
"hooks": {
|
|
4
4
|
"sessionStart": [
|
|
5
5
|
{
|
|
6
|
-
"
|
|
7
|
-
"
|
|
6
|
+
"type": "command",
|
|
7
|
+
"bash": "node -e \"require(require('path').join(process.env.PLUGIN_ROOT.split(String.fromCharCode(92)).join('/'), 'hooks/ponytail-activate.js'))\" || exit 0",
|
|
8
|
+
"powershell": "if (Get-Command node -ErrorAction SilentlyContinue) { node -e \"require(require('path').join(process.env.PLUGIN_ROOT.split(String.fromCharCode(92)).join('/'), 'hooks/ponytail-activate.js'))\" }",
|
|
9
|
+
"timeoutSec": 15
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"userPromptSubmitted": [
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"bash": "node -e \"require(require('path').join(process.env.PLUGIN_ROOT.split(String.fromCharCode(92)).join('/'), 'hooks/ponytail-mode-tracker.js'))\" || exit 0",
|
|
16
|
+
"powershell": "if (Get-Command node -ErrorAction SilentlyContinue) { node -e \"require(require('path').join(process.env.PLUGIN_ROOT.split(String.fromCharCode(92)).join('/'), 'hooks/ponytail-mode-tracker.js'))\" }",
|
|
17
|
+
"timeoutSec": 15
|
|
8
18
|
}
|
|
9
19
|
]
|
|
10
20
|
}
|
package/hooks/cursor-hooks.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"hooks": {
|
|
4
4
|
"sessionStart": [
|
|
5
5
|
{
|
|
6
|
-
"command": "node \"
|
|
6
|
+
"command": "node \"PONYTAIL_DIR/hooks/ponytail-activate.js\"",
|
|
7
7
|
"timeout": 15
|
|
8
8
|
}
|
|
9
9
|
],
|
|
10
10
|
"beforeSubmitPrompt": [
|
|
11
11
|
{
|
|
12
|
-
"command": "node \"
|
|
12
|
+
"command": "node \"PONYTAIL_DIR/hooks/ponytail-mode-tracker.js\"",
|
|
13
13
|
"timeout": 15
|
|
14
14
|
}
|
|
15
15
|
]
|