@herbertgao/pi-extensions 2026.9.4 → 2026.9.6

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 (146) hide show
  1. package/README.md +3 -15
  2. package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +1 -1
  3. package/node_modules/@dietrichgebert/ponytail/README.es.md +33 -2
  4. package/node_modules/@dietrichgebert/ponytail/README.ko.md +33 -2
  5. package/node_modules/@dietrichgebert/ponytail/README.md +49 -5
  6. package/node_modules/@dietrichgebert/ponytail/assets/retriever-icon.png +0 -0
  7. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-dark.svg +4 -0
  8. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-light.svg +4 -0
  9. package/node_modules/@dietrichgebert/ponytail/hooks/claude-codex-hooks.json +0 -3
  10. package/node_modules/@dietrichgebert/ponytail/hooks/cursor-hooks.json +17 -0
  11. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +22 -3
  12. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-mode-tracker.js +27 -2
  13. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-runtime.js +61 -2
  14. package/node_modules/@dietrichgebert/ponytail/package.json +2 -1
  15. package/node_modules/@dietrichgebert/ponytail/scripts/cursor-hooks.js +135 -0
  16. package/node_modules/@dietrichgebert/ponytail/scripts/uninstall.js +21 -4
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/index.ts +33 -15
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +6 -2
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/scroll.ts +23 -1
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +69 -14
  21. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  22. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +1 -1
  23. package/node_modules/@herbertgao/sol-pi/package.json +1 -1
  24. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/action-fusion/index.ts +0 -5
  25. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/economics.ts +4 -4
  26. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +41 -1
  27. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/state.ts +3 -1
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +5 -1
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/events.ts +5 -1
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
  31. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/normalize-params.ts +56 -0
  32. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/option-list-view.ts +1 -1
  33. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/tab-bar.ts +1 -2
  34. package/node_modules/@pi-plugins/fast-mode/README.md +31 -44
  35. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20914 -30
  36. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  37. package/node_modules/@pi-plugins/fast-mode/package.json +5 -7
  38. package/node_modules/@tifan/pi-copy-response/README.md +2 -2
  39. package/node_modules/@tifan/pi-copy-response/package.json +3 -2
  40. package/node_modules/@tifan/pi-inline-skills/README.md +2 -2
  41. package/node_modules/@tifan/pi-inline-skills/package.json +3 -2
  42. package/node_modules/@tifan/pi-mermaid-open/README.md +1 -1
  43. package/node_modules/@tifan/pi-mermaid-open/package.json +3 -2
  44. package/node_modules/@tifan/pi-preferred-thinking/package.json +3 -2
  45. package/node_modules/@tifan/pi-recap/package.json +3 -2
  46. package/node_modules/pi-mcp-adapter/CHANGELOG.md +78 -0
  47. package/node_modules/pi-mcp-adapter/README.md +125 -19
  48. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +69 -30
  49. package/node_modules/pi-mcp-adapter/agent-plugin-provenance.ts +36 -0
  50. package/node_modules/pi-mcp-adapter/claude-plugin-loader.ts +365 -0
  51. package/node_modules/pi-mcp-adapter/commands.ts +13 -15
  52. package/node_modules/pi-mcp-adapter/config.ts +198 -30
  53. package/node_modules/pi-mcp-adapter/consent-manager.ts +40 -9
  54. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +250 -0
  55. package/node_modules/pi-mcp-adapter/direct-tools.ts +9 -228
  56. package/node_modules/pi-mcp-adapter/dist/agent-plugin-loader.js +74 -32
  57. package/node_modules/pi-mcp-adapter/dist/agent-plugin-loader.js.map +1 -1
  58. package/node_modules/pi-mcp-adapter/dist/agent-plugin-provenance.d.ts +1 -0
  59. package/node_modules/pi-mcp-adapter/dist/agent-plugin-provenance.js +31 -0
  60. package/node_modules/pi-mcp-adapter/dist/agent-plugin-provenance.js.map +1 -0
  61. package/node_modules/pi-mcp-adapter/dist/claude-plugin-loader.d.ts +9 -0
  62. package/node_modules/pi-mcp-adapter/dist/claude-plugin-loader.js +362 -0
  63. package/node_modules/pi-mcp-adapter/dist/claude-plugin-loader.js.map +1 -0
  64. package/node_modules/pi-mcp-adapter/dist/config.d.ts +3 -1
  65. package/node_modules/pi-mcp-adapter/dist/config.js +189 -27
  66. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  67. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +7 -16
  68. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  69. package/node_modules/pi-mcp-adapter/dist/package-mcp-loader.js +5 -17
  70. package/node_modules/pi-mcp-adapter/dist/package-mcp-loader.js.map +1 -1
  71. package/node_modules/pi-mcp-adapter/dist/types.d.ts +30 -4
  72. package/node_modules/pi-mcp-adapter/dist/types.js +26 -0
  73. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  74. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +6 -0
  75. package/node_modules/pi-mcp-adapter/dist/utils.js +65 -6
  76. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  77. package/node_modules/pi-mcp-adapter/host-html-template.ts +20 -19
  78. package/node_modules/pi-mcp-adapter/http-ca.ts +84 -0
  79. package/node_modules/pi-mcp-adapter/index.ts +827 -164
  80. package/node_modules/pi-mcp-adapter/init.ts +48 -10
  81. package/node_modules/pi-mcp-adapter/lazy-loader.ts +7 -0
  82. package/node_modules/pi-mcp-adapter/mcp-auth-fetch.ts +113 -0
  83. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +256 -77
  84. package/node_modules/pi-mcp-adapter/mcp-auth.ts +316 -54
  85. package/node_modules/pi-mcp-adapter/mcp-code.ts +45 -18
  86. package/node_modules/pi-mcp-adapter/mcp-install.ts +60 -0
  87. package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +70 -6
  88. package/node_modules/pi-mcp-adapter/mcp-output-guard.ts +53 -42
  89. package/node_modules/pi-mcp-adapter/mcp-panel-theme.ts +104 -0
  90. package/node_modules/pi-mcp-adapter/mcp-panel.ts +366 -332
  91. package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -14
  92. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +3 -1
  93. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +383 -341
  94. package/node_modules/pi-mcp-adapter/mcp-status.ts +5 -0
  95. package/node_modules/pi-mcp-adapter/metadata-cache.ts +7 -16
  96. package/node_modules/pi-mcp-adapter/namespace-tools.ts +61 -3
  97. package/node_modules/pi-mcp-adapter/oauth.ts +6 -2
  98. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +5 -17
  99. package/node_modules/pi-mcp-adapter/package.json +13 -3
  100. package/node_modules/pi-mcp-adapter/prompts.ts +35 -3
  101. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -7
  102. package/node_modules/pi-mcp-adapter/runtime-owner.ts +16 -1
  103. package/node_modules/pi-mcp-adapter/sampling-handler.ts +9 -32
  104. package/node_modules/pi-mcp-adapter/sandbox-proxy-template.ts +20 -93
  105. package/node_modules/pi-mcp-adapter/server-manager.ts +227 -40
  106. package/node_modules/pi-mcp-adapter/session-approvals.ts +187 -0
  107. package/node_modules/pi-mcp-adapter/skills/mcp-scripting/SKILL.md +9 -1
  108. package/node_modules/pi-mcp-adapter/state.ts +10 -1
  109. package/node_modules/pi-mcp-adapter/tool-approval.ts +10 -22
  110. package/node_modules/pi-mcp-adapter/tool-metadata.ts +14 -0
  111. package/node_modules/pi-mcp-adapter/types.ts +57 -4
  112. package/node_modules/pi-mcp-adapter/ui-server.ts +36 -28
  113. package/node_modules/pi-mcp-adapter/utils.ts +64 -6
  114. package/package.json +13 -21
  115. package/examples/pi-footer.json +0 -275
  116. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +0 -70
  117. package/node_modules/@czottmann/pi-automode/LICENSE.md +0 -21
  118. package/node_modules/@czottmann/pi-automode/README.md +0 -215
  119. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +0 -39
  120. package/node_modules/@czottmann/pi-automode/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +0 -46
  121. package/node_modules/@czottmann/pi-automode/docs/adr/ADR-002-global-config-in-extension-data-directory.md +0 -60
  122. package/node_modules/@czottmann/pi-automode/docs/adr/INDEX.md +0 -6
  123. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +0 -428
  124. package/node_modules/@czottmann/pi-automode/docs/configuration.md +0 -171
  125. package/node_modules/@czottmann/pi-automode/docs/defaults.md +0 -178
  126. package/node_modules/@czottmann/pi-automode/docs/diagnostics.md +0 -90
  127. package/node_modules/@czottmann/pi-automode/docs/observability-logging.md +0 -160
  128. package/node_modules/@czottmann/pi-automode/examples/automode.local.json +0 -44
  129. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/bash.ts +0 -692
  130. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +0 -852
  131. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +0 -904
  132. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +0 -228
  133. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +0 -954
  134. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +0 -429
  135. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/log.ts +0 -173
  136. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/model-selector.ts +0 -113
  137. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/model.ts +0 -13
  138. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/paths.ts +0 -297
  139. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/permissions.ts +0 -623
  140. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/state.ts +0 -102
  141. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/transcript.ts +0 -236
  142. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/types.ts +0 -205
  143. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/utils.ts +0 -54
  144. package/node_modules/@czottmann/pi-automode/extensions/auto-mode.ts +0 -26
  145. package/node_modules/@czottmann/pi-automode/package.json +0 -61
  146. package/node_modules/@czottmann/pi-automode/skills/automode-diagnostics/SKILL.md +0 -63
