one-code-extension 0.1.3 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -20,46 +20,118 @@
20
20
  *
21
21
  * pi's emitToolCall has no try/catch around handlers, so every hook dispatch
22
22
  * here is wrapped — a throwing hook fails open, never takes the turn down.
23
+ *
24
+ * Hook context reaches the model in Claude Code's `hook_additional_context`
25
+ * shape — a `<system-reminder>` reading "<Event> hook additional context: …" —
26
+ * never as a message of its own: PreToolUse context is queued as a one-shot on
27
+ * the reminder channel, so it lands INSIDE the tool result it belongs to,
28
+ * persisted, at no extra turn; PostToolUse context is appended to the result
29
+ * the same way; prompt/session/compaction context is returned from
30
+ * `before_agent_start` as a custom message right AFTER the user's prompt. Until
31
+ * 2026-09-05 each was a custom steer (one extra LLM turn per hook per tool,
32
+ * pi draining one steer per call) and prompt context landed BEFORE the prompt
33
+ * (STEERING-REVIEW-2026-09-05 M6).
34
+ *
35
+ * Subagents: the tool hooks also run for a child's tool calls, through the
36
+ * bridge published on `SUBAGENT_HOOK_CHANNEL` (subagent-bridge.ts): the child
37
+ * calls back into this extension, which dispatches with the parent's context
38
+ * (settings, consent, logging) and a payload naming the child.
23
39
  */
24
40
 
25
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
41
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
42
+ import { RunOutcomeLatch } from "../lib/interrupt.ts";
26
43
  import { claudeConfigDir } from "../lib/paths.ts";
44
+ import { defaultDiscoverRoots } from "../lib/plugins.ts";
27
45
  import { appendHookLog, formatDebugLine, hooksDebugEnabled, hooksLogPath } from "./debug.ts";
28
46
  import { runHookCommand } from "./executor.ts";
29
- import { matcherApplies, ccToolName, toolMatchCandidates } from "./matcher.ts";
47
+ import { changedSince, FORMATTER_NOTICE, type FileSnapshot, fileToolTarget, snapshotFile } from "./formatter-notice.ts";
48
+ import { matcherApplies, ccToolName, toolMatchCandidates, ccToolInput, nativeToolInput } from "./matcher.ts";
30
49
  import { loadPluginHooks } from "./plugin-hooks.ts";
31
50
  import {
51
+ applyPostToolUseOutcome,
32
52
  type CcHookEvent,
33
53
  type HookOutcome,
34
54
  type HookStdinPayload,
35
55
  interpretHookResult,
36
56
  } from "./protocol.ts";
37
57
  import { type HookCommand, type HooksSource, loadHookSettings } from "./settings.ts";
58
+ import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
59
+ import { REMINDER_CHANNEL, wrapReminder } from "../lib/reminders.ts";
60
+ import { type ChildHookCall, type ChildHookResult, type HookBridge, SUBAGENT_HOOK_CHANNEL, type SubagentHookPayload } from "./subagent-bridge.ts";
38
61
  import { projectHooksApproved } from "./trust.ts";
39
62
 
63
+ /** Claude Code's `hook_additional_context` attachment text (utils/messages.ts). */
64
+ export function hookContextText(event: CcHookEvent, text: string): string {
65
+ return `${event} hook additional context: ${text}`;
66
+ }
67
+
68
+ /**
69
+ * Every hook text that reaches the model is bounded through this: additional
70
+ * context, a string or (JSON-encoded) object updatedToolResult, a block reason,
71
+ * a Stop stopReason. The executor allows 1 MB per stream, and a runaway hook
72
+ * would otherwise dump all of it into context (reviews T5, M2). Past the cap the
73
+ * full text is PERSISTED under the session's tool-results (Claude Code's
74
+ * convention) and the model gets a preview plus the path — nothing is cut away.
75
+ */
76
+ const HOOK_MODEL_TEXT_CAP = 20_000;
77
+ // A monotonic counter, not Date.now(), so two hooks finishing in the same
78
+ // millisecond do not persist to the same file and clobber each other (review M2).
79
+ let hookPersistSeq = 0;
80
+ function capHookText(text: string, ctx: HookDispatchCtx, label: string): string {
81
+ return persistIfLarge(text, { dir: sessionResultsDir(ctx), id: `hook-${label}-${hookPersistSeq++}`, maxBytes: HOOK_MODEL_TEXT_CAP });
82
+ }
83
+
40
84
  interface MatchedHook {
41
85
  source: HooksSource;
42
86
  hook: HookCommand;
43
87
  }
44
88
 
