Skip to content

Commit 40e604a

Browse files
author
0027007744
committed
fix(zcode): re-add ZCode support with prompt-unwrap and resume-keep fixes
#1040 dropped ZCode because it could not be tested. Field debugging on ZCode 3.x (Windows) found the two behaviors that made it look broken, both now fixed and live-verified: - ZCode renders a slash command as a markdown link, so UserPromptSubmit prompts arrive as [$ponytail](path) args; the mode tracker now unwraps that form. Bare /ponytail, @Ponytail and $ponytail pass through unchanged. - ZCode resumes its runtime right after a slash-command prompt, firing SessionStart(resume) that reset a just-switched level to the default within seconds; activate now reads the hook payload's source and, on ZCode only, keeps the live level across resume/compact. Other hosts keep their long-standing reset behavior. Restores the isZcode detection, the strict-JSON output branch (#798), the hook test and the INSTALL/portability rows removed by #1040.
1 parent 552acd5 commit 40e604a

7 files changed

Lines changed: 324 additions & 123 deletions

File tree

‎INSTALL.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ Start a new session (or reload plugins). Skills show as `/ponytail`, `/ponytail-
167167

168168
`AGENTS.md` still works instruction-only from a checkout without the plugin.
169169

170+
## ZCode
171+
172+
ZCode loads Claude Code plugins. Open **Settings → Plugins**, choose **Create → Add marketplace**, enter `DietrichGebert/ponytail`, then install ponytail from the Personal tab. The ruleset arrives at session start and `/ponytail` level switches work, like in Claude Code (needs ponytail 4.10.3 or later and `node` on your PATH). ZCode has no `SubagentStart` event, so subagents run without the ruleset.
173+
170174
## Goose
171175

172176
```bash

‎docs/agent-portability.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ to load in a given agent.
1212
| Codex | `.codex-plugin/plugin.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. |
1313
| CodeBuddy | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | CodeBuddy Code reads the Claude-format manifests when no `.codebuddy-plugin/` exists and expands `${CLAUDE_PLUGIN_ROOT}` in hook commands, so `/plugin marketplace add DietrichGebert/ponytail` + `/plugin install ponytail@ponytail` installs the Claude plugin unchanged. The hooks detect it by `CODEBUDDY_PLUGIN_ROOT`, keep the mode flag in `$CODEBUDDY_CONFIG_DIR` or `~/.codebuddy`, and answer in `hookSpecificOutput` JSON. No subagent injection: CodeBuddy discards `SubagentStart` hook output. Verified with CodeBuddy CLI 2.161.2. |
1414
| Grok Build | root `plugin.json`, `.grok-plugin/marketplace.json`, `skills/`, `commands/` | `grok plugin install DietrichGebert/ponytail --trust`, then enable. Grok can auto-invoke ponytail from its coding-task skill description; `/ponytail` makes activation explicit. Grok lifecycle hooks are not used because passive hook output cannot inject instructions. |
15+
| ZCode | `.claude-plugin/plugin.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | ZCode loads Claude Code plugins: **Settings → Plugins → Create → Add marketplace** → `DietrichGebert/ponytail`, then install ponytail. The hooks answer in ZCode's strict JSON shape (detected via `ZCODE_APP_VERSION`, #799), so the ruleset arrives at session start and `/ponytail` switches land. ZCode renders `/ponytail` prompts as markdown links and resumes its runtime right after a slash-command prompt; the hooks unwrap the link and keep the switched level across that resume. ZCode has no `SubagentStart` event, so subagents run without the ruleset; the statusline nudge is skipped. Confirmed by users on ZCode 3.14.4 with ponytail 4.10.3 (#798). |
1516
| Goose | `plugin.json`, `skills/`, `AGENTS.md` | `goose plugin install https://github.com/DietrichGebert/ponytail.git` clones the repo into `~/.agents/plugins/ponytail/` and imports the six skills as `ponytail:<skill>`. Always-on rules come from `AGENTS.md`: goose reads it from the project (git root down to the working directory) and from `~/.agents/AGENTS.md`, plus `.goosehints` from `~/.config/goose/`. No lifecycle hooks, so no persistent `/ponytail` levels. Verified with Goose 1.53.0. |
1617
| Muse Code (Meta) | `AGENTS.md`, `skills/` | Muse reads `AGENTS.md` (or `CLAUDE.md`) in the project once the workspace is trusted, and `~/.config/muse/AGENTS.md` for every project. Skills: copy the folders from `skills/` to `~/.agents/skills/`, or run `muse skills install ./skills/<name> --scope user` per skill. Instruction-tier, no `/ponytail` levels or hooks. Verified with Muse Code 1.4.2. |
1718
| OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. Installs globally with `opencode plugin add @dietrichgebert/ponytail` (OpenCode writes the `plugins` entry itself), or per project through the `plugins` array in `opencode.json`. |