@@ -3,7 +3,16 @@
3
3
  // Inspects user input for /ponytail commands and writes mode to flag file
4
4
 
5
5
  const { getDefaultMode, isDeactivationCommand, writeDefaultMode } = require('./ponytail-config');
6
- const { clearMode, isQoder, readMode, setMode, writeHookOutput } = require('./ponytail-runtime');
6
+ const {
7
+ clearMode,
8
+ cursorRuleNotice,
9
+ cursorRulePath,
10
+ isCursor,
11
+ isQoder,
12
+ readMode,
13
+ setMode,
14
+ writeHookOutput,
15
+ } = require('./ponytail-runtime');
7
16
  const { getPonytailInstructions } = require('./ponytail-instructions');
8
17
 
9
18
  let input = '';
@@ -17,6 +26,18 @@ function finish() {
17
26
  const data = JSON.parse(input.replace(/^\uFEFF/, ''));
18
27
  const prompt = (data.prompt || '').trim().toLowerCase();
19
28
 
29
+ // Cursor with the always-on rule in the workspace: no hook can change or
30
+ // switch off a rule, so answer the command with the notice instead of
31
+ // writing a mode the rule would contradict (#817). Ordinary prompts
32
+ // stay silent as usual.
33
+ if (isCursor && (/^[/@$]ponytail/.test(prompt) || isDeactivationCommand(prompt))) {
34
+ const rule = cursorRulePath();
35
+ if (rule) {
36
+ writeHookOutput('UserPromptSubmit', readMode() || 'off', cursorRuleNotice(rule));
37
+ return;
38
+ }
39
+ }
40
+
20
41
  // Match /ponytail commands
21
42
  let modeSwitched = false;
22
43
  let deactivated = false;
@@ -68,10 +89,14 @@ function finish() {
68
89
  // switch happens we fold the confirmation into the ruleset output
69
90
  // below (one JSON on stdout) instead of emitting two separate writes.
70
91
  if (!isQoder) {
92
+ // Cursor has no /ponytail command that would load the skill body
93
+ // for the new level, so the tracker delivers that level's ruleset
94
+ // along with the confirmation (#817).
95
+ const header = 'PONYTAIL MODE CHANGED — level: ' + mode;
71
96
  writeHookOutput(
72
97
  'UserPromptSubmit',
73
98
  mode,
74
- 'PONYTAIL MODE CHANGED level: ' + mode,
99
+ isCursor ? header + '\n\n' + getPonytailInstructions(mode) : header,
75
100
  );
76
101
  }
77
102
  } else if (mode === 'off') {
@@ -4,14 +4,37 @@ const os = require('os');
4
4
  const { getClaudeDir, getConfigDir } = require('./ponytail-config');
5
5
 
6
6
  const STATE_FILE = '.ponytail-active';
7
- const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA);
7
+
8
+ // ponytail: VS Code Copilot never sets COPILOT_PLUGIN_DATA — it only injects
9
+ // CLAUDE_PLUGIN_ROOT, pointed at an install path under .vscode/agent-plugins/
10
+ // (#528). Without this fallback isCopilot was false, so ponytail assumed
11
+ // native Claude Code and emitted the statusline nudge, which VS Code Copilot
12
+ // doesn't read.
13
+ function isVsCodeCopilotRoot(pluginRoot) {
14
+ if (!pluginRoot) return false;
15
+ return pluginRoot.split(/[\\/]+/).includes('agent-plugins') &&
16
+ pluginRoot.toLowerCase().includes('.vscode');
17
+ }
18
+
19
+ const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA) ||
20
+ isVsCodeCopilotRoot(process.env.CLAUDE_PLUGIN_ROOT);
8
21
  const isCodex = !isCopilot && Boolean(process.env.PLUGIN_DATA);