89
+ /**
90
+ * What the hook pipeline reads off a context — the live `ExtensionContext` is
91
+ * assignable, and a SessionEnd dispatch runs on a snapshot of the same shape
92
+ * (see `shutdownCtx`) after the real one has been disposed.
93
+ */
94
+ interface HookDispatchCtx {
95
+ cwd: string;
96
+ hasUI: boolean;
97
+ sessionManager: { getSessionId(): string; getSessionFile(): string | undefined; getSessionDir?(): string | undefined };
98
+ ui: { confirm(title: string, message: string): Promise<boolean | undefined>; notify(message: string, type: "info"): void };
99
+ /** Set on the shutdown snapshot: no consent prompt, no notices — the session is gone. */
100
+ sessionEnded?: true;
101
+ }
102
+
45
103
  export default function hooksExtension(pi: ExtensionAPI) {
46
104
  let stopHookActive = false;
47
- let pendingPromptContext: string[] = [];
105
+ /** Context from UserPromptSubmit / SessionStart / PostCompact hooks, delivered with the next prompt. */
106
+ let pendingPromptContext: Array<{ event: CcHookEvent; text: string }> = [];
107
+ /** The last context seen, for bridged child calls (dispatched parent-side). */
108
+ let lastCtx: ExtensionContext | undefined;
109
+ /**
110
+ * The in-flight SessionStart hook dispatch. It runs OFF the `session_start`
111
+ * path so the editor opens immediately (a slow hook used to hold the prompt
112
+ * for its whole run — TUI-REVIEW M2); the first `before_agent_start` awaits it
113
+ * so the first turn still carries the hook's additionalContext, and the
114
+ * first-run approval dialog still gates execution.
115
+ */
116
+ let sessionStartPending: Promise<void> | undefined;
117
+ /** Bumped on every session_start; a superseded dispatch checks it before writing context. */
118
+ let sessionGen = 0;
48
119
 
49
- const notify = (ctx: ExtensionContext, message: string) => {
120
+ const notify = (ctx: HookDispatchCtx, message: string) => {
121
+ if (ctx.sessionEnded) return; // session gone; nowhere to show it
50
122
  if (ctx.hasUI) ctx.ui.notify(message, "info");
51
123
  else process.stderr.write(`${message}\n`);
52
124
  };
53
125
 
54
126
  /** All hooks for an event across trusted sources, matcher already applied. */
55
127
  const collectHooks = async (
56
- ctx: ExtensionContext,
128
+ ctx: HookDispatchCtx,
57
129
  event: CcHookEvent,
58
130
  matchValue: { candidates?: string[]; ignoreMatcher?: boolean },
59
131
  ): Promise<MatchedHook[]> => {
60
132
  const claudeDir = claudeConfigDir();
61
133
  const loaded = loadHookSettings(claudeDir, ctx.cwd);
62
- const pluginSources = loadPluginHooks(claudeDir, loaded.diagnostics);
134
+ const pluginSources = loadPluginHooks(defaultDiscoverRoots(getAgentDir(), ctx.cwd), loaded.diagnostics);
63
135
  if (hooksDebugEnabled()) {
64
136
  for (const diagnostic of loaded.diagnostics) process.stderr.write(`[hooks] ${diagnostic}\n`);
65
137
  }
@@ -70,6 +142,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
70
142
  if (hasProjectHooks) {
71
143
  projectAllowed = await projectHooksApproved(ctx.cwd, projectSources, {
72
144
  hasUI: ctx.hasUI,
145
+ noPrompt: ctx.sessionEnded,
73
146
  confirm: (title, message) => ctx.ui.confirm(title, message),
74
147
  notify: (message) => notify(ctx, message),
75
148
  });
@@ -92,7 +165,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
92
165
  return matched;
93
166
  };
94
167
 
95
- const basePayload = (ctx: ExtensionContext, event: CcHookEvent): HookStdinPayload => ({
168
+ const basePayload = (ctx: HookDispatchCtx, event: CcHookEvent): HookStdinPayload => ({
96
169
  session_id: ctx.sessionManager.getSessionId(),
97
170
  transcript_path: ctx.sessionManager.getSessionFile() ?? "",
98
171
  cwd: ctx.cwd,
@@ -105,15 +178,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
105
178
  * systemMessages concatenate. Any throw inside is caught → fail open.
106
179
  */
107
180
  const dispatch = async (
108
- ctx: ExtensionContext,
181
+ ctx: HookDispatchCtx,
109
182
  event: CcHookEvent,
110
183
  matchValue: { candidates?: string[]; ignoreMatcher?: boolean },
111
184
  payload: HookStdinPayload,
185
+ /** Runs once, only when hooks will actually run — the point where a caller can snapshot state the hooks may change. */
186
+ willRun?: () => void,
112
187
  ): Promise<HookOutcome> => {
113
188
  const merged: HookOutcome = {};
114
189
  try {
115
190
  const hooks = await collectHooks(ctx, event, matchValue);
116
191
  if (hooks.length === 0) return merged;
192
+ willRun?.();
117
193
  const stdin = JSON.stringify(payload);
118
194
  const outcomes = await Promise.all(
119
195
  hooks.map(async ({ source, hook }) => {
@@ -121,6 +197,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
121
197
  cwd: ctx.cwd,
122
198
  timeoutSeconds: hook.timeout,
123
199
  projectDir: ctx.cwd,
200
+ // SessionEnd is fire-and-forget at shutdown: it must not hold a
201
+ // one-shot process open. Every other hook is awaited work that
202
+ // must (executor.ts, the event-loop drain).
203
+ detached: event === "SessionEnd",
124
204
  });
125
205
  const outcome = interpretHookResult(event, run);
126
206
  const decision = outcome.block ? "block" : outcome.additionalContext ? "context" : run.exitCode === 0 || run.exitCode === 2 ? "allow" : "error";
@@ -141,11 +221,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
141
221
  }),
142
222
  );
143
223
  for (const outcome of outcomes) {
144
- merged.block ??= outcome.block;
224
+ // A block reason becomes model-facing text (the tool result, or a
225
+ // Stop follow-up), so it is bounded like the other hook texts —
226
+ // stderr on an exit-2 hook is easily a full stack trace (review M2).
227
+ if (!merged.block && outcome.block) {
228
+ merged.block = { reason: capHookText(outcome.block.reason, ctx, `${event}-block`) };
229
+ }
145
230
  if (outcome.updatedInput) merged.updatedInput = { ...merged.updatedInput, ...outcome.updatedInput };
146
- if ("updatedToolResult" in outcome) merged.updatedToolResult = outcome.updatedToolResult;
231
+ if ("updatedToolResult" in outcome) {
232
+ // A non-string replacement is JSON-encoded before it reaches the
233
+ // model, so stringify then cap it too — not just the string case.
234
+ const text =
235
+ typeof outcome.updatedToolResult === "string" ? outcome.updatedToolResult : JSON.stringify(outcome.updatedToolResult);
236
+ merged.updatedToolResult = capHookText(text, ctx, `${event}-result`);
237
+ }
147
238
  if (outcome.additionalContext) {
148
- merged.additionalContext = [merged.additionalContext, outcome.additionalContext].filter(Boolean).join("\n");
239
+ merged.additionalContext = capHookText(
240
+ [merged.additionalContext, outcome.additionalContext].filter(Boolean).join("\n"),
241
+ ctx,
242
+ `${event}-context`,
243
+ );
149
244
  }
150
245
  if (outcome.systemMessage) {
151
246
  merged.systemMessage = [merged.systemMessage, outcome.systemMessage].filter(Boolean).join("\n");
@@ -161,37 +256,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
161
256
  return merged;
162
257
  };
163
258
 
164
- /** Hidden custom message the model sees as context, CC's additionalContext. */
165
- const injectContext = (text: string, midLoop: boolean) => {
166
- const message = { customType: "one-code:hook-context", content: `<hook-additional-context>\n${text}\n</hook-additional-context>`, display: false };
167
- try {
168
- if (midLoop) pi.sendMessage(message, { deliverAs: "steer" });
169
- else pi.sendMessage(message);
170
- } catch {
171
- try {
172
- pi.sendMessage(message);
173
- } catch {
174
- // No session to speak to; drop the context.
175
- }
176
- }
177
- };
178
-
179
259
  // ---- PreToolUse ---------------------------------------------------------
180
260
  pi.on("tool_call", async (event, ctx) => {
181
261
  const payload: HookStdinPayload = {
182
262
  ...basePayload(ctx, "PreToolUse"),
183
263
  tool_name: ccToolName(event.toolName),
184
- tool_input: event.input as Record<string, unknown>,
264
+ tool_input: ccToolInput(event.toolName, event.input as Record<string, unknown>),
185
265
  };
186
266
  const outcome = await dispatch(ctx, "PreToolUse", { candidates: toolMatchCandidates(event.toolName) }, payload);
187
267
  if (outcome.block) return { block: true, reason: `PreToolUse hook: ${outcome.block.reason}` };
188
268
  if (outcome.updatedInput) {
189
269
  // In-place, so worktree/file-tracker/permissions (later in the
190
270
  // extension order) all see the rewritten input — CC applies hook
191
- // input updates before permission evaluation.
192
- Object.assign(event.input as Record<string, unknown>, outcome.updatedInput);
271
+ // input updates before permission evaluation. The hook answered in
272
+ // CC's parameter names; translate back to the tool's own.
273
+ Object.assign(event.input as Record<string, unknown>, nativeToolInput(event.toolName, outcome.updatedInput));
193
274
  }
194
- if (outcome.additionalContext) injectContext(outcome.additionalContext, true);
275
+ // A one-shot pending when this call's result is stored is written into
276
+ // that result by the system-reminder extension — CC's shape, no extra turn.
277
+ if (outcome.additionalContext) pi.events.emit(REMINDER_CHANNEL, { text: hookContextText("PreToolUse", outcome.additionalContext) });
195
278
  return undefined;
196
279
  });
197
280
 
@@ -200,75 +283,219 @@ export default function hooksExtension(pi: ExtensionAPI) {
200
283
  const payload: HookStdinPayload = {
201
284
  ...basePayload(ctx, "PostToolUse"),
202
285
  tool_name: ccToolName(event.toolName),
203
- tool_input: event.input,
286
+ tool_input: ccToolInput(event.toolName, event.input as Record<string, unknown>),
204
287
  tool_response: { content: event.content, is_error: event.isError },
205
288
  };
206
- const outcome = await dispatch(ctx, "PostToolUse", { candidates: toolMatchCandidates(event.toolName) }, payload);
207
- if (!outcome.block && outcome.updatedToolResult === undefined && !outcome.additionalContext) return undefined;
289
+ // A hook that rewrites the file the model just edited is otherwise invisible
290
+ // to it: this extension awaits the hook and loads before file-tracker, so
291
+ // the tracker records the hook's version as the model's own write
292
+ // (formatter-notice.ts, review M1). Snapshot only when hooks will run.
293
+ const target = event.isError ? undefined : fileToolTarget(event.toolName, event.input, ctx.cwd);
294
+ let before: FileSnapshot | undefined;
295
+ const outcome = await dispatch(ctx, "PostToolUse", { candidates: toolMatchCandidates(event.toolName) }, payload, () => {
296
+ if (target) before = snapshotFile(target);
297
+ });
298
+ const formatterNotice = target && before && changedSince(target, before) ? wrapReminder(FORMATTER_NOTICE(target)) : undefined;
299
+ // CC feeds PostToolUse block reasons back to the model in the result the
300
+ // same way; the assembly rules live in applyPostToolUseOutcome.
301
+ const content = applyPostToolUseOutcome(event.content, {
302
+ ...outcome,
303
+ additionalContext:
304
+ [outcome.additionalContext && wrapReminder(hookContextText("PostToolUse", outcome.additionalContext)), formatterNotice]
305
+ .filter(Boolean)
306
+ .join("\n") || undefined,
307
+ });
308
+ if (!content) return undefined;
309
+ return { content, isError: event.isError };
310
+ });
208
311
 
209
- let content = [...event.content];
210
- let isError = event.isError;
211
- if (outcome.updatedToolResult !== undefined) {
212
- const replacement = outcome.updatedToolResult;
213
- content = [{ type: "text", text: typeof replacement === "string" ? replacement : JSON.stringify(replacement) }];
214
- }
215
- if (outcome.block) {
216
- // The tool already ran; the only channel left is the result the
217
- // model reads, so the objection is delivered there (CC feeds
218
- // PostToolUse block reasons back to the model the same way).
219
- content = [{ type: "text" as const, text: `PostToolUse hook: ${outcome.block.reason}` }, ...content];
220
- isError = true;
221
- }
222
- if (outcome.additionalContext) {
223
- content = [...content, { type: "text" as const, text: `<hook-additional-context>\n${outcome.additionalContext}\n</hook-additional-context>` }];
312
+ /** Await the backgrounded SessionStart dispatch once, if one is pending. */
313
+ const drainSessionStart = async () => {
314
+ if (!sessionStartPending) return;
315
+ const pending = sessionStartPending;
316
+ sessionStartPending = undefined;
317
+ try {
318
+ await pending;
319
+ } catch {
320
+ // dispatch already reports its own failures; never block the turn on one.
224
321
  }
225
- return { content, isError };
226
- });
322
+ };
227
323
 
228
324
  // ---- UserPromptSubmit ---------------------------------------------------
229
325
  pi.on("input", async (event, ctx) => {
230
326
  if (event.source === "extension") return undefined;
231
327
  stopHookActive = false;
328
+ // Drain the backgrounded SessionStart dispatch before this prompt's own
329
+ // UserPromptSubmit context, so their order in the first turn is unchanged
330
+ // (SessionStart context precedes UserPromptSubmit). before_agent_start is
331
+ // the backstop for turns with no input event (one-shot, programmatic).
332
+ await drainSessionStart();
232
333
  const payload: HookStdinPayload = { ...basePayload(ctx, "UserPromptSubmit"), prompt: event.text };
233
334
  const outcome = await dispatch(ctx, "UserPromptSubmit", { ignoreMatcher: true }, payload);
234
335
  if (outcome.block) {
235
336
  notify(ctx, `Prompt blocked by UserPromptSubmit hook: ${outcome.block.reason}`);
236
337
  return { action: "handled" as const };
237
338
  }
238
- if (outcome.additionalContext) pendingPromptContext.push(outcome.additionalContext);
339
+ if (outcome.additionalContext) pendingPromptContext.push({ event: "UserPromptSubmit", text: outcome.additionalContext });
239
340
  return undefined;
240
341
  });
241
342
 
242
- // Prompt-hook context is injected right before the agent loop builds its
243
- // context, so it lands on the same turn as the prompt that produced it.
244
- pi.on("before_agent_start", () => {
343
+ // Prompt/session/compaction hook context rides the turn it belongs to as a
344
+ // custom message pi appends right AFTER the user's prompt (the
345
+ // before_agent_start `message` return). An idle `sendMessage` from here
346
+ // landed it BEFORE the prompt (pi appends first, then adds the user message).
347
+ pi.on("before_agent_start", async (_event, ctx) => {
348
+ lastCtx = ctx;
349
+ // The SessionStart dispatch runs in the background from session_start; the
350
+ // first turn must wait for it so its additionalContext is present (and, on
351
+ // first run, so its approval dialog gates execution before the model runs).
352
+ // The `input` handler usually drained it already; this is the backstop for
353
+ // turns with no input event.
354
+ await drainSessionStart();
245
355
  if (pendingPromptContext.length === 0) return;
246
356
  const texts = pendingPromptContext;
247
357
  pendingPromptContext = [];
248
- for (const text of texts) injectContext(text, false);
358
+ return {
359
+ message: {
360
+ customType: "one-code:hook-context",
361
+ content: texts.map(({ event, text }) => wrapReminder(hookContextText(event, text))).join("\n"),
362
+ display: false,
363
+ },
364
+ };
249
365
  });
250
366
 
251
367
  // ---- SessionStart / SessionEnd -----------------------------------------
252
- const dispatchSessionStart = async (ctx: ExtensionContext, source: string) => {
368
+ const dispatchSessionStart = async (ctx: ExtensionContext, source: string, gen: number) => {
253
369
  const payload: HookStdinPayload = { ...basePayload(ctx, "SessionStart"), source };
254
370
  const outcome = await dispatch(ctx, "SessionStart", { candidates: [source] }, payload);
255
- if (outcome.additionalContext) injectContext(outcome.additionalContext, false);
371
+ // A later session_start (rapid /clear, resume) may have superseded this one
372
+ // while its hook ran; discard the stale result instead of pushing it into
373
+ // the new session's context (`pendingPromptContext` is shared and reset).
374
+ if (gen === sessionGen && outcome.additionalContext) {
375
+ pendingPromptContext.push({ event: "SessionStart", text: outcome.additionalContext });
376
+ }
256
377
  };
257
378
 
258
- pi.on("session_start", async (event, ctx) => {
379
+ pi.on("session_start", (event, ctx) => {
380
+ lastCtx = ctx;
381
+ const gen = ++sessionGen;
382
+ pendingPromptContext = [];
383
+ sessionStartPending = undefined;
384
+ // Publish the child hook bridge (subagent-bridge.ts). The closures read
385
+ // live parent state per call, so once per session start is enough.
386
+ pi.events.emit(SUBAGENT_HOOK_CHANNEL, { bridge: childHookBridge } satisfies SubagentHookPayload);
387
+ // Claude Code's sources are startup | resume | clear | compact; pi's
388
+ // `new` (/clear, /new) is a clear — the process-launch case arrives as
389
+ // `startup` already. `compact` is dispatched from session_compact below.
259
390
  const reason = (event as { reason?: string }).reason ?? "startup";
260
391
  if (reason === "reload" || reason === "fork") return;
261
- await dispatchSessionStart(ctx, reason === "new" ? "startup" : reason);
392
+ // Run the dispatch OFF this handler so the prompt editor opens immediately;
393
+ // the first before_agent_start awaits the promise (TUI-REVIEW M2). The
394
+ // detached `.catch` only suppresses an unhandled rejection before that
395
+ // await attaches — the real error still surfaces there.
396
+ sessionStartPending = dispatchSessionStart(ctx, reason === "new" ? "clear" : reason, gen);
397
+ sessionStartPending.catch(() => {});
398
+ });
399
+
400
+ // ---- Subagent bridge ----------------------------------------------------
401
+ /** A child's payload: the child's identity, the parent's config and consent. */
402
+ const childPayload = (call: ChildHookCall, event: CcHookEvent): HookStdinPayload => ({
403
+ session_id: call.sessionId ?? "",
404
+ transcript_path: call.transcriptPath ?? "",
405
+ cwd: call.cwd,
406
+ hook_event_name: event,
407
+ tool_name: ccToolName(call.toolName),
408
+ tool_input: ccToolInput(call.toolName, call.input),
409
+ agent_id: call.sessionId,
410
+ agent_type: call.agentType,
262
411
  });
412
+ const childHookBridge: HookBridge = {
413
+ async preToolUse(call) {
414
+ const ctx = lastCtx;
415
+ if (!ctx) return {};
416
+ const outcome = await dispatch(ctx, "PreToolUse", { candidates: toolMatchCandidates(call.toolName) }, childPayload(call, "PreToolUse"));
417
+ return {
418
+ block: outcome.block,
419
+ updatedInput: outcome.updatedInput ? nativeToolInput(call.toolName, outcome.updatedInput) : undefined,
420
+ additionalContext: outcome.additionalContext ? hookContextText("PreToolUse", outcome.additionalContext) : undefined,
421
+ };
422
+ },
423
+ async postToolUse(result: ChildHookResult) {
424
+ const ctx = lastCtx;
425
+ if (!ctx) return {};
426
+ const payload: HookStdinPayload = {
427
+ ...childPayload(result, "PostToolUse"),
428
+ tool_response: { content: result.content, is_error: result.isError },
429
+ };
430
+ // The CHILD's cwd, not the parent's: a worktree-isolated subagent edits
431
+ // its own copy, and resolving against ctx.cwd would snapshot the main
432
+ // checkout — missing the hook's rewrite, or reporting an unrelated change.
433
+ const target = result.isError ? undefined : fileToolTarget(result.toolName, result.input, result.cwd);
434
+ let before: FileSnapshot | undefined;
435
+ const outcome = await dispatch(ctx, "PostToolUse", { candidates: toolMatchCandidates(result.toolName) }, payload, () => {
436
+ if (target) before = snapshotFile(target);
437
+ });
438
+ const formatterNotice = target && before && changedSince(target, before) ? wrapReminder(FORMATTER_NOTICE(target)) : undefined;
439
+ return {
440
+ block: outcome.block,
441
+ updatedToolResult: outcome.updatedToolResult,
442
+ additionalContext:
443
+ [outcome.additionalContext && wrapReminder(hookContextText("PostToolUse", outcome.additionalContext)), formatterNotice]
444
+ .filter(Boolean)
445
+ .join("\n") || undefined,
446
+ };
447
+ },
448
+ };
263
449
 
264
- pi.on("session_shutdown", async (_event, ctx) => {
265
- const payload: HookStdinPayload = { ...basePayload(ctx, "SessionEnd"), source: "other" };
266
- // Fire and forget: the process is on its way out; nothing to apply.
267
- void dispatch(ctx, "SessionEnd", { ignoreMatcher: true }, payload);
450
+ pi.on("session_shutdown", (event, ctx) => {
451
+ // A reload re-runs the extensions but keeps the conversation (pi's
452
+ // AgentSession.reload): not a session end, Claude Code has no such event.
453
+ if (event.reason === "reload") return;
454
+ // Claude Code's SessionEnd carries `reason`: clear | logout | prompt_input_exit
455
+ // | other. pi's `new` (/clear, /new) is a clear; a `quit` from the TUI is the
456
+ // user leaving the prompt; a headless run's end, a resume and a fork are
457
+ // `other` (LIFECYCLE-REVIEW-2026-09-06 M4).
458
+ const reason = event.reason === "new" ? "clear" : event.reason === "quit" && ctx.hasUI ? "prompt_input_exit" : "other";
459
+ const payload: HookStdinPayload = { ...basePayload(ctx, "SessionEnd"), reason };
460
+ // Fire and forget: the process is on its way out; nothing to apply. The ctx
461
+ // is frozen first: after this handler returns pi disposes the session and
462
+ // every getter on the real ctx throws, so a dispatch that awaited (the
463
+ // project-hooks consent read) would then fail inside its own try and the
464
+ // hook silently would not run. Consent is never PROMPTED at shutdown: a
465
+ // project config that was never approved is skipped, as in headless runs.
466
+ void dispatch(shutdownCtx(ctx), "SessionEnd", { ignoreMatcher: true }, payload);
268
467
  });
269
468
 
469
+ /** The subset of ctx `dispatch` reads, snapshotted so it survives the session's disposal. */
470
+ const shutdownCtx = (ctx: ExtensionContext): HookDispatchCtx => {
471
+ const sessionId = ctx.sessionManager.getSessionId();
472
+ const sessionFile = ctx.sessionManager.getSessionFile();
473
+ const sessionDir = ctx.sessionManager.getSessionDir();
474
+ return {
475
+ sessionEnded: true,
476
+ cwd: ctx.cwd,
477
+ hasUI: false,
478
+ sessionManager: { getSessionId: () => sessionId, getSessionFile: () => sessionFile, getSessionDir: () => sessionDir },
479
+ ui: { confirm: async () => false, notify: () => {} },
480
+ };
481
+ };
482
+
270
483
  // ---- Stop ---------------------------------------------------------------
271
- pi.on("agent_end", async (_event, ctx) => {
484
+ // agent_settled, not agent_end: CC fires Stop once, when the main agent has
485
+ // finished responding, and a turn can hold several runs (`lib/interrupt.ts`).
486
+ // Dispatching per run would run the user's Stop hook several times per turn and
487
+ // let a blocking hook inject its follow-up mid-retry.
488
+ const stopOutcome = new RunOutcomeLatch();
489
+ pi.on("agent_end", (event, ctx) => {
490
+ stopOutcome.record(event.messages, ctx.signal?.aborted);
491
+ });
492
+
493
+ pi.on("agent_settled", async (_event, ctx) => {
494
+ // CC skips Stop when the turn ended on an API error (a blocking hook would
495
+ // spiral: error → block → retry → error) or on a user interrupt (its query
496
+ // loop returns on the abort signal before the stop-hook step). An empty latch
497
+ // means no run ended in this turn, which is not a response to stop on either.
498
+ if (stopOutcome.take() !== "ok") return;
272
499
  const payload: HookStdinPayload = { ...basePayload(ctx, "Stop"), stop_hook_active: stopHookActive };
273
500
  const outcome = await dispatch(ctx, "Stop", { ignoreMatcher: true }, payload);
274
501
  if (!outcome.block) return;
@@ -288,10 +515,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
288
515
  });
289
516
 
290
517
  // ---- PreCompact / PostCompact ------------------------------------------
518
+ // Claude Code's trigger is manual | auto; pi's threshold and overflow
519
+ // reasons are both `auto`. Both payloads mirror CC's PreCompactHookInput /
520
+ // PostCompactHookInput field for field.
521
+ const compactTrigger = (event: { reason?: string }): "manual" | "auto" => (event.reason === "manual" ? "manual" : "auto");
522
+
291
523
  pi.on("session_before_compact", async (event, ctx) => {
292
- const reason = (event as { reason?: string }).reason;
293
- const trigger = reason === "manual" ? "manual" : "auto";
294
- const payload: HookStdinPayload = { ...basePayload(ctx, "PreCompact"), trigger };
524
+ const trigger = compactTrigger(event);
525
+ const payload: HookStdinPayload = {
526
+ ...basePayload(ctx, "PreCompact"),
527
+ trigger,
528
+ custom_instructions: event.customInstructions ?? "",
529
+ };
295
530
  const outcome = await dispatch(ctx, "PreCompact", { candidates: [trigger] }, payload);
296
531
  if (outcome.block) {
297
532
  notify(ctx, `Compaction cancelled by PreCompact hook: ${outcome.block.reason}`);
@@ -300,11 +535,17 @@ export default function hooksExtension(pi: ExtensionAPI) {
300
535
  return undefined;
301
536
  });
302
537
 
303
- pi.on("session_compact", async (_event, ctx) => {
304
- const payload: HookStdinPayload = { ...basePayload(ctx, "PostCompact"), trigger: "auto" };
305
- const outcome = await dispatch(ctx, "PostCompact", { ignoreMatcher: true }, payload);
306
- if (outcome.additionalContext) injectContext(outcome.additionalContext, false);
307
- // CC fires SessionStart(source: "compact") after compaction.
308
- await dispatchSessionStart(ctx, "compact");
538
+ pi.on("session_compact", async (event, ctx) => {
539
+ const trigger = compactTrigger(event);
540
+ const payload: HookStdinPayload = {
541
+ ...basePayload(ctx, "PostCompact"),
542
+ trigger,
543
+ compact_summary: event.compactionEntry.summary,
544
+ };
545
+ const outcome = await dispatch(ctx, "PostCompact", { candidates: [trigger] }, payload);
546
+ if (outcome.additionalContext) pendingPromptContext.push({ event: "PostCompact", text: outcome.additionalContext });
547
+ // CC fires SessionStart(source: "compact") after compaction. Awaited inline
548
+ // (not backgrounded), so the current generation is still in force.
549
+ await dispatchSessionStart(ctx, "compact", sessionGen);
309
550
  });
310
551
  }
@@ -25,7 +25,6 @@ const CC_CANONICAL: Record<string, string> = {
25
25
  web_fetch: "WebFetch",
26
26
  web_search: "WebSearch",
27
27
  subagent: "Task",
28
- todo_write: "TodoWrite",
29
28
  skill: "Skill",
30
29
  ask_user_question: "AskUserQuestion",
31
30
  enter_plan_mode: "EnterPlanMode",
@@ -49,6 +48,41 @@ export function ccToolName(nativeName: string): string {
49
48
  return CC_CANONICAL[nativeName] ?? nativeName;
50
49
  }
51
50
 
51
+ /**
52
+ * pi parameter name → Claude Code parameter name, per tool, for the hook stdin
53
+ * payload's `tool_input`. A CC hook reads `.tool_input.file_path`; pi's tools
54
+ * call it `path` (review T4). Tools not listed pass their input through
55
+ * unchanged (bash's `command`/`timeout`/`description`/`run_in_background`
56
+ * already match CC's Bash; MCP tools are their own schema).
57
+ */
58
+ const CC_INPUT_NAMES: Record<string, Record<string, string>> = {
59
+ read: { path: "file_path" },
60
+ edit: { path: "file_path", oldText: "old_string", newText: "new_string" },
61
+ write: { path: "file_path" },
62
+ grep: { ignoreCase: "-i", context: "-C", limit: "head_limit" },
63
+ notebook_edit: { path: "notebook_path" },
64
+ };
65
+
66
+ function renameKeys(input: Record<string, unknown>, names: Record<string, string>): Record<string, unknown> {
67
+ const out: Record<string, unknown> = {};
68
+ for (const [key, value] of Object.entries(input)) out[names[key] ?? key] = value;
69
+ return out;
70
+ }
71
+
72
+ /** A pi tool's input spelled with Claude Code's parameter names. */
73
+ export function ccToolInput(nativeName: string, input: Record<string, unknown>): Record<string, unknown> {
74
+ const names = CC_INPUT_NAMES[nativeName];
75
+ return names ? renameKeys(input, names) : input;
76
+ }
77
+
78
+ /** The inverse: a hook's `updatedInput` (CC names) back to the pi tool's names. */
79
+ export function nativeToolInput(nativeName: string, input: Record<string, unknown>): Record<string, unknown> {
80
+ const names = CC_INPUT_NAMES[nativeName];
81
+ if (!names) return input;
82
+ const inverse = Object.fromEntries(Object.entries(names).map(([pi, cc]) => [cc, pi]));
83
+ return renameKeys(input, inverse);
84
+ }
85
+
52
86
  /** Every spelling a matcher may reasonably target for this tool. */
53
87
  export function toolMatchCandidates(nativeName: string): string[] {
54
88
  return [...new Set([nativeName, ccToolName(nativeName), ...ccAliasesForTool(nativeName)])];
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFileSync, statSync } from "node:fs";
12
- import { discoverPlugins, pluginResources } from "../lib/plugins.ts";
12
+ import { discoverPlugins, pluginResources, type DiscoverRoots } from "../lib/plugins.ts";
13
13
  import { parseHooksBlock, type HooksSource } from "./settings.ts";
14
14
 
15
15
  interface CacheEntry {
@@ -67,9 +67,9 @@ function readPluginHooksFile(hooksConfig: string, pluginName: string, pluginPath
67
67
  return source;
68
68
  }
69
69
 
70
- export function loadPluginHooks(claudeDir: string, diagnostics: string[]): HooksSource[] {
70
+ export function loadPluginHooks(roots: DiscoverRoots, diagnostics: string[]): HooksSource[] {
71
71
  const sources: HooksSource[] = [];
72
- for (const plugin of discoverPlugins(claudeDir).plugins) {
72
+ for (const plugin of discoverPlugins(roots).enabledPlugins) {
73
73
  const { hooksConfig } = pluginResources(plugin);
74
74
  if (!hooksConfig) continue;
75
75
  const source = readPluginHooksFile(hooksConfig, plugin.name, plugin.path, diagnostics);