‎hooks/ponytail-activate.js‎

Lines changed: 164 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -19,144 +19,187 @@ const {
1919
isCodex,
2020
isCopilot,
2121
isCursor,
22+
isZcode,
23+
readMode,
2224
setMode,
2325
writeHookOutput,
2426
} = require('./ponytail-runtime');
2527

26-
const claudeDir = getClaudeDir();
27-
const settingsPath = path.join(claudeDir, 'settings.json');
28+
let input = '';
29+
let done = false;
2830

29-
const mode = getDefaultMode();
31+
function finish() {
32+
if (done) return;
33+
done = true;
3034

31-
// "off" mode — skip activation entirely, don't write flag or emit rules
32-
if (mode === 'off') {
33-
clearMode();
34-
process.exit(0);
35-
}
35+
// A resumed/compacted session keeps the level the user switched to; a fresh
36+
// start (or a missing flag) initializes from the default. ZCode resumes the
37+
// runtime right after a slash-command prompt, so an unconditional reset here
38+
// erased a just-switched level within seconds. Other hosts only resume
39+
// between sessions, where resetting to the default is long-standing
40+
// behavior — keep this fix scoped to ZCode (#1040 follow-up).
41+
let source = 'startup';
42+
try {
43+
const data = JSON.parse(input.replace(/^\uFEFF/, ''));
44+
if (data && typeof data.source === 'string') source = data.source;
45+
} catch (e) { /* no/invalid stdin — treat as a fresh start */ }
3646

37-
// Cursor with the always-on rule in the workspace: the rule already carries the
38-
// ruleset and would contradict any other level, so leave the flag alone and
39-
// hand the model a one-line notice instead of a second copy (#817).
40-
if (isCursor) {
41-
const rule = cursorRulePath();
42-
if (rule) {
43-
try {
44-
writeHookOutput('SessionStart', mode, cursorRuleNotice(rule));
45-
} catch (e) {
46-
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
47-
}
48-
process.exit(0);
47+
const claudeDir = getClaudeDir();
48+
const settingsPath = path.join(claudeDir, 'settings.json');
49+
50+
let mode = getDefaultMode();
51+
if ((source === 'resume' || source === 'compact') && isZcode) {
52+
const live = readMode();
53+
if (live && live !== 'off') mode = live;
4954
}
50-
}
5155

52-
// 1. Write flag file
53-
try {
54-
setMode(mode);
55-
} catch (e) {
56-
// Silent fail -- flag is best-effort, don't block the hook
57-
}
56+
// "off" mode — skip activation entirely, don't write flag or emit rules
57+
if (mode === 'off') {
58+
clearMode();
59+
process.exit(0);
60+
}
5861

59-
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
60-
let output = getPonytailInstructions(mode);
61-
62-
// 3. Detect missing statusline config — nudge Claude to help set it up
63-
if (!isCodex && !isCopilot && !isCursor && !isCodeBuddy) try {
64-
const isWindows = process.platform === 'win32';
65-
let statusCommand = null;
66-
if (fs.existsSync(settingsPath)) {
67-
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
68-
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
69-
const settings = JSON.parse(raw);
70-
if (settings.statusLine) {
71-
statusCommand = String(settings.statusLine.command || '');
62+
// Cursor with the always-on rule in the workspace: the rule already carries the
63+
// ruleset and would contradict any other level, so leave the flag alone and
64+
// hand the model a one-line notice instead of a second copy (#817).
65+
if (isCursor) {
66+
const rule = cursorRulePath();
67+
if (rule) {
68+
try {
69+
writeHookOutput('SessionStart', mode, cursorRuleNotice(rule));
70+
} catch (e) {
71+
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
72+
}
73+
process.exit(0);
7274
}
7375
}
7476

75-
// A statusLine already set up before #1032 can still run a script from a
76-
// plugin version that has since been deleted. Only absolute paths are checked,
77-
// so ~ or $HOME forms are never mistaken for missing files.
78-
const ref = statusCommand &&
79-
statusCommand.match(/"([^"]*ponytail-statusline\.(?:sh|ps1))"|(\S*ponytail-statusline\.(?:sh|ps1))/);
80-
const refPath = ref ? (ref[1] || ref[2]) : null;
81-
// isShellSafe keeps quotes, newlines and shell metacharacters from
82-
// settings.json out of the model context.
83-
// On Windows only drive-letter or UNC paths count: a Git Bash path such as
84-
// /c/Users/... is absolute to Node but does not resolve, so it would be
85-
// flagged as broken while it works.
86-
const checkable = refPath && isShellSafe(refPath) && path.isAbsolute(refPath) &&
87-
(!isWindows || /^([A-Za-z]:[\\/]|\\\\)/.test(refPath));
88-
const stalePath = checkable && !fs.existsSync(refPath) ? refPath : null;
89-
90-
// The plugin root is a versioned cache dir (.../ponytail/4.12.0/) that Claude
91-
// Code deletes on update, so a statusLine pointing into it goes blank after the
92-
// next update (#1032). Point it at a copy in the config dir instead. A statusLine
93-
// that already runs ponytail keeps its script type, so its copy stays fresh.
94-
const usePs1 = refPath ? refPath.endsWith('.ps1') : isWindows;
95-
const scriptName = usePs1 ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh';
96-
const scriptPath = path.join(claudeDir, scriptName);
97-
98-
// Nudge at most once — the flag file marks that the user has already seen
99-
// (and implicitly declined) the statusline setup offer. Repeating it every
100-
// session start turns a helpful hint into a nag. A broken path is nudged once
101-
// per path: the flag records it.
102-
const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged');
103-
let nudged = null;
104-
try { nudged = fs.readFileSync(nudgeFlagPath, 'utf8'); } catch (e) { /* not nudged yet */ }
105-
const nudge = stalePath ? nudged !== stalePath : statusCommand === null && nudged === null;
106-
107-
// Refresh the copy every session so script fixes ship with plugin updates.
108-
// Copy to a new temp file, then rename: the rename replaces a symlink at
109-
// scriptPath instead of writing through it, and a concurrent session never
110-
// runs a half-written script.
111-
if (nudge || fs.existsSync(scriptPath)) {
112-
const tmpPath = scriptPath + '.' + process.pid + '.tmp';
113-
try {
114-
fs.copyFileSync(path.join(__dirname, scriptName), tmpPath, fs.constants.COPYFILE_EXCL);
115-
fs.chmodSync(tmpPath, 0o644);
116-
fs.renameSync(tmpPath, scriptPath);
117-
} finally {
118-
try { fs.unlinkSync(tmpPath); } catch (e) { /* renamed */ }
119-
}
77+
// 1. Write flag file
78+
try {
79+
setMode(mode);
80+
} catch (e) {
81+
// Silent fail -- flag is best-effort, don't block the hook
12082
}
12183

122-
if (nudge) {
123-
try { fs.writeFileSync(nudgeFlagPath, stalePath || ''); } catch (e) { /* best-effort */ }
124-
if (stalePath) {
125-
output += "\n\n" +
126-
"STATUSLINE BROKEN: The statusLine in " + settingsPath + " runs " + stalePath +
127-
", which no longer exists (the ponytail plugin was updated and its old version removed), " +
128-
"so the ponytail badge is blank. Replace that path with " + scriptPath + ", which survives updates, " +
129-
"quoting it for your shell. Keep the rest of the command. " +
130-
"Proactively offer to fix this for the user on first interaction.";
131-
} else if (isShellSafe(scriptPath)) {
132-
const command = isWindows
133-
? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
134-
: `bash "${scriptPath}"`;
135-
const statusLineSnippet =
136-
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
137-
output += "\n\n" +
138-
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " +
139-
"(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " +
140-
"To enable, add this to " + settingsPath + ": " +
141-
statusLineSnippet + " " +
142-
"Proactively offer to set this up for the user on first interaction.";
143-
} else {
144-
// ponytail: config dir path has shell metacharacters; don't embed it in a
145-
// command snippet; have the agent wire it up by hand instead.
146-
output += "\n\n" +
147-
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " +
148-
"Its path contains characters unsafe to embed in a shell command, so configure it manually: " +
149-
"add a statusLine command of type \"command\" that runs " + scriptName +
150-
" from " + claudeDir + " to " + settingsPath + ", quoting/escaping the path for your shell. " +
151-
"Proactively offer to set this up for the user on first interaction.";
84+
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
85+
let output = getPonytailInstructions(mode);
86+
87+
// 3. Detect missing statusline config — nudge Claude to help set it up
88+
// Skipped on ZCode: its statusline configuration story is unverified, and a
89+
// wrong pointer at Claude's settings.json would just mislead the agent.
90+
if (!isCodex && !isCopilot && !isCursor && !isZcode && !isCodeBuddy) try {
91+
const isWindows = process.platform === 'win32';
92+
let statusCommand = null;
93+
if (fs.existsSync(settingsPath)) {
94+
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
95+
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
96+
const settings = JSON.parse(raw);
97+
if (settings.statusLine) {
98+
statusCommand = String(settings.statusLine.command || '');
99+
}
152100
}
101+
102+
// A statusLine already set up before #1032 can still run a script from a
103+
// plugin version that has since been deleted. Only absolute paths are checked,
104+
// so ~ or $HOME forms are never mistaken for missing files.
105+
const ref = statusCommand &&
106+
statusCommand.match(/"([^"]*ponytail-statusline\.(?:sh|ps1))"|(\S*ponytail-statusline\.(?:sh|ps1))/);
107+
const refPath = ref ? (ref[1] || ref[2]) : null;
108+
// isShellSafe keeps quotes, newlines and shell metacharacters from
109+
// settings.json out of the model context.
110+
// On Windows only drive-letter or UNC paths count: a Git Bash path such as
111+
// /c/Users/... is absolute to Node but does not resolve, so it would be
112+
// flagged as broken while it works.
113+
const checkable = refPath && isShellSafe(refPath) && path.isAbsolute(refPath) &&
114+
(!isWindows || /^([A-Za-z]:[\\/]|\\\\)/.test(refPath));
115+
const stalePath = checkable && !fs.existsSync(refPath) ? refPath : null;
116+
117+
// The plugin root is a versioned cache dir (.../ponytail/4.12.0/) that Claude
118+
// Code deletes on update, so a statusLine pointing into it goes blank after the
119+
// next update (#1032). Point it at a copy in the config dir instead. A statusLine
120+
// that already runs ponytail keeps its script type, so its copy stays fresh.
121+
const usePs1 = refPath ? refPath.endsWith('.ps1') : isWindows;
122+
const scriptName = usePs1 ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh';
123+
const scriptPath = path.join(claudeDir, scriptName);
124+
125+
// Nudge at most once — the flag file marks that the user has already seen
126+
// (and implicitly declined) the statusline setup offer. Repeating it every
127+
// session start turns a helpful hint into a nag. A broken path is nudged once
128+
// per path: the flag records it.
129+
const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged');
130+
let nudged = null;
131+
try { nudged = fs.readFileSync(nudgeFlagPath, 'utf8'); } catch (e) { /* not nudged yet */ }
132+
const nudge = stalePath ? nudged !== stalePath : statusCommand === null && nudged === null;
133+
134+
// Refresh the copy every session so script fixes ship with plugin updates.
135+
// Copy to a new temp file, then rename: the rename replaces a symlink at
136+
// scriptPath instead of writing through it, and a concurrent session never
137+
// runs a half-written script.
138+
if (nudge || fs.existsSync(scriptPath)) {
139+
const tmpPath = scriptPath + '.' + process.pid + '.tmp';
140+
try {
141+
fs.copyFileSync(path.join(__dirname, scriptName), tmpPath, fs.constants.COPYFILE_EXCL);
142+
fs.chmodSync(tmpPath, 0o644);
143+
fs.renameSync(tmpPath, scriptPath);
144+
} finally {
145+
try { fs.unlinkSync(tmpPath); } catch (e) { /* renamed */ }
146+
}
147+
}
148+
149+
if (nudge) {
150+
try { fs.writeFileSync(nudgeFlagPath, stalePath || ''); } catch (e) { /* best-effort */ }
151+
if (stalePath) {
152+
output += "\n\n" +
153+
"STATUSLINE BROKEN: The statusLine in " + settingsPath + " runs " + stalePath +
154+
", which no longer exists (the ponytail plugin was updated and its old version removed), " +
155+
"so the ponytail badge is blank. Replace that path with " + scriptPath + ", which survives updates, " +
156+
"quoting it for your shell. Keep the rest of the command. " +
157+
"Proactively offer to fix this for the user on first interaction.";
158+
} else if (isShellSafe(scriptPath)) {
159+
const command = isWindows
160+
? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
161+
: `bash "${scriptPath}"`;
162+
const statusLineSnippet =
163+
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
164+
output += "\n\n" +
165+
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " +
166+
"(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " +
167+
"To enable, add this to " + settingsPath + ": " +
168+
statusLineSnippet + " " +
169+
"Proactively offer to set this up for the user on first interaction.";
170+
} else {
171+
// ponytail: config dir path has shell metacharacters; don't embed it in a
172+
// command snippet; have the agent wire it up by hand instead.
173+
output += "\n\n" +
174+
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " +
175+
"Its path contains characters unsafe to embed in a shell command, so configure it manually: " +
176+
"add a statusLine command of type \"command\" that runs " + scriptName +
177+
" from " + claudeDir + " to " + settingsPath + ", quoting/escaping the path for your shell. " +
178+
"Proactively offer to set this up for the user on first interaction.";
179+
}
180+
}
181+
} catch (e) {
182+
// Silent fail — don't block session start over statusline detection
183+
}
184+
185+
try {
186+
writeHookOutput('SessionStart', mode, output);
187+
} catch (e) {
188+
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
153189
}
154-
} catch (e) {
155-
// Silent fail — don't block session start over statusline detection
156190
}
157191

158-
try {
159-
writeHookOutput('SessionStart', mode, output);
160-
} catch (e) {
161-
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
192+
process.stdin.on('data', chunk => { input += chunk; });
193+
// Same never-hang contract as the mode tracker: exit on 'end', and after a
194+
// short ref'd fallback for the Windows case where piped stdin never reaches
195+
// EOF (#443, #790). One addition: exit only after stdout has flushed —
196+
// process.exit can truncate the large raw-text ruleset write on Windows
197+
// pipes mid-output, cutting off the statusline nudge at the tail.
198+
function finishAndExit() {
199+
finish();
200+
process.stdout.write('', () => process.exit(0));
201+
setTimeout(() => process.exit(0), 250).unref();
162202
}
203+
process.stdin.on('end', finishAndExit);
204+
process.stdin.on('error', finishAndExit);
205+
setTimeout(finishAndExit, 1000);

‎hooks/ponytail-mode-tracker.js‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
isCodex,
1111
isCursor,
1212
isQoder,
13+
isZcode,
1314
readMode,
1415
setMode,
1516
writeHookOutput,
@@ -25,7 +26,15 @@ function finish() {
2526
try {
2627
// Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
2728
const data = JSON.parse(input.replace(/^\uFEFF/, ''));
28-
const prompt = (data.prompt || '').trim().toLowerCase();
29+
// ZCode renders a slash command as a markdown link ([$ponytail](path)
30+
// args); unwrap it so the /^[/@$]ponytail/ match below sees the bare
31+
// command. Gated on ZCode: a [ponytail](url) link pasted at the start of
32+
// an ordinary prompt must not switch the level on other hosts. Bare
33+
// /ponytail, @ponytail and $ponytail forms pass through everywhere.
34+
let prompt = (data.prompt || '').trim().toLowerCase();
35+
if (isZcode) {
36+
prompt = prompt.replace(/^\[([/@$]?)(ponytail[^\]]*)\]\([^)]*\)/, '/$2');
37+
}
2938

3039
// Cursor with the always-on rule in the workspace: no hook can change or
3140
// switch off a rule, so answer the command with the notice instead of

0 commit comments

Comments
 (0)