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,254 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ const require = createRequire(import.meta.url);
4
+ const {
5
+ DEFAULT_MODE,
6
+ RUNTIME_MODES,
7
+ getDefaultMode,
8
+ getQuietStartup,
9
+ getHideStatus,
10
+ normalizeMode,
11
+ normalizePersistedMode,
12
+ isDeactivationCommand,
13
+ writeDefaultMode,
14
+ } = require("../hooks/ponytail-config.js");
15
+ const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
16
+
17
+ export { filterSkillBodyForMode };
18
+ export const readDefaultMode = getDefaultMode;
19
+ export const readQuietStartup = getQuietStartup;
20
+
21
+ const RUNTIME_MODE_LIST = RUNTIME_MODES.join("|");
22
+ const PONYTAIL_COMMAND_DESCRIPTION = `Set mode: ${RUNTIME_MODE_LIST}. Commands: status, default <mode>`;
23
+
24
+ // Every item is a complete command: pi submits on Enter, so a bare "default" would run without a mode.
25
+ // Values only: per-level descriptions would be a second copy of the Levels table in SKILL.md.
26
+ export function completePonytailArgs(prefix) {
27
+ const typed = String(prefix || "").trimStart();
28
+ const values = [...RUNTIME_MODES, "status", ...RUNTIME_MODES.map((m) => `default ${m}`)].filter((v) => v.startsWith(typed));
29
+ return values.length ? values.map((value) => ({ value, label: value })) : null;
30
+ }
31
+
32
+ export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
33
+ const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
34
+ if (!Array.isArray(entries)) return fallback;
35
+
36
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
37
+ const entry = entries[i];
38
+ if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue;
39
+
40
+ const mode = normalizePersistedMode(entry?.data?.mode);
41
+ if (mode) return mode;
42
+ }
43
+
44
+ return fallback;
45
+ }
46
+
47
+ export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE, currentMode = null) {
48
+ const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE;
49
+ const normalizedText = String(text || "").trim().toLowerCase();
50
+
51
+ if (!normalizedText) {
52
+ // Bare /ponytail switches ponytail on, or reports the level when it already is (#639).
53
+ if (currentMode && currentMode !== "off") return { type: "status" };
54
+ return { type: "set-mode", mode: fallback === "off" ? "full" : fallback };
55
+ }
56
+
57
+ const [primary, secondary] = normalizedText.split(/\s+/);
58
+
59
+ if (primary === "status") return { type: "status" };
60
+
61
+ if (primary === "default") {
62
+ // ponytail: a default must be a runtime level; review is session-only (#377).
63
+ const mode = normalizeMode(secondary);
64
+ return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
65
+ }
66
+
67
+ const mode = normalizeMode(primary);
68
+ return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary };
69
+ }
70
+
71
+ export { writeDefaultMode };
72
+
73
+ export default function ponytailExtension(pi) {
74
+ let currentMode = DEFAULT_MODE;
75
+ let configuredDefaultMode = getDefaultMode();
76
+ let hideStatus = getHideStatus();
77
+ let isActive = false;
78
+ let lastCtx = null;
79
+
80
+ // -- Status bar --
81
+ function syncStatus(ctx) {
82
+ if (ctx) lastCtx = ctx;
83
+ const c = ctx || lastCtx;
84
+ // ponytail: hide the indicator but keep the ruleset active (#324).
85
+ if (hideStatus) return;
86
+ if (!c?.ui?.setStatus) return;
87
+ // ponytail: try/catch guards against pi-web theme proxy throwing before initTheme
88
+ let theme;
89
+ try { theme = c.ui.theme; if (!theme?.fg) return; } catch { return; }
90
+ if (currentMode === "off") {
91
+ c.ui.setStatus("ponytail", undefined);
92
+ return;
93
+ }
94
+ const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" };
95
+ const icon = levelIcons[currentMode] || "";
96
+ const label = currentMode.toUpperCase();
97
+ const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○");
98
+ c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label));
99
+ }
100
+
101
+ const setMode = (mode, ctx) => {
102
+ const normalized = normalizePersistedMode(mode);
103
+ if (!normalized) return;
104
+
105
+ currentMode = normalized;
106
+ pi.appendEntry("ponytail-mode", { mode: normalized });
107
+ syncStatus(ctx);
108
+ ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
109
+ };
110
+
111
+ const sendAlias = (skillName, args, ctx) => {
112
+ const normalized = String(args || "").trim();
113
+ const message = normalized ? `${skillName} ${normalized}` : skillName;
114
+
115
+ // pi.sendUserMessage does not expand skill commands on its own, so without
116
+ // expandPromptTemplates the alias lands as the literal text
117
+ // "/skill:ponytail-review" and the skill body never reaches the agent.
118
+ if (ctx?.isIdle?.() === false) {
119
+ pi.sendUserMessage(message, { expandPromptTemplates: true, deliverAs: "followUp" });
120
+ ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info");
121
+ return;
122
+ }
123
+
124
+ pi.sendUserMessage(message, { expandPromptTemplates: true });
125
+ };
126
+
127
+ pi.registerCommand("ponytail", {
128
+ description: PONYTAIL_COMMAND_DESCRIPTION,
129
+ getArgumentCompletions: (prefix) => completePonytailArgs(prefix),
130
+ handler: async (args, ctx) => {
131
+ const parsed = parsePonytailCommand(args, configuredDefaultMode, currentMode);
132
+
133
+ if (parsed.type === "status") {
134
+ ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info");
135
+ return;
136
+ }
137
+
138
+ if (parsed.type === "set-default") {
139
+ try {
140
+ const written = writeDefaultMode(parsed.mode);
141
+ if (written) {
142
+ configuredDefaultMode = getDefaultMode();
143
+ const message = configuredDefaultMode === written
144
+ ? `Default Ponytail mode set to ${written}.`
145
+ : `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
146
+ ctx?.ui?.notify?.(message, "info");
147
+ }
148
+ } catch (e) {
149
+ ctx?.ui?.notify?.(`Failed to save default mode: ${e.message}`, "error");
150
+ }
151
+ return;
152
+ }
153
+
154
+ if (parsed.type === "set-mode") {
155
+ setMode(parsed.mode, ctx);
156
+ return;
157
+ }
158
+
159
+ ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning");
160
+ },
161
+ });
162
+
163
+ pi.registerCommand("ponytail-review", {
164
+ description: "Run /skill:ponytail-review",
165
+ handler: (args, ctx) => sendAlias("/skill:ponytail-review", args, ctx),
166
+ });
167
+
168
+ pi.registerCommand("ponytail-audit", {
169
+ description: "Run /skill:ponytail-audit",
170
+ handler: (args, ctx) => sendAlias("/skill:ponytail-audit", args, ctx),
171
+ });
172
+
173
+ pi.registerCommand("ponytail-gain", {
174
+ description: "Run /skill:ponytail-gain",
175
+ handler: (args, ctx) => sendAlias("/skill:ponytail-gain", args, ctx),
176
+ });
177
+
178
+ pi.registerCommand("ponytail-debt", {
179
+ description: "Run /skill:ponytail-debt",
180
+ handler: (args, ctx) => sendAlias("/skill:ponytail-debt", args, ctx),
181
+ });
182
+
183
+ pi.registerCommand("ponytail-help", {
184
+ description: "Run /skill:ponytail-help",
185
+ handler: (args, ctx) => sendAlias("/skill:ponytail-help", args, ctx),
186
+ });
187
+
188
+ pi.on("input", async (event, ctx) => {
189
+ if (event?.source === "extension") return;
190
+
191
+ const text = String(event?.text || "");
192
+ if (currentMode !== "off" && isDeactivationCommand(text)) {
193
+ setMode("off", ctx);
194
+ }
195
+ });
196
+
197
+ const restoreSessionMode = (ctx) => {
198
+ const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
199
+ currentMode = resolveSessionMode(entries, configuredDefaultMode);
200
+ syncStatus(ctx);
201
+ };
202
+
203
+ pi.on("session_start", async (_event, ctx) => {
204
+ configuredDefaultMode = getDefaultMode();
205
+ hideStatus = getHideStatus();
206
+ restoreSessionMode(ctx);
207
+ if (!getQuietStartup()) {
208
+ ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info");
209
+ }
210
+ });
211
+
212
+ // Both hosts emit session_tree. Pi re-emits session_start on /new, /resume and
213
+ // /fork; OMP emits session_switch and session_branch instead.
214
+ for (const eventName of ["session_tree", "session_switch", "session_branch"]) {
215
+ pi.on(eventName, async (_event, ctx) => {
216
+ restoreSessionMode(ctx);
217
+ });
218
+ }
219
+
220
+ pi.on("agent_start", async (_event, ctx) => {
221
+ isActive = true;
222
+ syncStatus(ctx);
223
+ });
224
+
225
+ pi.on("agent_end", async (_event, ctx) => {
226
+ isActive = false;
227
+ syncStatus(ctx);
228
+ });
229
+
230
+ pi.on("before_agent_start", async (event) => {
231
+ if (!currentMode || currentMode === "off") {
232
+ // Clear a stale section when the mode is off so a previous turn does not linger (#953).
233
+ if (event?.systemPromptOptions?.sections) delete event.systemPromptOptions.sections.ponytail;
234
+ return;
235
+ }
236
+ const instructions = getPonytailInstructions(currentMode);
237
+ // Prefer structured sections so Pi can keep the cached prefix when other
238
+ // extensions change their own section (#953). Fall back to replacement
239
+ // for older Pi versions without systemPromptOptions.sections.
240
+ const sections = event?.systemPromptOptions?.sections;
241
+ if (sections && typeof sections === "object") {
242
+ sections.ponytail = instructions;
243
+ return;
244
+ }
245
+ // OMP passes the system prompt as an array of parts; keep it one (#776).
246
+ if (Array.isArray(event?.systemPrompt)) {
247
+ return { systemPrompt: [...event.systemPrompt, instructions] };
248
+ }
249
+ // Guard a null/undefined event or a missing systemPrompt: don't crash, and
250
+ // don't prepend the literal string "undefined" to the prompt (#439, #440).
251
+ const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
252
+ return { systemPrompt: `${base}${instructions}` };
253
+ });
254
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "ponytail-pi-extension-dev",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test ./test/*.test.js"
7
+ }
8
+ }
@@ -0,0 +1,367 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import ponytailExtension, { parsePonytailCommand } from "../index.js";
8
+
9
+ function createPiHarness() {
10
+ const events = new Map();
11
+ const commands = new Map();
12
+ const appendedEntries = [];
13
+ const sentUserMessages = [];
14
+
15
+ const pi = {
16
+ on(eventName, handler) {
17
+ events.set(eventName, handler);
18
+ },
19
+ registerCommand(name, options) {
20
+ commands.set(name, options);
21
+ },
22
+ appendEntry(customType, data) {
23
+ appendedEntries.push({ customType, data });
24
+ },
25
+ sendUserMessage(text, options) {
26
+ sentUserMessages.push({ text, options });
27
+ },
28
+ };
29
+
30
+ ponytailExtension(pi);
31
+ return { events, commands, appendedEntries, sentUserMessages };
32
+ }
33
+
34
+ function createCommandContext(overrides = {}) {
35
+ return {
36
+ isIdle: () => true,
37
+ sessionManager: { getEntries: () => [] },
38
+ ui: { notify() {} },
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ function withTempConfig(fn) {
44
+ const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-"));
45
+ const previousXdg = process.env.XDG_CONFIG_HOME;
46
+ const previousHide = process.env.PONYTAIL_HIDE_STATUS;
47
+ const previousDefault = process.env.PONYTAIL_DEFAULT_MODE;
48
+ process.env.XDG_CONFIG_HOME = tempConfigHome;
49
+ delete process.env.PONYTAIL_HIDE_STATUS;
50
+ delete process.env.PONYTAIL_DEFAULT_MODE;
51
+
52
+ return Promise.resolve()
53
+ .then(fn)
54
+ .finally(() => {
55
+ if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
56
+ else process.env.XDG_CONFIG_HOME = previousXdg;
57
+ if (previousHide === undefined) delete process.env.PONYTAIL_HIDE_STATUS;
58
+ else process.env.PONYTAIL_HIDE_STATUS = previousHide;
59
+ if (previousDefault === undefined) delete process.env.PONYTAIL_DEFAULT_MODE;
60
+ else process.env.PONYTAIL_DEFAULT_MODE = previousDefault;
61
+ rmSync(tempConfigHome, { recursive: true, force: true });
62
+ });
63
+ }
64
+
65
+ test("extension registers Ponytail commands", () => {
66
+ const { commands } = createPiHarness();
67
+
68
+ assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-audit", "ponytail-debt", "ponytail-gain", "ponytail-help", "ponytail-review"]);
69
+ });
70
+
71
+ test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => {
72
+ const { commands, events, appendedEntries } = createPiHarness();
73
+ const ctx = createCommandContext();
74
+
75
+ await events.get("session_start")({ reason: "startup" }, ctx);
76
+ await commands.get("ponytail").handler("ultra", ctx);
77
+
78
+ assert.deepEqual(appendedEntries.at(-1), {
79
+ customType: "ponytail-mode",
80
+ data: { mode: "ultra" },
81
+ });
82
+
83
+ const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
84
+ assert.ok(result.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
85
+ assert.ok(result.systemPrompt.includes("ultra"));
86
+ }));
87
+
88
+ test("before_agent_start guards missing event and missing systemPrompt (#439, #440)", async () => withTempConfig(async () => {
89
+ const { events } = createPiHarness();
90
+ const ctx = createCommandContext();
91
+ await events.get("session_start")({ reason: "startup" }, ctx); // currentMode -> default (full)
92
+
93
+ // #439: a null/undefined event must not crash, and still injects the ruleset.
94
+ for (const bad of [undefined, null]) {
95
+ const r = await events.get("before_agent_start")(bad, ctx);
96
+ assert.ok(r.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
97
+ assert.ok(!r.systemPrompt.includes("undefined"), "must not contain the literal 'undefined'");
98
+ }
99
+
100
+ // #440: an event without a systemPrompt must not prepend the literal "undefined".
101
+ const empty = await events.get("before_agent_start")({}, ctx);
102
+ assert.ok(empty.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
103
+ assert.ok(!empty.systemPrompt.startsWith("undefined"), "must not start with 'undefined'");
104
+
105
+ // A real base prompt is still preserved and prepended.
106
+ const withBase = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
107
+ assert.ok(withBase.systemPrompt.startsWith("BASE\n\n"));
108
+ assert.ok(withBase.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
109
+ }));
110
+
111
+ test("before_agent_start keeps OMP prompt parts and uses Pi sections (#776, #953)", async () => withTempConfig(async () => {
112
+ const { commands, events } = createPiHarness();
113
+ const ctx = createCommandContext();
114
+ await events.get("session_start")({ reason: "startup" }, ctx);
115
+
116
+ // OMP: systemPrompt is an array of parts; they must stay separate, not comma-joined.
117
+ const omp = await events.get("before_agent_start")({ systemPrompt: ["A", "B"] }, ctx);
118
+ assert.deepEqual(omp.systemPrompt.slice(0, 2), ["A", "B"]);
119
+ assert.match(omp.systemPrompt[2], /PONYTAIL MODE ACTIVE/);
120
+
121
+ // Pi >= 0.86: write a section and leave the prompt alone; off removes it again.
122
+ const event = { systemPrompt: "BASE", systemPromptOptions: { sections: {} } };
123
+ assert.equal(await events.get("before_agent_start")(event, ctx), undefined);
124
+ assert.match(event.systemPromptOptions.sections.ponytail, /PONYTAIL MODE ACTIVE/);
125
+ await commands.get("ponytail").handler("off", ctx);
126
+ await events.get("before_agent_start")(event, ctx);
127
+ assert.equal(event.systemPromptOptions.sections.ponytail, undefined);
128
+ }));
129
+
130
+ test("session_start restores latest persisted mode", async () => withTempConfig(async () => {
131
+ const { events } = createPiHarness();
132
+ const ctx = createCommandContext({
133
+ sessionManager: {
134
+ getEntries: () => [
135
+ { type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
136
+ ],
137
+ },
138
+ });
139
+
140
+ await events.get("session_start")({ reason: "resume" }, ctx);
141
+ const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
142
+
143
+ assert.ok(result.systemPrompt.includes("lite"));
144
+ }));
145
+
146
+ test("session_tree restores the selected branch's mode without persisting it", async () => withTempConfig(async () => {
147
+ const { events, commands, appendedEntries } = createPiHarness();
148
+ const lite = { type: "custom", customType: "ponytail-mode", data: { mode: "lite" } };
149
+ const off = { type: "custom", customType: "ponytail-mode", data: { mode: "off" } };
150
+ let branch = [lite, off];
151
+ const statusWrites = [];
152
+ const notifications = [];
153
+ const ctx = createCommandContext({
154
+ sessionManager: { getBranch: () => branch, getEntries: () => [lite, off] },
155
+ ui: {
156
+ notify: (text) => notifications.push(text),
157
+ setStatus: (_key, text) => statusWrites.push(text),
158
+ theme: { fg: (_color, text) => text },
159
+ },
160
+ });
161
+
162
+ await events.get("session_start")({ reason: "resume" }, ctx);
163
+ await commands.get("ponytail").handler("default ultra", ctx);
164
+ assert.equal(await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx), undefined);
165
+ const notificationCount = notifications.length;
166
+
167
+ for (const [entries, mode] of [[[lite], "lite"], [[lite, off], "off"], [[], "ultra"]]) {
168
+ branch = entries;
169
+ await events.get("session_tree")?.({ type: "session_tree" }, ctx);
170
+ const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
171
+ if (mode === "off") {
172
+ assert.equal(result, undefined);
173
+ assert.equal(statusWrites.at(-1), undefined);
174
+ } else {
175
+ assert.ok(result?.systemPrompt.startsWith(`BASE\n\nPONYTAIL MODE ACTIVE — level: ${mode}\n`), `expected ${mode} instructions`);
176
+ assert.ok(statusWrites.at(-1).includes(mode.toUpperCase()));
177
+ }
178
+ }
179
+
180
+ assert.deepEqual(appendedEntries, [], "navigation must not append a new mode entry");
181
+ assert.equal(notifications.length, notificationCount, "navigation must not repeat the startup toast");
182
+ }));
183
+
184
+ test("OMP session_switch and session_branch restore the target session's mode", async () => withTempConfig(async () => {
185
+ // OMP keeps the legacy Pi events instead of re-emitting session_start on /new, /resume, /fork and /branch.
186
+ const { events, appendedEntries } = createPiHarness();
187
+ const lite = { type: "custom", customType: "ponytail-mode", data: { mode: "lite" } };
188
+ const off = { type: "custom", customType: "ponytail-mode", data: { mode: "off" } };
189
+ let branch = [off];
190
+ const ctx = createCommandContext({ sessionManager: { getBranch: () => branch } });
191
+
192
+ await events.get("session_start")({ type: "session_start" }, ctx);
193
+ for (const [type, entries, mode] of [["session_switch", [lite], "lite"], ["session_branch", [off], "off"], ["session_switch", [], "full"]]) {
194
+ branch = entries;
195
+ await events.get(type)?.({ type }, ctx);
196
+ const result = await events.get("before_agent_start")({ systemPrompt: ["BASE"] }, ctx);
197
+ if (mode === "off") assert.equal(result, undefined, `${type} should restore off`);
198
+ else assert.ok(result?.systemPrompt.at(-1).startsWith(`PONYTAIL MODE ACTIVE — level: ${mode}\n`), `${type} should restore ${mode}`);
199
+ }
200
+ assert.deepEqual(appendedEntries, [], "restoring must not append a new mode entry");
201
+ }));
202
+
203
+ test("skill alias commands delegate to Pi skill commands", async () => {
204
+ const { commands, sentUserMessages } = createPiHarness();
205
+ const ctx = createCommandContext();
206
+
207
+ await commands.get("ponytail-review").handler("", ctx);
208
+ await commands.get("ponytail-audit").handler("", ctx);
209
+ await commands.get("ponytail-debt").handler("", ctx);
210
+ await commands.get("ponytail-gain").handler("", ctx);
211
+ await commands.get("ponytail-help").handler("", ctx);
212
+
213
+ assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
214
+ "/skill:ponytail-review",
215
+ "/skill:ponytail-audit",
216
+ "/skill:ponytail-debt",
217
+ "/skill:ponytail-gain",
218
+ "/skill:ponytail-help",
219
+ ]);
220
+ });
221
+
222
+ test("skill alias commands expand the skill and forward their arguments", async () => {
223
+ const { commands, sentUserMessages } = createPiHarness();
224
+
225
+ await commands.get("ponytail-review").handler("staged", createCommandContext());
226
+ await commands.get("ponytail-audit").handler("src/api", createCommandContext({ isIdle: () => false }));
227
+
228
+ assert.deepEqual(sentUserMessages, [
229
+ { text: "/skill:ponytail-review staged", options: { expandPromptTemplates: true } },
230
+ { text: "/skill:ponytail-audit src/api", options: { expandPromptTemplates: true, deliverAs: "followUp" } },
231
+ ]);
232
+ });
233
+
234
+ test("normal mode disables persistent instructions", async () => withTempConfig(async () => {
235
+ const { commands, events } = createPiHarness();
236
+ const ctx = createCommandContext();
237
+
238
+ await events.get("session_start")({ reason: "startup" }, ctx);
239
+ await commands.get("ponytail").handler("ultra", ctx);
240
+ await events.get("input")({ text: "normal mode", source: "interactive" }, ctx);
241
+
242
+ const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
243
+ assert.equal(disabled, undefined);
244
+ }));
245
+
246
+ test("a request mentioning normal mode stays active", async () => withTempConfig(async () => {
247
+ const { commands, events } = createPiHarness();
248
+ const ctx = createCommandContext();
249
+
250
+ await events.get("session_start")({ reason: "startup" }, ctx);
251
+ await commands.get("ponytail").handler("ultra", ctx);
252
+ await events.get("input")({ text: "add a normal mode toggle next to dark mode", source: "interactive" }, ctx);
253
+
254
+ const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
255
+ assert.match(result.systemPrompt, /PONYTAIL MODE ACTIVE/);
256
+ }));
257
+
258
+ test("status bar renders the mode and flips active on agent_start", async () => withTempConfig(async () => {
259
+ const { events } = createPiHarness();
260
+ const statusWrites = [];
261
+ const ctx = createCommandContext({
262
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
263
+ ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_color, text) => text } },
264
+ });
265
+
266
+ await events.get("session_start")({ reason: "resume" }, ctx);
267
+ await events.get("agent_start")({}, ctx);
268
+
269
+ assert.equal(statusWrites.at(-2).key, "ponytail");
270
+ assert.match(statusWrites.at(-2).text, /○.*ULTRA/);
271
+ assert.match(statusWrites.at(-1).text, /●.*ULTRA/);
272
+ }));
273
+
274
+ test("off mode clears the status bar entry", async () => withTempConfig(async () => {
275
+ const { events } = createPiHarness();
276
+ const statusWrites = [];
277
+ const ctx = createCommandContext({
278
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "off" } }] },
279
+ ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_color, text) => text } },
280
+ });
281
+
282
+ await events.get("session_start")({ reason: "resume" }, ctx);
283
+
284
+ assert.deepEqual(statusWrites.at(-1), { key: "ponytail", text: undefined });
285
+ }));
286
+
287
+ test("status bar stays silent when ui lacks a theme", async () => withTempConfig(async () => {
288
+ const { events } = createPiHarness();
289
+ const calls = [];
290
+ const ctx = createCommandContext({
291
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
292
+ ui: { notify() {}, setStatus: (_key, text) => calls.push(text) }, // setStatus present, theme absent
293
+ });
294
+
295
+ await events.get("session_start")({ reason: "resume" }, ctx);
296
+ await events.get("agent_start")({}, ctx);
297
+
298
+ assert.deepEqual(calls, []);
299
+ }));
300
+
301
+ test("PONYTAIL_HIDE_STATUS hides the indicator but keeps ponytail active (#324)", async () => withTempConfig(async () => {
302
+ process.env.PONYTAIL_HIDE_STATUS = "1";
303
+ const { events } = createPiHarness();
304
+ const statusWrites = [];
305
+ const ctx = createCommandContext({
306
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
307
+ ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
308
+ });
309
+
310
+ await events.get("session_start")({ reason: "resume" }, ctx);
311
+ await events.get("agent_start")({}, ctx);
312
+ const injected = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
313
+
314
+ assert.deepEqual(statusWrites, [], "status bar must not be drawn when hidden");
315
+ assert.match(injected.systemPrompt, /PONYTAIL MODE ACTIVE/, "ruleset must still inject while status is hidden");
316
+ }));
317
+
318
+ test("config.hideStatus hides the indicator but keeps ponytail active (#324)", async () => withTempConfig(async () => {
319
+ mkdirSync(join(process.env.XDG_CONFIG_HOME, "ponytail"), { recursive: true });
320
+ writeFileSync(join(process.env.XDG_CONFIG_HOME, "ponytail", "config.json"), JSON.stringify({ hideStatus: true }));
321
+ const { events } = createPiHarness();
322
+ const statusWrites = [];
323
+ const ctx = createCommandContext({
324
+ ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
325
+ });
326
+
327
+ await events.get("session_start")({ reason: "startup" }, ctx);
328
+ await events.get("agent_start")({}, ctx);
329
+ const injected = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
330
+
331
+ assert.deepEqual(statusWrites, [], "config.hideStatus must suppress the status bar");
332
+ assert.match(injected.systemPrompt, /PONYTAIL MODE ACTIVE/, "ruleset must still inject while status is hidden");
333
+ }));
334
+
335
+ test("PONYTAIL_HIDE_STATUS=0 does not hide the indicator", async () => withTempConfig(async () => {
336
+ process.env.PONYTAIL_HIDE_STATUS = "0";
337
+ const { events } = createPiHarness();
338
+ const statusWrites = [];
339
+ const ctx = createCommandContext({
340
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
341
+ ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
342
+ });
343
+
344
+ await events.get("session_start")({ reason: "resume" }, ctx);
345
+ await events.get("agent_start")({}, ctx);
346
+
347
+ assert.ok(statusWrites.length > 0, "0 must be treated as 'do not hide'");
348
+ }));
349
+
350
+ test("/ponytail autocompletes modes, commands and default <mode>", () => {
351
+ const { commands } = createPiHarness();
352
+ const complete = (prefix) => commands.get("ponytail").getArgumentCompletions(prefix);
353
+
354
+ assert.deepEqual(complete("").map((item) => item.value),
355
+ ["off", "lite", "full", "ultra", "status", "default off", "default lite", "default full", "default ultra"]);
356
+ assert.deepEqual(complete("fu").map((item) => item.value), ["full"]);
357
+ assert.deepEqual(complete("default ").map((item) => item.value), ["default off", "default lite", "default full", "default ultra"]);
358
+ assert.deepEqual(complete("default l").map((item) => item.value), ["default lite"]);
359
+ assert.deepEqual(complete("default").map((item) => item.value),
360
+ ["default off", "default lite", "default full", "default ultra"], "never a bare default: Enter would submit it without a mode");
361
+ assert.equal(complete("status x"), null, "no command takes a second word after status");
362
+ assert.equal(complete("lite x"), null);
363
+ assert.equal(complete("bogus"), null, "nothing matches an unknown prefix");
364
+ for (const { value } of complete("")) {
365
+ assert.notEqual(parsePonytailCommand(value).type, "invalid", `Enter on "${value}" must run a valid command`);
366
+ }
367
+ });