9
22
  const isQoder = !isCopilot && !isCodex && Boolean(process.env.QODER_SESSION_ID);
23
+ // Cursor (#817): CURSOR_VERSION is set only in the environment Cursor builds
24
+ // for hook processes (Cursor 3.20.17 assigns it in exactly one place, the hook
25
+ // env builder), so it never leaks into a Claude Code session running inside
26
+ // Cursor's terminal. Cursor also sets it when it runs a Claude-format plugin's
27
+ // hooks next to CLAUDE_PLUGIN_ROOT, and it needs Cursor-shaped JSON either
28
+ // way, so this check comes after the hosts with their own data dirs.
29
+ const isCursor = !isCopilot && !isCodex && !isQoder && Boolean(process.env.CURSOR_VERSION);
10
30
 
11
31
  let stateDir = getClaudeDir();
12
32
  if (isCodex) stateDir = process.env.PLUGIN_DATA;
13
- if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA;
33
+ // COPILOT_PLUGIN_DATA is unset under VS Code Copilot, so fall back to
34
+ // getClaudeDir() rather than building a path from undefined.
35
+ if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA || getClaudeDir();
14
36
  if (isQoder) stateDir = path.join(os.homedir(), '.qoder');
37
+ if (isCursor) stateDir = path.join(os.homedir(), '.cursor');
15
38
 
