one-code-extension 0.1.4 → 0.3.1

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 +222 -177
  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 +128 -0
  8. package/docs/guide/configuration.md +173 -0
  9. package/docs/guide/differences-from-claude-code.md +152 -0
  10. package/docs/guide/doctor.md +88 -0
  11. package/docs/guide/hooks.md +111 -0
  12. package/docs/guide/installation.md +126 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
  14. package/docs/guide/providers-and-models.md +196 -0
  15. package/docs/guide/reference.md +226 -0
  16. package/docs/guide/sessions-and-context.md +192 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +178 -0
  18. package/docs/guide/subagents-and-workflows.md +195 -0
  19. package/docs/guide/tasks-and-background-work.md +113 -0
  20. package/docs/guide/terminal-interface.md +157 -0
  21. package/docs/guide/tools.md +177 -0
  22. package/docs/guide/troubleshooting.md +177 -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 -217
  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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Pure formatting for the One Code footer — the single status line that
3
+ * replaces pi's built-in footer (`ctx.ui.setFooter`). It surfaces what a user
4
+ * actually watches — context fill, total cost, cache-hit health, the PR, the
5
+ * model, and the effort level — and drops pi's raw ↑/↓/R/W token counters.
6
+ *
7
+ * Everything here is pure and paint-injected so it unit-tests with an identity
8
+ * paint. `paint(color, text)` matches `safeThemePaint`; in tests it returns the
9
+ * text unchanged, so assertions see the plain line. Inclusion decisions use
10
+ * plain lengths only, so the painted result (ANSI adds zero display width)
11
+ * always fits the width it was built for.
12
+ */
13
+
14
+ import { costOf, usageEntryCost } from "../lib/usage-bus.ts";
15
+
16
+ export type Paint = (color: string, text: string) => string;
17
+
18
+ export interface FooterData {
19
+ /** Absolute cwd; `home` is folded to `~` for display. */
20
+ cwd: string;
21
+ home: string;
22
+ branch?: string;
23
+ /** Context tokens in use and the model's window; both needed to show fill. */
24
+ contextTokens?: number;
25
+ contextWindow?: number;
26
+ /** Context percentage, shown in parens after the fill; `null`/undefined omits it
27
+ * (e.g. right after compaction, unknown until the next reply). */
28
+ contextPercent?: number | null;
29
+ /** All-in cost across every LLM call this session (main + subagents + one-shots). */
30
+ cost: number;
31
+ /** Latest turn's cache-hit rate; a per-turn health signal, not cumulative. */
32
+ cacheHitPercent?: number;
33
+ /** Open PR number for the current branch, when a `gh` lookup found one. */
34
+ pr?: number;
35
+ /** Short model name (already abbreviated by the caller). */
36
+ model?: string;
37
+ /** Effort label to show after the model — the level, or "✦ ultracode". */
38
+ effort?: string;
39
+ }
40
+
41
+ /** pi's token formatter, replicated so a "1.1M" here reads like pi's own. */
42
+ export function formatTokens(count: number): string {
43
+ if (count < 1000) return count.toString();
44
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
45
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
46
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
47
+ return `${Math.round(count / 1000000)}M`;
48
+ }
49
+
50
+ /** Cost with cent-fraction precision for small spends, coarser once it grows. */
51
+ export function formatCost(cost: number): string {
52
+ return `$${cost.toFixed(cost < 100 ? 3 : 2)}`;
53
+ }
54
+
55
+ /** Fold the home prefix to `~`. */
56
+ function tilde(cwd: string, home: string): string {
57
+ if (home && (cwd === home || cwd.startsWith(`${home}/`))) return `~${cwd.slice(home.length)}`;
58
+ return cwd;
59
+ }
60
+
61
+ /**
62
+ * Fit a path into `max` columns, keeping the tail (the deepest, most specific
63
+ * folders) with a leading `…`. `max <= 1` collapses to just `…`.
64
+ */
65
+ function truncatePath(path: string, max: number): string {
66
+ if (path.length <= max) return path;
67
+ if (max <= 1) return "…";
68
+ return `…${path.slice(path.length - (max - 1))}`;
69
+ }
70
+
71
+ interface Part {
72
+ plain: string;
73
+ painted: string;
74
+ /** Higher drops first when the line is too narrow; 0 = never dropped. */
75
+ dropPriority: number;
76
+ }
77
+
78
+ const SEP = " · ";
79
+
80
+ /**
81
+ * Build the footer as a single line fit to `width`. When too narrow, the
82
+ * cache-hit rate drops first, then the PR (with the path/branch on the left),
83
+ * then the path itself; context, cost, model, and effort are the core and are
84
+ * kept (truncation is the last resort).
85
+ */
86
+ export function buildFooterLines(data: FooterData, width: number, paint: Paint): string[] {
87
+ if (width <= 0) return [""];
88
+
89
+ const parts: Part[] = [];
90
+ // A labelled metric: the label is muted, the value takes `color`. The plain
91
+ // form (label + value) drives the width/drop math; the painted form colours
92
+ // the two halves separately.
93
+ const push = (label: string, value: string, color: string, dropPriority = 0) => {
94
+ if (!value) return;
95
+ const plain = label ? `${label} ${value}` : value;
96
+ const painted = label ? `${paint("muted", label)} ${paint(color, value)}` : paint(color, value);
97
+ parts.push({ plain, painted, dropPriority });
98
+ };
99
+
100
+ // Context fill as "usage: tokens/window (pct%)".
101
+ if (typeof data.contextTokens === "number" && typeof data.contextWindow === "number" && data.contextWindow > 0) {
102
+ const pct = typeof data.contextPercent === "number" ? ` (${data.contextPercent.toFixed(0)}%)` : "";
103
+ push("usage:", `${formatTokens(data.contextTokens)}/${formatTokens(data.contextWindow)}${pct}`, "dim");
104
+ }
105
+ // Total cost, always shown once known.
106
+ push("cost:", formatCost(data.cost), "dim");
107
+ // Cache-hit: a warning only when it drops low, otherwise dim; droppable.
108
+ if (typeof data.cacheHitPercent === "number") {
109
+ const color = data.cacheHitPercent < 20 ? "error" : data.cacheHitPercent < 50 ? "warning" : "dim";
110
+ push("cache-hit:", `${data.cacheHitPercent.toFixed(0)}%`, color, 2);
111
+ }
112
+ // Model, then effort — no labels; both self-describing.
113
+ if (data.model) push("", data.model, "dim");
114
+ if (data.effort) push("", data.effort, "accent");
115
+
116
+ // Left chunk: path + branch, with the open PR attached to the branch as
117
+ // "⎇ branch ← PR #123". The branch (short, useful) is kept and the path is
118
+ // head-truncated to fit; when the PR would not fit, it is dropped before the
119
+ // branch (bare-branch fallback), and only when there is no room even for the
120
+ // bare branch does the whole chunk go, leaving metrics alone. The three
121
+ // segments paint separately — path dim, branch in accent, PR in the link
122
+ // colour — but every width/gap decision uses their plain lengths.
123
+ const path = tilde(data.cwd, data.home);
124
+ const branchSeg = data.branch ? ` ⎇ ${data.branch}` : "";
125
+ const prSeg = data.branch && typeof data.pr === "number" ? ` ← PR #${data.pr}` : "";
126
+
127
+ // Drop optional right parts (highest priority first) until the metrics fit.
128
+ let kept = parts.slice();
129
+ const rightPlain = () => kept.map((p) => p.plain).join(SEP);
130
+ while (rightPlain().length > width) {
131
+ const droppable = kept
132
+ .map((p, i) => ({ p, i }))
133
+ .filter((x) => x.p.dropPriority > 0)
134
+ .sort((a, b) => b.p.dropPriority - a.p.dropPriority)[0];
135
+ if (!droppable) break;
136
+ kept = kept.filter((_, i) => i !== droppable.i);
137
+ }
138
+
139
+ const rightLen = rightPlain().length;
140
+
141
+ // Even the core overflows: last resort, truncate the plain metrics to width.
142
+ if (rightLen > width) {
143
+ return [paint("dim", rightPlain().slice(0, width))];
144
+ }
145
+
146
+ const rightPaintedStr = kept.map((p) => p.painted).join(paint("dim", SEP));
147
+
148
+ // Room for the left chunk after the metrics and at least one space of gap.
149
+ // Keep the PR only if the branch+PR both fit; otherwise drop it and keep the
150
+ // bare branch.
151
+ const room = width - rightLen - 1;
152
+ const keptPr = room >= branchSeg.length + prSeg.length + 1 ? prSeg : "";
153
+ const suffixLen = branchSeg.length + keptPr.length;
154
+ if (room >= suffixLen + 1) {
155
+ const shownPath = truncatePath(path, room - suffixLen);
156
+ const leftPlain = `${shownPath}${branchSeg}${keptPr}`;
157
+ const gap = " ".repeat(width - leftPlain.length - rightLen);
158
+ const leftPainted = `${paint("dim", shownPath)}${paint("accent", branchSeg)}${paint("mdLink", keptPr)}`;
159
+ return [`${leftPainted}${gap}${rightPaintedStr}`];
160
+ }
161
+ // No room for even the branch: metrics only, right-aligned.
162
+ const pad = " ".repeat(Math.max(0, width - rightLen));
163
+ return [`${pad}${rightPaintedStr}`];
164
+ }
165
+
166
+ /**
167
+ * All-in session cost and latest cache-hit rate, computed from the session
168
+ * entries: the main session exactly as pi's own footer does (assistant
169
+ * messages, tool-result and branch-summary/compaction usage) plus every
170
+ * persisted out-of-band usage entry (`recordUsage`). Reading the ledger from
171
+ * the entries, not process memory, is what keeps the figure honest across a
172
+ * `--continue`/`--session` restart. Kept pure and loosely typed so it tests
173
+ * with plain fixtures.
174
+ */
175
+ export function computeMainUsage(entries: readonly unknown[]): { cost: number; cacheHitPercent?: number } {
176
+ let cost = 0;
177
+ let cacheHitPercent: number | undefined;
178
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
179
+
180
+ for (const raw of entries) {
181
+ const entry = raw as {
182
+ type?: string;
183
+ message?: { role?: string; usage?: Record<string, unknown> };
184
+ usage?: Record<string, unknown>;
185
+ };
186
+ if (entry.type === "message" && entry.message?.role === "assistant" && entry.message.usage) {
187
+ const u = entry.message.usage;
188
+ cost += costOf(u);
189
+ // Only meaningful when the provider actually reported cache activity —
190
+ // otherwise leave it unset so the footer omits the field, matching pi
191
+ // (a non-caching provider must not show a permanent "cache-hit: 0%").
192
+ const cacheRead = num(u.cacheRead);
193
+ const hasCacheActivity = cacheRead > 0 || num(u.cacheWrite) > 0;
194
+ const prompt = num(u.input) + cacheRead + num(u.cacheWrite);
195
+ cacheHitPercent = hasCacheActivity && prompt > 0 ? (cacheRead / prompt) * 100 : undefined;
196
+ } else if (entry.type === "message" && entry.message?.role === "toolResult" && entry.message.usage) {
197
+ cost += costOf(entry.message.usage);
198
+ } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
199
+ cost += costOf(entry.usage);
200
+ } else {
201
+ // Out-of-band calls (subagents, classifier, readers, recap, setup)
202
+ // persisted by recordUsage — the all-in half of the total.
203
+ cost += usageEntryCost(entry);
204
+ }
205
+ }
206
+ return { cost, cacheHitPercent };
207
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * footer extension — replaces pi's built-in status line with One Code's own via
3
+ * `ctx.ui.setFooter`. A single line: path + branch on the left; context fill,
4
+ * all-in cost, cache-hit health, PR number, model, and effort on the right.
5
+ *
6
+ * pi's footer only ever sees the main session's usage. Here the cost figure is a
7
+ * true total: the main session (from its entries, exactly as pi computes it)
8
+ * plus every out-of-band LLM call (in-process subagents, the auto-mode
9
+ * classifier, and the reader-style one-shots — web-fetch, recap, and auto-mode
10
+ * setup — via the shared withReasoningFallback wrapper). Those are persisted as
11
+ * `one-code:usage` session entries by `recordUsage`, so the total survives a
12
+ * `--continue`/`--session` restart; the usage bus is only the live repaint
13
+ * signal. The effort label
14
+ * after the model reads the live thinking level, and swaps to "✦ ultracode" when
15
+ * the effort extension has published that status.
16
+ *
17
+ * Set CC_FOOTER=0 to keep pi's built-in footer instead.
18
+ */
19
+
20
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { linesComponent, safeThemePaint } from "../lib/tui-render.ts";
22
+ import { formatModel, isRealModel } from "../permissions/modes.ts";
23
+ import { ULTRACODE_STATUS_KEY } from "../effort/slider.ts";
24
+ import { USAGE_CHANNEL } from "../lib/usage-bus.ts";
25
+ import { buildFooterLines, computeMainUsage, type FooterData } from "./footer-line.ts";
26
+ import { fetchPrNumber } from "./pr.ts";
27
+
28
+ export default function footerExtension(pi: ExtensionAPI) {
29
+ if (process.env.CC_FOOTER === "0") return;
30
+
31
+ // All-in cost + latest cache-hit, recomputed only when the session's entries
32
+ // change (message_end/agent_end, and a usage entry landing) — an O(n)
33
+ // transcript scan that must NOT run on every repaint (model/effort events
34
+ // change no entry).
35
+ let mainUsage: { cost: number; cacheHitPercent?: number } = { cost: 0 };
36
+ let lastCtx: ExtensionContext | undefined;
37
+ let pr: number | undefined;
38
+ /** Bumped per branch change so a slow gh lookup for an old branch is ignored. */
39
+ let prToken = 0;
40
+
41
+ /** Invalidate the memoized line and repaint; set once the footer mounts. */
42
+ let repaint = () => {};
43
+
44
+ const recomputeMain = (ctx: ExtensionContext) => {
45
+ lastCtx = ctx;
46
+ mainUsage = computeMainUsage(ctx.sessionManager.getEntries());
47
+ };
48
+
49
+ // A usage entry was just appended (recordUsage persists before it emits):
50
+ // re-sum the ledger. Known limitation: a background subagent spawned before
51
+ // a /clear (newSession) keeps reporting after the switch, so its late cost
52
+ // is persisted into — and shown for — the new session. Rare (only a
53
+ // *background* subagent outlives a turn — classifier/reader/recap/setup all
54
+ // finish synchronously), and it only nudges a display figure, so we accept
55
+ // it rather than thread session identity through the bus.
56
+ pi.events.on(USAGE_CHANNEL, () => {
57
+ if (lastCtx) recomputeMain(lastCtx);
58
+ repaint();
59
+ });
60
+
61
+ const refreshPr = (cwd: string, branch: string | null) => {
62
+ const token = ++prToken;
63
+ if (!branch) {
64
+ pr = undefined;
65
+ repaint();
66
+ return;
67
+ }
68
+ void fetchPrNumber(cwd, branch).then((found) => {
69
+ if (token !== prToken) return; // a newer branch superseded this lookup
70
+ pr = found;
71
+ repaint();
72
+ });
73
+ };
74
+
75
+ pi.on("session_start", (_event, ctx) => {
76
+ recomputeMain(ctx);
77
+ if (!ctx.hasUI) return;
78
+
79
+ ctx.ui.setFooter((tui: unknown, theme: unknown, footerData: unknown) => {
80
+ const paint = safeThemePaint(theme);
81
+ const fd = footerData as {
82
+ getGitBranch(): string | null;
83
+ getExtensionStatuses(): ReadonlyMap<string, string>;
84
+ /** Returns the unsubscribe (pi keeps callbacks in a Set for the process lifetime). */
85
+ onBranchChange(cb: () => void): () => void;
86
+ };
87
+
88
+ const snapshot = (): FooterData => {
89
+ const usage = ctx.getContextUsage();
90
+ const ultracode = fd.getExtensionStatuses().get(ULTRACODE_STATUS_KEY);
91
+ const effort = ultracode ?? safeThinkingLevel(ctx);
92
+ const model = ctx.model;
93
+ return {
94
+ cwd: ctx.cwd,
95
+ home: process.env.HOME || process.env.USERPROFILE || "",
96
+ branch: fd.getGitBranch() ?? undefined,
97
+ contextTokens: usage?.tokens ?? undefined,
98
+ contextWindow: usage?.contextWindow,
99
+ contextPercent: usage?.percent,
100
+ cost: mainUsage.cost,
101
+ cacheHitPercent: mainUsage.cacheHitPercent,
102
+ pr,
103
+ model: model && isRealModel(model.id) ? formatModel(model.provider, model.id) : "none",
104
+ effort,
105
+ };
106
+ };
107
+
108
+ const component = linesComponent((width) => buildFooterLines(snapshot(), width, paint));
109
+ repaint = () => {
110
+ component.invalidate();
111
+ (tui as { requestRender?: () => void } | undefined)?.requestRender?.();
112
+ };
113
+
114
+ // The branch drives the PR lookup; seed it now and follow changes. The
115
+ // factory re-runs on every session_start (/clear, /new, resume) and pi
116
+ // only disposes the component, so the subscription must be released
117
+ // here — otherwise each replaced footer keeps firing its `gh pr list`
118
+ // against a stale cwd on every branch change.
119
+ refreshPr(ctx.cwd, fd.getGitBranch());
120
+ const stopFollowingBranch = fd.onBranchChange(() => {
121
+ refreshPr(ctx.cwd, fd.getGitBranch());
122
+ repaint();
123
+ });
124
+
125
+ return Object.assign(component, {
126
+ dispose: () => {
127
+ stopFollowingBranch();
128
+ repaint = () => {};
129
+ },
130
+ });
131
+ });
132
+ });
133
+
134
+ // The main session's own entries changed: refresh the cached main-usage (the
135
+ // only place the O(n) transcript scan runs), then repaint.
136
+ pi.on("message_end", (_event, ctx) => {
137
+ recomputeMain(ctx);
138
+ repaint();
139
+ });
140
+ pi.on("agent_end", (_event, ctx) => {
141
+ recomputeMain(ctx);
142
+ repaint();
143
+ });
144
+ // Model/effort switches change only the label; no main-usage recompute needed.
145
+ pi.on("model_select", () => repaint());
146
+ pi.on("thinking_level_select", () => repaint());
147
+ }
148
+
149
+ /** Current effort/thinking level as a short label, or undefined if unavailable. */
150
+ function safeThinkingLevel(ctx: ExtensionContext): string | undefined {
151
+ return ctx.thinkingLevel;
152
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Open-PR lookup for the footer, mirroring Claude Code's PR indicator. Uses the
3
+ * GitHub CLI (`gh`) so it inherits the user's existing auth; when `gh` is absent
4
+ * or the branch has no open PR it resolves to `undefined` and the footer simply
5
+ * omits the field — same graceful degrade as pi's git-branch watcher.
6
+ *
7
+ * `gh pr list --head <branch>`, not `gh pr view <branch>`: the latter only finds
8
+ * a PR whose head lives in the base repo, so it misses the common case of a PR
9
+ * opened from a fork (`isCrossRepository`), even though `list` on the base repo
10
+ * knows about it. `--state open` keeps a merged/closed PR for the same branch
11
+ * name from showing.
12
+ *
13
+ * The exec lives here but the parse is split out so it unit-tests without a
14
+ * subprocess.
15
+ */
16
+
17
+ import { execFile } from "node:child_process";
18
+
19
+ /**
20
+ * Pull the PR number out of `gh pr list --json number` output — a JSON array of
21
+ * PRs (possibly empty). The first open PR for the branch wins.
22
+ */
23
+ export function parsePrNumber(stdout: string): number | undefined {
24
+ try {
25
+ const data = JSON.parse(stdout) as Array<{ number?: unknown }>;
26
+ if (!Array.isArray(data)) return undefined;
27
+ for (const pr of data) {
28
+ if (pr && typeof pr.number === "number" && Number.isFinite(pr.number)) return pr.number;
29
+ }
30
+ return undefined;
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ const LOOKUP_TIMEOUT_MS = 4000;
37
+
38
+ /**
39
+ * Resolve the open PR number for `branch` in `cwd`, or `undefined`. Never
40
+ * rejects: any failure (no gh, no PR, detached HEAD, timeout) is `undefined`.
41
+ */
42
+ export function fetchPrNumber(cwd: string, branch: string): Promise<number | undefined> {
43
+ return new Promise((resolve) => {
44
+ execFile(
45
+ "gh",
46
+ ["pr", "list", "--head", branch, "--state", "open", "--json", "number"],
47
+ { cwd, timeout: LOOKUP_TIMEOUT_MS, encoding: "utf8" },
48
+ (error, stdout) => {
49
+ resolve(error ? undefined : parsePrNumber(stdout));
50
+ },
51
+ );
52
+ });
53
+ }
@@ -18,14 +18,41 @@
18
18
  * - output capped at 1MB per stream
19
19
  * - stdin errors ignored (a hook that exits without reading stdin — `exit 2`
20
20
  * — would otherwise EPIPE-crash the write)
21
- * - timeout clamped under Node's 2^31-1 ms timer overflow, timer unref'd so a
22
- * pending hook can't keep a one-shot `pi -p` process alive
21
+ * - stdin JSON is newline-terminated: without a trailing "\n" a hook doing
22
+ * `read -r line` sees EOF-before-delimiter and `read` exits 1, so the
23
+ * `if read -r line; then …` branch is silently skipped even though the
24
+ * variable was populated (Claude Code carries the same fix — bug CC-161)
25
+ * - timeout clamped under Node's 2^31-1 ms timer overflow, timer unref'd (it
26
+ * still fires while the process lives; it must not be what keeps it alive)
27
+ * - the child process handle stays REF'd while the hook runs, unless the caller
28
+ * passes `detached` (SessionEnd at shutdown, fire-and-forget). Until
29
+ * 2026-09-05 every hook child was `unref()`d, and in a one-shot run
30
+ * (`pi -p` / `--mode json`) a PreToolUse hook was often the only pending
31
+ * work: the provider's keep-alive socket is idle between the response and
32
+ * the tool's spawn, so nothing ref'd remained but the hook's stdio pipes.
33
+ * When the child exited, the pipe-close callbacks could run before its
34
+ * SIGCHLD-driven exit callback; with the process handle unref'd the loop
35
+ * counted itself empty and node exited 0 mid-tool, silently, before the
36
+ * hook's `close` ever fired — measured at ~55% of runs with a trivial
37
+ * `exit 0` hook (docs/one-shot-lsp-event-loop-drain.md has the same
38
+ * mechanism for the LSP client). A ref'd child holds the loop until `close`.
23
39
  */
24
40
 
25
41
  import { spawn } from "node:child_process";
42
+ import { killProcessTree } from "../lib/process-tree.ts";
26
43
 
27
44
  export interface HookRunResult {
28
- /** null when the process was killed (timeout) or never spawned. */
45
+ /**
46
+ * null when the process was killed (timeout) or never spawned. Normalized on
47
+ * the timeout path rather than taken from `waitpid`: a group SIGKILL is not
48
+ * atomic, so the shell can be scheduled after its foreground child is killed
49
+ * and before its own signal lands, reap the child and exit(128+9) itself —
50
+ * `close` then reports a normal exit of 137 instead of death by signal
51
+ * (roughly 1% of timeouts under load, and never when the shell had exec'd
52
+ * away, leaving no shell to reap; findings §10.20). Callers should still
53
+ * prefer `timedOut`, which says what happened rather than what it looked
54
+ * like, but they no longer have to.
55
+ */
29
56
  exitCode: number | null;
30
57
  timedOut: boolean;
31
58
  /** Set when the child could not be spawned at all. */
@@ -41,6 +68,18 @@ export interface HookRunOptions {
41
68
  timeoutSeconds?: number;
42
69
  /** Exposed to the hook as CLAUDE_PROJECT_DIR; defaults to cwd. */
43
70
  projectDir?: string;
71
+ /**
72
+ * Let the process exit without waiting for this hook. Only for fire-and-forget
73
+ * dispatches at shutdown; an awaited hook must keep the event loop alive or a
74
+ * one-shot run drains mid-await (header). The child is `unref()`d AND spawned
75
+ * without stdout/stderr pipes: `unref` releases the process handle only, and
76
+ * each inherited pipe is a ref'd handle of its own that stays open until the
77
+ * child exits — a `sleep 20` SessionEnd hook held a `-p` run open for the
78
+ * full 20 s (LIFECYCLE-REVIEW-2026-09-06 M4, measured). stdin is still piped
79
+ * for the payload, written and closed at once, and unref'd too. The result's
80
+ * stdout/stderr are therefore empty for a detached hook — nothing reads them.
81
+ */
82
+ detached?: boolean;
44
83
  }
45
84
 
46
85
  const MAX_OUTPUT_BYTES = 1_000_000;
@@ -58,7 +97,7 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
58
97
  child = spawn("/bin/sh", ["-c", command], {
59
98
  cwd: opts.cwd,
60
99
  detached: true,
61
- stdio: ["pipe", "pipe", "pipe"],
100
+ stdio: opts.detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"],
62
101
  env: { ...process.env, CLAUDE_PROJECT_DIR: opts.projectDir ?? opts.cwd },
63
102
  });
64
103
  } catch (error) {
@@ -72,7 +111,12 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
72
111
  });
73
112
  return;
74
113
  }
75
- child.unref();
114
+ if (opts.detached) {
115
+ child.unref();
116
+ // The stdin pipe is the one handle left; it closes as soon as the
117
+ // payload is flushed below, and must not hold the loop meanwhile.
118
+ (child.stdin as { unref?: () => void } | null)?.unref?.();
119
+ }
76
120
 
77
121
  let stdout = "";
78
122
  let stderr = "";
@@ -91,21 +135,10 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
91
135
  child.stdout?.on("data", capture("stdout"));
92
136
  child.stderr?.on("data", capture("stderr"));
93
137
 
94
- const killTree = () => {
95
- // Negative pid = the whole process group the detached shell leads.
96
- try {
97
- if (child.pid) process.kill(-child.pid, "SIGKILL");
98
- } catch {
99
- try {
100
- child.kill("SIGKILL");
101
- } catch {
102
- // Already gone.
103
- }
104
- }
105
- };
106
138
  const timer = setTimeout(() => {
107
139
  timedOut = true;
108
- killTree();
140
+ // The whole process group the detached shell leads (lib/process-tree.ts).
141
+ killProcessTree(child, "SIGKILL");
109
142
  }, timeoutMs);
110
143
  timer.unref();
111
144
 
@@ -120,13 +153,17 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
120
153
  finish({ exitCode: null, timedOut, spawnError: error.message, stdout, stderr });
121
154
  });
