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.
Files changed (240) hide show
  1. package/.agents/rules/ponytail.md +30 -0
  2. package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
  3. package/.claude-plugin/marketplace.json +17 -0
  4. package/.claude-plugin/plugin.json +8 -3
  5. package/.cline-plugin/plugin.js +6 -0
  6. package/.clinerules/ponytail.md +30 -0
  7. package/.codex-plugin/plugin.json +4 -3
  8. package/.cursor/rules/ponytail.mdc +36 -0
  9. package/.cursor-plugin/marketplace.json +16 -0
  10. package/.cursor-plugin/plugin.json +2 -2
  11. package/.devin-plugin/plugin.json +2 -2
  12. package/.grok-plugin/marketplace.json +16 -0
  13. package/.kimi-plugin/plugin.json +11 -29
  14. package/.kiro/steering/ponytail.md +35 -0
  15. package/.omp-plugin/marketplace.json +15 -0
  16. package/.openclaw/skills/ponytail/SKILL.md +47 -0
  17. package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
  18. package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
  19. package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
  20. package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
  21. package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
  22. package/.opencode/command/ponytail-audit.md +5 -0
  23. package/.opencode/command/ponytail-debt.md +5 -0
  24. package/.opencode/command/ponytail-gain.md +5 -0
  25. package/.opencode/command/ponytail-help.md +5 -0
  26. package/.opencode/command/ponytail-review.md +5 -0
  27. package/.opencode/command/ponytail.md +5 -0
  28. package/.opencode/plugins/index.js +4 -0
  29. package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
  30. package/.opencode/plugins/ponytail.mjs +206 -0
  31. package/.qoder-plugin/plugin.json +12 -26
  32. package/.windsurf/rules/ponytail.md +30 -0
  33. package/AGENTS.md +32 -0
  34. package/__init__.py +228 -0
  35. package/assets/antislop-banner.png +0 -0
  36. package/assets/band/band.png +0 -0
  37. package/assets/band/board.png +0 -0
  38. package/assets/benchmark-3model.svg +21 -0
  39. package/assets/benchmark-agentic.svg +62 -0
  40. package/assets/codex-icon.png +0 -0
  41. package/assets/compare/code/after.webp +0 -0
  42. package/assets/compare/code/before.webp +0 -0
  43. package/assets/compare/text/after.webp +0 -0
  44. package/assets/compare/text/before.webp +0 -0
  45. package/assets/compare/ui/w-all.webp +0 -0
  46. package/assets/compare/ui/w-antislop.webp +0 -0
  47. package/assets/compare/ui/w-design.webp +0 -0
  48. package/assets/compare/ui/without.webp +0 -0
  49. package/assets/logo-dark.png +0 -0
  50. package/assets/logo-dark.svg +115 -0
  51. package/assets/logo-greenpt-dark.svg +27 -0
  52. package/assets/logo-greenpt.svg +27 -0
  53. package/assets/logo.png +0 -0
  54. package/assets/photoreunion-logo-dark.svg +4 -0
  55. package/assets/photoreunion-logo-light.svg +4 -0
  56. package/assets/profile.png +0 -0
  57. package/assets/retriever-logo-dark.svg +4 -0
  58. package/assets/retriever-logo-light.svg +4 -0
  59. package/assets/stories-banner.png +0 -0
  60. package/assets/v5/audit-es.png +0 -0
  61. package/assets/v5/audit-ja.png +0 -0
  62. package/assets/v5/audit-ko.png +0 -0
  63. package/assets/v5/audit-zh-CN.png +0 -0
  64. package/assets/v5/audit.png +0 -0
  65. package/assets/v5/beforeafter-es.png +0 -0
  66. package/assets/v5/beforeafter-ja.png +0 -0
  67. package/assets/v5/beforeafter-ko.png +0 -0
  68. package/assets/v5/beforeafter-zh-CN.png +0 -0
  69. package/assets/v5/beforeafter.png +0 -0
  70. package/assets/v5/chart-es.png +0 -0
  71. package/assets/v5/chart-ja.png +0 -0
  72. package/assets/v5/chart-ko.png +0 -0
  73. package/assets/v5/chart-zh-CN.png +0 -0
  74. package/assets/v5/chart.png +0 -0
  75. package/assets/v5/hero-es.jpg +0 -0
  76. package/assets/v5/hero-ja.jpg +0 -0
  77. package/assets/v5/hero-ko.jpg +0 -0
  78. package/assets/v5/hero-zh-CN.jpg +0 -0
  79. package/assets/v5/hero.jpg +0 -0
  80. package/assets/v5/ladder-es.png +0 -0
  81. package/assets/v5/ladder-ja.png +0 -0
  82. package/assets/v5/ladder-ko.png +0 -0
  83. package/assets/v5/ladder-zh-CN.png +0 -0
  84. package/assets/v5/ladder.png +0 -0
  85. package/assets/v5/review-es.png +0 -0
  86. package/assets/v5/review-ja.png +0 -0
  87. package/assets/v5/review-ko.png +0 -0
  88. package/assets/v5/review-zh-CN.png +0 -0
  89. package/assets/v5/review.png +0 -0
  90. package/assets/v5/tests-es.png +0 -0
  91. package/assets/v5/tests-ja.png +0 -0
  92. package/assets/v5/tests-ko.png +0 -0
  93. package/assets/v5/tests-zh-CN.png +0 -0
  94. package/assets/v5/tests.png +0 -0
  95. package/assets/waitlist-banner-es.png +0 -0
  96. package/assets/waitlist-banner-ja.png +0 -0
  97. package/assets/waitlist-banner-ko.png +0 -0
  98. package/assets/waitlist-banner-zh-CN.png +0 -0
  99. package/assets/waitlist-banner.png +0 -0
  100. package/benchmarks/.env.example +2 -0
  101. package/benchmarks/agentic/README.md +201 -0
  102. package/benchmarks/agentic/answer.py +288 -0
  103. package/benchmarks/agentic/compare.py +93 -0
  104. package/benchmarks/agentic/complete.py +178 -0
  105. package/benchmarks/agentic/judge.py +198 -0
  106. package/benchmarks/agentic/run.py +543 -0
  107. package/benchmarks/agentic/tasks.py +1544 -0
  108. package/benchmarks/arms/baseline.js +2 -0
  109. package/benchmarks/arms/caveman-SKILL.md +67 -0
  110. package/benchmarks/arms/caveman.js +8 -0
  111. package/benchmarks/arms/ponytail.js +8 -0
  112. package/benchmarks/behavior.js +54 -0
  113. package/benchmarks/behavior.yaml +40 -0
  114. package/benchmarks/correctness.js +291 -0
  115. package/benchmarks/loc.js +17 -19
  116. package/benchmarks/promptfooconfig.gemini.yaml +32 -0
  117. package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
  118. package/benchmarks/promptfooconfig.yaml +41 -0
  119. package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
  120. package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
  121. package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
  122. package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
  123. package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
  124. package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
  125. package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
  126. package/benchmarks/results/2026-06-18-agentic.md +219 -0
  127. package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
  128. package/benchmarks/results/2026-10-07-agentic.md +186 -0
  129. package/cli/index.mjs +241 -0
  130. package/cli/lib/banner.mjs +21 -0
  131. package/cli/lib/install.mjs +498 -0
  132. package/cli/lib/settings.mjs +48 -0
  133. package/cli/package-lock.json +87 -0
  134. package/cli/package.json +42 -0
  135. package/cli/scripts/smoke-test.mjs +95 -0
  136. package/cli/scripts/smoke-worker.mjs +411 -0
  137. package/cli/scripts/sync-skills.mjs +81 -0
  138. package/commands/ponytail-audit.toml +2 -0
  139. package/commands/ponytail-debt.toml +2 -0
  140. package/commands/ponytail-gain.toml +2 -0
  141. package/commands/ponytail-help.toml +2 -0
  142. package/commands/ponytail-review.toml +2 -0
  143. package/commands/ponytail.toml +2 -0
  144. package/examples/email-validation.md +156 -0
  145. package/examples/group-by.md +35 -0
  146. package/examples/infinite-scroll.md +58 -0
  147. package/examples/number-formatting.md +37 -0
  148. package/examples/rate-limit.md +272 -0
  149. package/examples/react-countdown.md +390 -0
  150. package/examples/url-params.md +41 -0
  151. package/gemini-extension.json +2 -2
  152. package/hooks/band/board.ts +560 -0
  153. package/hooks/band/logo.ts +3 -0
  154. package/hooks/band/metrics.ts +311 -0
  155. package/hooks/band/register.tsx +495 -0
  156. package/hooks/band/types.d.ts +34 -0
  157. package/hooks/chill-statusline.ps1 +1 -1
  158. package/hooks/claude-band.json +6 -0
  159. package/hooks/claude-codex-hooks.json +34 -4
  160. package/hooks/copilot-hooks.json +12 -2
  161. package/hooks/cursor-hooks.json +2 -2
  162. package/hooks/ponytail-activate.js +201 -0
  163. package/hooks/ponytail-band-import.js +274 -0
  164. package/hooks/ponytail-config.js +175 -0
  165. package/hooks/ponytail-instructions.js +98 -0
  166. package/hooks/ponytail-map.js +111 -0
  167. package/hooks/ponytail-mode-tracker.js +216 -0
  168. package/hooks/ponytail-runtime.js +182 -0
  169. package/hooks/ponytail-statusline.ps1 +24 -0
  170. package/hooks/ponytail-statusline.sh +18 -0
  171. package/hooks/ponytail-subagent.js +94 -0
  172. package/hooks/qoder-hooks.json +19 -4
  173. package/i18n/README.ko.md +230 -0
  174. package/package.json +17 -6
  175. package/pi-extension/index.js +254 -0
  176. package/pi-extension/package.json +8 -0
  177. package/pi-extension/test/extension.test.js +367 -0
  178. package/pi-extension/test/helpers.test.js +179 -0
  179. package/plugin.yaml +21 -12
  180. package/rules/antislop.mdc +18 -0
  181. package/scripts/build-openclaw-skills.js +60 -0
  182. package/scripts/check-repo.mjs +389 -0
  183. package/scripts/check-rule-copies.js +74 -0
  184. package/scripts/check-versions.js +77 -0
  185. package/scripts/cursor-hooks.js +145 -0
  186. package/scripts/generate-chillslop.mjs +42 -0
  187. package/scripts/generate-examples.mjs +149 -0
  188. package/scripts/generate-plugin-doors.mjs +108 -0
  189. package/scripts/publish-openclaw-skills.js +75 -0
  190. package/scripts/skill-index.mjs +81 -0
  191. package/scripts/sync-ponytail.mjs +65 -0
  192. package/scripts/uninstall.js +92 -0
  193. package/skills/antislop/SKILL.md +700 -0
  194. package/skills/antislop/VERSION +1 -0
  195. package/skills/antislop-code/SKILL.md +128 -0
  196. package/skills/antislop-copywriting/SKILL.md +407 -0
  197. package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
  198. package/skills/antislop-human/SKILL.md +204 -0
  199. package/skills/antislop-human/contrast-check.py +168 -0
  200. package/skills/antislop-human/contrast-mcp.py +121 -0
  201. package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
  202. package/skills/antislop-human/references/WCAG.md +127 -0
  203. package/skills/antislop-layoutmobile/SKILL.md +170 -0
  204. package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
  205. package/skills/antislop-ui/SKILL.md +344 -0
  206. package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
  207. package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
  208. package/skills/plugin.json +5 -0
  209. package/skills/ponytail/SKILL.md +52 -0
  210. package/skills/ponytail-audit/SKILL.md +103 -0
  211. package/skills/ponytail-debt/SKILL.md +44 -0
  212. package/skills/ponytail-gain/SKILL.md +52 -0
  213. package/skills/ponytail-help/SKILL.md +76 -0
  214. package/skills/ponytail-review/SKILL.md +101 -0
  215. package/src/package.json +3 -0
  216. package/tests/activation-map.test.js +63 -0
  217. package/tests/band-import.test.js +111 -0
  218. package/tests/band.test.tsx +520 -0
  219. package/tests/behavior.test.js +80 -0
  220. package/tests/commands.test.js +94 -0
  221. package/tests/copilot-plugin.test.js +35 -0
  222. package/tests/correctness.test.js +245 -0
  223. package/tests/cursor-hooks.test.js +343 -0
  224. package/tests/gemini-extension.test.js +91 -0
  225. package/tests/grok-plugin.test.js +39 -0
  226. package/tests/hermes-plugin.test.js +403 -0
  227. package/tests/hooks-windows.test.js +205 -0
  228. package/tests/hooks.test.js +1005 -0
  229. package/tests/kimi-plugin.test.js +25 -0
  230. package/tests/loc.test.js +18 -0
  231. package/tests/map.test.js +30 -0
  232. package/tests/openclaw-skills.test.js +26 -0
  233. package/tests/opencode-plugin.test.js +340 -0
  234. package/tests/package-scripts.test.js +35 -0
  235. package/tests/package.test.js +23 -0
  236. package/tests/qoder-plugin.test.js +84 -0
  237. package/tests/uninstall.test.js +202 -0
  238. /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
  239. /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
  240. /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
+ };