16
39
  const statePath = path.join(stateDir, STATE_FILE);
17
40
 
@@ -33,6 +56,27 @@ function readMode() {
33
56
  }
34
57
  }
35
58
 
59
+ // Cursor's always-on project rule (.cursor/rules/ponytail.mdc) already puts the
60
+ // ruleset in front of every prompt and no hook can switch a rule off, so while
61
+ // it is in the workspace the hooks step back instead of injecting a second,
62
+ // possibly contradicting, copy (#817). Cursor hands every hook the workspace
63
+ // root as CURSOR_PROJECT_DIR; project hooks also run from that directory.
64
+ // ponytail: first workspace root only, a rule in a secondary folder of a
65
+ // multi-root workspace goes undetected.
66
+ function cursorRulePath() {
67
+ const root = process.env.CURSOR_PROJECT_DIR || process.cwd();
68
+ const rule = path.join(root, '.cursor', 'rules', 'ponytail.mdc');
69
+ return fs.existsSync(rule) ? rule : null;
70
+ }
71
+
72
+ function cursorRuleNotice(rule) {
73
+ return 'PONYTAIL: the always-on Cursor rule ' + rule + ' is active in this workspace and ' +
74
+ 'already carries the ponytail ruleset, so the ponytail hooks injected nothing further. ' +
75
+ 'Mode switching (/ponytail lite|full|ultra|off, "stop ponytail") is unavailable while ' +
76
+ 'that rule exists. When the user tries to switch or turn off ponytail, tell them to ' +
77
+ 'delete that rule so hooks.json can manage the level.';
78
+ }
79
+
36
80
  function writeHookOutput(event, mode, context = '') {
37
81
  if (isCopilot) {
38
82
  // Copilot reads additionalContext on SessionStart; ignores output elsewhere.
@@ -64,6 +108,18 @@ function writeHookOutput(event, mode, context = '') {
64
108
  process.stdout.write(JSON.stringify(output));
65
109
  return;
66
110
  }
111
+ if (isCursor) {
112
+ // Cursor parses stdout as JSON and treats empty stdout as "nothing to
113
+ // say"; raw text would be logged as a parse error. sessionStart takes
114
+ // additional_context into the conversation's system context;
115
+ // beforeSubmitPrompt needs continue:true and, in Cursor 3.20.17, injects
116
+ // additional_context into that turn (docs/cursor-hooks.md).
117
+ if (!context) return;
118
+ const output = { additional_context: context };
119
+ if (event === 'UserPromptSubmit') output.continue = true;
120
+ process.stdout.write(JSON.stringify(output));
121
+ return;
122
+ }
67
123
  // Native Claude: SessionStart accepts raw stdout, but SubagentStart needs the
68
124
  // hookSpecificOutput JSON form or the context is dropped.
69
125
  if (event === 'SubagentStart') {
@@ -76,8 +132,11 @@ function writeHookOutput(event, mode, context = '') {
76
132
 
77
133
  module.exports = {
78
134
  clearMode,
135
+ cursorRuleNotice,
136
+ cursorRulePath,
79
137
  isCodex,
80
138
  isCopilot,
139
+ isCursor,
81
140
  isQoder,
82
141
  readMode,
83
142
  setMode,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dietrichgebert/ponytail",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
5
5
  "keywords": ["opencode-plugin", "opencode", "ponytail", "pi-package", "pi", "skills", "qoder"],
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  ".qoder-plugin/",
31
31
  "pi-extension/",
32
32
  "scripts/uninstall.js",
33
+ "scripts/cursor-hooks.js",
33
34
  "assets/",
34
35
  "LICENSE"
35
36
  ],
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // ponytail — install or remove the Cursor hooks (hooks/cursor-hooks.json) in
3
+ // ~/.cursor/hooks.json (default) or <cwd>/.cursor/hooks.json (--project),
4
+ // merging with whatever hooks are already there. Only entries that run one of
5
+ // ponytail's own hooks/ponytail-*.js scripts are added or removed; every other
6
+ // hook stays as it was.
7
+ //
8
+ // node scripts/cursor-hooks.js install [--project]
9
+ // node scripts/cursor-hooks.js uninstall [--project]
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const { isShellSafe } = require('../hooks/ponytail-config');
15
+
16
+ const ROOT = path.join(__dirname, '..');
17
+ const TEMPLATE = path.join(ROOT, 'hooks', 'cursor-hooks.json');
18
+ const PONYTAIL_HOOK = /ponytail-[\w-]+\.js/;
19
+
20
+ function isPonytailHook(entry) {
21
+ return Boolean(entry && typeof entry.command === 'string' && PONYTAIL_HOOK.test(entry.command));
22
+ }
23
+
24
+ function hooksPath(scope) {
25
+ return scope === 'project'
26
+ ? path.join(process.cwd(), '.cursor', 'hooks.json')
27
+ : path.join(os.homedir(), '.cursor', 'hooks.json');
28
+ }
29
+
30
+ // Missing file → empty config. Malformed JSON throws a SyntaxError so the
31
+ // caller can refuse to touch the file rather than overwrite it.
32
+ function readConfig(file) {
33
+ let config = {};
34
+ try {
35
+ config = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
36
+ } catch (e) {
37
+ if (e.code !== 'ENOENT') throw e;
38
+ }
39
+ if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
40
+ if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) config.hooks = {};
41
+ return config;
42
+ }
43
+
44
+ function writeConfig(file, config) {
45
+ fs.mkdirSync(path.dirname(file), { recursive: true });
46
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n', 'utf8');
47
+ }
48
+
49
+ // The template's PONYTAIL_DIR placeholder becomes this checkout's absolute
50
+ // path. Forward slashes run unchanged under cmd, PowerShell and bash, and the
51
+ // same allowlist that guards the statusline snippet keeps shell metacharacters
52
+ // out of the command string.
53
+ function ponytailEntries() {
54
+ const root = ROOT.replace(/\\/g, '/');
55
+ if (!isShellSafe(root)) {
56
+ throw new Error('ponytail is checked out at a path with shell metacharacters (' + ROOT +
57
+ '); move it, or copy hooks/cursor-hooks.json by hand and quote the path for your shell');
58
+ }
59
+ const template = JSON.parse(fs.readFileSync(TEMPLATE, 'utf8'));
60
+ const hooks = {};
61
+ for (const [event, entries] of Object.entries(template.hooks)) {
62
+ hooks[event] = entries.map((entry) => ({ ...entry, command: entry.command.replace(/PONYTAIL_DIR/g, root) }));
63
+ }
64
+ return hooks;
65
+ }
66
+
67
+ function stripPonytail(config) {
68
+ for (const [event, entries] of Object.entries(config.hooks)) {
69
+ if (!Array.isArray(entries)) continue;
70
+ const kept = entries.filter((entry) => !isPonytailHook(entry));
71
+ if (kept.length) config.hooks[event] = kept;
72
+ else delete config.hooks[event];
73
+ }
74
+ }
75
+
76
+ function install(scope) {
77
+ const file = hooksPath(scope);
78
+ const config = readConfig(file);
79
+ if (config.version === undefined) config.version = 1;
80
+ // Re-running replaces stale ponytail entries instead of duplicating them.
81
+ stripPonytail(config);
82
+ for (const [event, entries] of Object.entries(ponytailEntries())) {
83
+ config.hooks[event] = [...(config.hooks[event] || []), ...entries];
84
+ }
85
+ writeConfig(file, config);
86
+ return file;
87
+ }
88
+
89
+ // Returns the file it changed, or null when there was nothing of ponytail's in it.
90
+ function uninstall(scope) {
91
+ const file = hooksPath(scope);
92
+ if (!fs.existsSync(file)) return null;
93
+ const config = readConfig(file);
94
+ const before = JSON.stringify(config);
95
+ stripPonytail(config);
96
+ if (JSON.stringify(config) === before) return null;
97
+ const otherKeys = Object.keys(config).filter((k) => k !== 'version' && k !== 'hooks');
98
+ if (Object.keys(config.hooks).length === 0 && otherKeys.length === 0) {
99
+ // Only ponytail lived here: drop the file rather than leave an empty husk.
100
+ fs.unlinkSync(file);
101
+ } else {
102
+ writeConfig(file, config);
103
+ }
104
+ return file;
105
+ }
106
+
107
+ if (require.main === module) {
108
+ const args = process.argv.slice(2);
109
+ const action = args[0];
110
+ const scope = args.includes('--project') ? 'project' : 'user';
111
+ try {
112
+ if (action === 'install') {
113
+ const file = install(scope);
114
+ console.log('Installed ponytail hooks in ' + file);
115
+ console.log('Cursor reloads hooks.json on save; start a new chat to activate.');
116
+ } else if (action === 'uninstall') {
117
+ const file = uninstall(scope);
118
+ console.log(file
119
+ ? 'Removed ponytail hooks from ' + file
120
+ : 'No ponytail hooks in ' + hooksPath(scope));
121
+ } else {
122
+ console.error('usage: node scripts/cursor-hooks.js install|uninstall [--project]');
123
+ process.exit(1);
124
+ }
125
+ } catch (e) {
126
+ if (e instanceof SyntaxError) {
127
+ console.error(hooksPath(scope) + ' is not valid JSON; nothing was changed, fix it by hand (' + e.message + ')');
128
+ } else {
129
+ console.error(e.message);
130
+ }
131
+ process.exit(1);
132
+ }
133
+ }
134
+
135
+ module.exports = { hooksPath, install, isPonytailHook, uninstall };
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // ponytail — removes state ponytail wrote outside the plugin's own files:
3
- // the mode flag, the config file, and the statusLine entry it added to
4
- // settings.json. Plugin files themselves are removed by each host's own
5
- // uninstall command (see README); this only cleans up what those commands
6
- // can't see.
3
+ // the mode flag, the config file, the statusLine entry it added to
4
+ // settings.json, and its entries in ~/.cursor/hooks.json. Plugin files
5
+ // themselves are removed by each host's own uninstall command (see README);
6
+ // this only cleans up what those commands can't see.
7
7
 
8
8
  const fs = require('fs');
9
+ const os = require('os');
9
10
  const path = require('path');
10
11
  const { getConfigPath, getClaudeDir } = require('../hooks/ponytail-config');
12
+ const cursorHooks = require('./cursor-hooks');
11
13
 
12
14
  const STATUSLINE_SCRIPT = 'ponytail-statusline';
13
15
 
@@ -21,8 +23,23 @@ function removeIfExists(filePath, label) {
21
23
  }
22
24
 
23
25
  removeIfExists(path.join(getClaudeDir(), '.ponytail-active'), 'mode flag');
26
+ removeIfExists(path.join(os.homedir(), '.cursor', '.ponytail-active'), 'Cursor mode flag');
24
27
  removeIfExists(getConfigPath(), 'config file');
25
28
 
29
+ // Cursor hooks (#817): drop only ponytail's entries from ~/.cursor/hooks.json,
30
+ // keep every other hook the user configured there.
31
+ try {
32
+ const hooksFile = cursorHooks.uninstall('user');
33
+ if (hooksFile) console.log(`Removed ponytail hooks from ${hooksFile}`);
34
+ } catch (e) {
35
+ if (e instanceof SyntaxError) {
36
+ // ponytail: malformed hooks.json — can't safely edit it; leave intact, warn
37
+ console.warn(`~/.cursor/hooks.json is malformed — could not remove the ponytail hook entries. Remove them manually from: ${cursorHooks.hooksPath('user')} (${e.message})`);
38
+ } else {
39
+ throw e;
40
+ }
41
+ }
42
+
26
43
  const settingsPath = path.join(getClaudeDir(), 'settings.json');
27
44
  try {
28
45
  const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
@@ -334,6 +334,7 @@ export function createAutocompleteProvider(
334
334
  current: AutocompleteProvider,
335
335
  getReferences: () => Promise<SessionReference[]>,
336
336
  currentCwd: string,
337
+ isCurrent: () => boolean = () => true,
337
338
  ): AutocompleteProvider {
338
339
  return {
339
340
  triggerCharacters: ["@"],
@@ -346,14 +347,15 @@ export function createAutocompleteProvider(
346
347
  const currentLine = lines[cursorLine] ?? "";
347
348
  const query = extractMentionQuery(currentLine.slice(0, cursorCol));
348
349
  if (query === undefined) {
349
- return current.getSuggestions(lines, cursorLine, cursorCol, options);
350
+ const suggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
351
+ return options.signal.aborted || !isCurrent() ? null : suggestions;
350
352
  }
351
353
 
352
354
  const [baseSuggestions, references] = await Promise.all([
353
355
  current.getSuggestions(lines, cursorLine, cursorCol, options),
354
356
  getReferences(),
355
357
  ]);
356
- if (options.signal.aborted) return null;
358
+ if (options.signal.aborted || !isCurrent()) return null;
357
359
 
358
360
  const sessionItems = filterSessions(references, query, currentCwd);
359
361
  const fileItems = baseSuggestions?.prefix === `@${query}` ? baseSuggestions.items : [];
@@ -416,6 +418,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
416
418
  subagentIds.clear();
417
419
  clearLiveSubagentRecords();
418
420
  let loadErrorShown = false;
421
+ const currentCwd = ctx.cwd;
419
422
  const currentSessionId = ctx.sessionManager.getSessionId();
420
423
  const currentSessionFile = ctx.sessionManager.getSessionFile();
421
424
  let sessionsPromise: Promise<SessionInfo[]> | undefined;
@@ -429,7 +432,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
429
432
  ),
430
433
  )
431
434
  .catch((error: unknown) => {
432
- if (!loadErrorShown) {
435
+ if (!loadErrorShown && generation === sessionGeneration) {
433
436
  loadErrorShown = true;
434
437
  const reason = error instanceof Error ? error.message : String(error);
435
438
  ctx.ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
@@ -445,6 +448,8 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
445
448
  | undefined;
446
449
  const getReferences = async (): Promise<SessionReference[]> => {
447
450
  const sessions = await getSessions();
451
+ // Session replacement can invalidate this closure while listAll() is pending.
452
+ if (generation !== sessionGeneration) return [];
448
453
  const subagentKey = [...subagentIds].join("\0");
449
454
  if (
450
455
  referencesCache &&
@@ -455,21 +460,27 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
455
460
  }
456
461
  const ordered = orderSessionReferences(
457
462
  mergeReferences(sessions, liveSubagentReferences(subagentIds, currentSessionId)),
458
- ctx.cwd,
463
+ currentCwd,
459
464
  );
460
465
  referencesCache = { sessions, subagentKey, ordered };
461
466
  return ordered;
462
467
  };
463
468
 
464
469
  getAvailableReferences = getReferences;
465
- if (ctx.mode === "tui") {
470
+ const isTui = ctx.mode === "tui";
471
+ if (isTui) {
466
472
  void getReferences();
467
473
  // Register after other session_start handlers. pi-fff claims every @
468
474
  // prefix, so a provider installed before it would never see session mentions.
469
475
  setTimeout(() => {
470
476
  if (generation !== sessionGeneration) return;
471
477
  ctx.ui.addAutocompleteProvider((current) =>
472
- createAutocompleteProvider(current, getReferences, ctx.cwd),
478
+ createAutocompleteProvider(
479
+ current,
480
+ getReferences,
481
+ currentCwd,
482
+ () => generation === sessionGeneration,
483
+ ),
473
484
  );
474
485
  }, 0);
475
486
  }
@@ -479,14 +490,23 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
479
490
  const referenceIds = extractSessionReferenceIds(event.prompt);
480
491
  if (referenceIds.length === 0) return;
481
492
 
493
+ const generation = sessionGeneration;
482
494
  const currentSessionId = ctx.sessionManager.getSessionId();
483
495
  const references = await (getAvailableReferences?.() ??
484
- SessionManager.listAll().then((sessions) =>
485
- mergeReferences(
486
- sessions.filter((session) => session.id !== currentSessionId),
487
- liveSubagentReferences(subagentIds, currentSessionId),
488
- ),
489
- ));
496
+ SessionManager.listAll()
497
+ .then((sessions) =>
498
+ mergeReferences(
499
+ sessions.filter((session) => session.id !== currentSessionId),
500
+ liveSubagentReferences(subagentIds, currentSessionId),
501
+ ),
502
+ )
503
+ .catch((error: unknown) => {
504
+ if (generation !== sessionGeneration) return [];
505
+ const reason = error instanceof Error ? error.message : String(error);
506
+ ctx.ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
507
+ return [];
508
+ }));
509
+ if (generation !== sessionGeneration) return;
490
510
  const referencesById = new Map<string, SessionReference>();
491
511
  const referencesByName = new Map<string, SessionReference>();
492
512
  const ambiguousNames = new Set<string>();
@@ -565,9 +585,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
565
585
  });
566
586
 
567
587
  pi.on("session_before_switch", () => {
568
- subagentIds.clear();
569
- clearLiveSubagentRecords();
570
- getAvailableReferences = undefined;
588
+ // This event can cancel; preserve current-session state until shutdown.
571
589
  });
572
590
 
573
591
  pi.on("session_shutdown", () => {
@@ -42,11 +42,13 @@ import {
42
42
  isScrollBottomInput,
43
43
  renderScrollButton,
44
44
  resetScrollButtonState,
45
+ restoreOfficialScrollToEnd,
45
46
  scheduleScrollButtonSync,
46
47
  setScrollButtonHovered,
47
48
  setScrollButtonVisible,
48
49
  setScrollButtonWidget,
49
50
  setToolMouseTui,
51
+ syncOfficialScrollToEnd,
50
52
  getScrollButtonVisible,
51
53
  getScrollButtonWidget,
52
54
  getToolMouseTui,
@@ -879,6 +881,7 @@ export function teardownToolMouseInteraction(
879
881
  }
880
882
  restoreToolMouseRenderPatch();
881
883
  restoreFullscreenViewportInput(getToolMouseTui());
884
+ restoreOfficialScrollToEnd(getToolMouseTui());
882
885
  resetScrollButtonState();
883
886
  setToolMouseTui(null);
884
887
  toolMouseUi = null;
@@ -894,6 +897,7 @@ export function resetToolHoverState(): void {
894
897
  setHoveredCompactAssistant(null);
895
898
  setScrollButtonVisible(false);
896
899
  setScrollButtonHovered(false);
900
+ restoreOfficialScrollToEnd(getToolMouseTui());
897
901
  releaseFullscreenToolMouseMotion(getToolMouseTui());
898
902
  }
899
903
 
@@ -916,12 +920,12 @@ export function installToolMouseInteraction(
916
920
  setToolMouseTui(tui);
917
921
  setToolTuiFullscreen(fullscreenLazyTui(tui));
918
922
  if (isLazyProxyTui(tui)) {
919
- disableOfficialScrollToEnd(tui);
923
+ syncOfficialScrollToEnd(tui);
920
924
  patchFullscreenViewportInput(tui);
921
925
  ensureFullscreenToolMouseMotion(tui);
922
926
  setScrollButtonWidget({
923
927
  render: (width: number) => {
924
- disableOfficialScrollToEnd(tui);
928
+ syncOfficialScrollToEnd(tui);
925
929
  patchFullscreenViewportInput(tui);
926
930
  ensureFullscreenToolMouseMotion(tui);
927
931
  return renderScrollButton(width, theme);
@@ -83,9 +83,31 @@ export function fullscreenLazyTui(tui: any): boolean {
83
83
  return isLazyProxyTui(tui) && tui.mode === "fullscreen";
84
84
  }
85
85
 
86
+ const OFFICIAL_SCROLL_TO_END_KEY = Symbol.for("pi.ccstyle.official-scroll-to-end");
87
+
86
88
  /** 关掉 pi 0.85 Jump to latest overlay,避免和本仓库 dock 按钮叠两层。 */
87
89
  export function disableOfficialScrollToEnd(tui: any): void {
88
- if (typeof tui?.scrollToEndIndicator === "function") tui.scrollToEndIndicator = undefined;
90
+ if (!tui) return;
91
+ const current = tui.scrollToEndIndicator;
92
+ if (typeof current !== "function") return;
93
+ if (!tui[OFFICIAL_SCROLL_TO_END_KEY]) {
94
+ tui[OFFICIAL_SCROLL_TO_END_KEY] = { original: current };
95
+ }
96
+ tui.scrollToEndIndicator = undefined;
97
+ }
98
+
99
+ /** /ccstyle off 或 teardown 时还回官方 overlay。 */
100
+ export function restoreOfficialScrollToEnd(tui: any): void {
101
+ if (!tui) return;
102
+ const original = tui[OFFICIAL_SCROLL_TO_END_KEY]?.original;
103
+ if (typeof original === "function") tui.scrollToEndIndicator = original;
104
+ tui[OFFICIAL_SCROLL_TO_END_KEY] = undefined;
105
+ }
106
+
107
+ /** on/compact 用 dock 按钮;off 还回 pi 原生 Jump to latest。 */
108
+ export function syncOfficialScrollToEnd(tui: any): void {
109
+ if (toolMouseInteractionActive()) disableOfficialScrollToEnd(tui);
110
+ else restoreOfficialScrollToEnd(tui);
89
111
  }
90
112
 
91
113
  /** 官方 fullscreen:是否已跟随 transcript 底部(按钮隐藏条件)。 */