122
155
  child.on("close", (code) => {
123
- finish({ exitCode: code, timedOut, stdout, stderr });
156
+ // A killed process reports null here, EXCEPT when the shell outlived the
157
+ // group kill just long enough to reap its child and exit 128+9 itself
158
+ // (see exitCode's contract). Normalizing keeps "we killed it" from ever
159
+ // looking like an ordinary non-zero exit, which fails OPEN downstream.
160
+ finish({ exitCode: timedOut ? null : code, timedOut, stdout, stderr });
124
161
  });
125
162
 
126
163
  // A hook that never reads stdin (e.g. plain `exit 2`) closes the pipe
127
164
  // early; the resulting EPIPE must not take the extension down.
128
165
  child.stdin?.on("error", () => {});
129
- child.stdin?.write(stdinJson);
166
+ child.stdin?.write(stdinJson.endsWith("\n") ? stdinJson : `${stdinJson}\n`);
130
167
  child.stdin?.end();
131
168
  });
132
169
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Telling the model when a PostToolUse hook rewrote the file it just edited.
3
+ *
4
+ * A "formatter" hook is the common shape of PostToolUse, and its rewrite was
5
+ * invisible: this extension loads before `file-tracker` and AWAITS the hook, so
6
+ * by the time the tracker observes the file the hook's version is already on
7
+ * disk and gets recorded as the model's own write. With sequential edits the
8
+ * model was told nothing at all (3/3 runs, zero reminders); with parallel edits
9
+ * it was told by a race, about the wrong file. A tiny-tier model then showed
10
+ * the user its pre-hook text from memory (WEAK-MODEL-REVIEW-2026-09-06 M1).
11
+ *
12
+ * Claude Code has a dedicated notice for exactly this, delivered as hook
13
+ * context on the edit's own result. This module is the deterministic detector
14
+ * behind ours: snapshot the target before the hooks run, compare after.
15
+ */
16
+
17
+ import { readFileSync, statSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import { isWritingTool, pathArgument } from "../auto-mode/paths.ts";
20
+
21
+ /**
22
+ * Reading the whole file twice per edit is fine for source files and wrong for
23
+ * a large generated one; past this size the comparison falls back to the disk
24
+ * stamp, which a rewrite moves anyway.
25
+ */
26
+ const MAX_COMPARE_BYTES = 2 * 1024 * 1024;
27
+
28
+ /**
29
+ * What a file looked like at snapshot time: its content, or its stamp when too
30
+ * large. `undefined` is "nothing comparable" — missing, binary or unreadable —
31
+ * which every caller already treats as "make no claim".
32
+ */
33
+ export type FileSnapshot = { kind: "content"; value: string } | { kind: "stamp"; value: string };
34
+
35
+ /**
36
+ * The absolute path a file tool is acting on, or undefined for any other tool.
37
+ * The tool set and the `path`/`file_path` field names come from `auto-mode/paths.ts`,
38
+ * the one place that knows them, so a new writing tool is added once.
39
+ */
40
+ export function fileToolTarget(toolName: string, input: unknown, cwd: string): string | undefined {
41
+ if (!isWritingTool(toolName)) return undefined;
42
+ const raw = pathArgument(input as Record<string, unknown> | undefined);
43
+ if (!raw?.trim()) return undefined;
44
+ return resolve(cwd, raw);
45
+ }
46
+
47
+ export function snapshotFile(path: string): FileSnapshot | undefined {
48
+ try {
49
+ const stat = statSync(path);
50
+ if (stat.size > MAX_COMPARE_BYTES) return { kind: "stamp", value: `${stat.mtimeMs}:${stat.size}` };
51
+ return { kind: "content", value: readFileSync(path, "utf-8") };
52
+ } catch {
53
+ // Missing, binary, or unreadable: nothing we could compare meaningfully.
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ /** True when the file differs from the snapshot. An unreadable file compares equal (nothing to claim). */
59
+ export function changedSince(path: string, before: FileSnapshot | undefined): boolean {
60
+ if (!before) return false;
61
+ const after = snapshotFile(path);
62
+ if (!after) return false;
63
+ // A kind change means the file crossed the MAX_COMPARE_BYTES boundary between
64
+ // snapshots — the size changed, so the file changed. (Comparing values across
65
+ // kinds is meaningless: a stamp is never equal to file content.)
66
+ if (after.kind !== before.kind) return true;
67
+ return after.value !== before.value;
68
+ }
69
+
70
+ /**
71
+ * Claude Code's wording, with our tool names: the model must know the bytes on
72
+ * disk are no longer the bytes it wrote, without being sent into a revert (the
73
+ * hook's change is intentional).
74
+ */
75
+ export const FORMATTER_NOTICE = (path: string) =>
76
+ `PostToolUse hook modified ${path} after your edit (likely a formatter). The change was intentional — do not revert it. Your next edit will not fail with a stale-file error, but if its old text targets a region the hook reformatted, read the file first, and read it before reporting the file's contents to the user.`;