one-code-extension 0.1.4 → 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 -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,124 @@
1
+ /**
2
+ * The `/doctor` panel (pure): a read-only, scrollable view of the rendered
3
+ * report inside `ctx.ui.custom`, with the key hints on screen (a hidden key is
4
+ * an undiscoverable key). The wiring owns repaint and the fix/close effects.
5
+ */
6
+
7
+ import { panelTopRule } from "../lib/tui-render.ts";
8
+
9
+ export interface DoctorViewerState {
10
+ offset: number;
11
+ }
12
+
13
+ export type DoctorViewerKey =
14
+ | { kind: "up" }
15
+ | { kind: "down" }
16
+ | { kind: "pageUp" }
17
+ | { kind: "pageDown" }
18
+ | { kind: "home" }
19
+ | { kind: "end" }
20
+ | { kind: "fix" }
21
+ | { kind: "close" };
22
+
23
+ export function decodeDoctorKey(data: string): DoctorViewerKey | undefined {
24
+ switch (data) {
25
+ case "\x1b[A":
26
+ case "\x1bOA":
27
+ case "k":
28
+ return { kind: "up" };
29
+ case "\x1b[B":
30
+ case "\x1bOB":
31
+ case "j":
32
+ return { kind: "down" };
33
+ case "\x1b[5~":
34
+ case "\x02": // ctrl+b
35
+ case "b":
36
+ return { kind: "pageUp" };
37
+ case "\x1b[6~":
38
+ case "\x06": // ctrl+f
39
+ case " ":
40
+ return { kind: "pageDown" };
41
+ case "\x1b[H":
42
+ case "\x1b[1~":
43
+ case "g":
44
+ return { kind: "home" };
45
+ case "\x1b[F":
46
+ case "\x1b[4~":
47
+ case "G":
48
+ return { kind: "end" };
49
+ case "f":
50
+ return { kind: "fix" };
51
+ case "\x1b":
52
+ case "\x03": // ctrl+c
53
+ case "q":
54
+ case "\r":
55
+ case "\n":
56
+ return { kind: "close" };
57
+ default:
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ export type DoctorViewerEffect = { kind: "fix" } | { kind: "close" };
63
+
64
+ /** Move the window; returns an effect for keys the wiring must act on. */
65
+ export function applyDoctorKey(state: DoctorViewerState, key: DoctorViewerKey, total: number, visible: number): DoctorViewerEffect | undefined {
66
+ const max = Math.max(0, total - visible);
67
+ const page = Math.max(1, visible - 1);
68
+ switch (key.kind) {
69
+ case "up":
70
+ state.offset = Math.max(0, state.offset - 1);
71
+ return undefined;
72
+ case "down":
73
+ state.offset = Math.min(max, state.offset + 1);
74
+ return undefined;
75
+ case "pageUp":
76
+ state.offset = Math.max(0, state.offset - page);
77
+ return undefined;
78
+ case "pageDown":
79
+ state.offset = Math.min(max, state.offset + page);
80
+ return undefined;
81
+ case "home":
82
+ state.offset = 0;
83
+ return undefined;
84
+ case "end":
85
+ state.offset = max;
86
+ return undefined;
87
+ case "fix":
88
+ return { kind: "fix" };
89
+ case "close":
90
+ return { kind: "close" };
91
+ }
92
+ }
93
+
94
+ export interface DoctorViewerInput {
95
+ /** Already rendered (painted, width-cut) report lines. */
96
+ lines: string[];
97
+ state: DoctorViewerState;
98
+ width: number;
99
+ /** Total rows the panel may occupy, chrome included. */
100
+ height: number;
101
+ /** Whether a model is available, so the `f` hint is shown. */
102
+ canFix: boolean;
103
+ }
104
+
105
+ /** Rows taken by the top rule and the footer hint. */
106
+ export const VIEWER_CHROME_ROWS = 3;
107
+
108
+ export function visibleBodyRows(height: number): number {
109
+ return Math.max(1, height - VIEWER_CHROME_ROWS);
110
+ }
111
+
112
+ export function renderDoctorViewer(input: DoctorViewerInput, paint: (color: string, text: string) => string): string[] {
113
+ const { lines, state, width, height } = input;
114
+ const visible = visibleBodyRows(height);
115
+ const max = Math.max(0, lines.length - visible);
116
+ if (state.offset > max) state.offset = max;
117
+ const window = lines.slice(state.offset, state.offset + visible);
118
+ while (window.length < visible) window.push("");
119
+ const position = lines.length > visible ? ` · ${state.offset + 1}-${Math.min(lines.length, state.offset + visible)} of ${lines.length}` : "";
120
+ // PageUp/PageDown are pi-tui's own scrollback keys in fullscreen mode and never
121
+ // reach a component, so the hint names keys that do.
122
+ const hints = [`↑/↓ scroll · space/b page · g/G top/end${position}`, ...(input.canFix ? ["f run the checkup (the model fixes the findings)"] : []), "esc close"].join(" · ");
123
+ return [panelTopRule(paint, width), ...window, "", paint("dim", ` ${hints}`)];
124
+ }
@@ -21,13 +21,14 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
21
21
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat";
22
22
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
23
  import { REMINDER_CHANNEL } from "../lib/reminders.ts";
24
- import { safeThemePaint } from "../lib/tui-render.ts";
24
+ import { dimRightAlignedWidget, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
25
25
  import {
26
26
  acceptedEffortArgs,
27
27
  choiceForState,
28
28
  decodeKey,
29
29
  type EffortChoice,
30
30
  EFFORT_CHOICES,
31
+ effortNoteText,
31
32
  enabledStops,
32
33
  levelRank,
33
34
  moveIndex,
@@ -38,6 +39,8 @@ import {
38
39
  thinkingLevelFor,
39
40
  ULTRACODE,
40
41
  ULTRACODE_LEVEL,
42
+ ULTRACODE_MODE_CHANNEL,
43
+ ULTRACODE_STATUS_KEY,
41
44
  } from "./slider.ts";
42
45
 
43
46
  /** The levels pi says the active model can reach; the full ladder when unknown. */
@@ -51,7 +54,8 @@ function modelLabel(ctx: ExtensionContext): string {
51
54
  }
52
55
 
53
56
  const REMINDER_KEY = "ultracode-mode";
54
- const STATUS_KEY = "ultracode";
57
+ const NOTE_KEY = "effort-note";
58
+ const NOTE_MS = 5000;
55
59
 
56
60
  /**
57
61
  * The standing instruction for ultracode mode. Deliberately stronger than the
@@ -77,17 +81,44 @@ export default function effortExtension(pi: ExtensionAPI) {
77
81
 
78
82
  const applyStatus = (ctx: ExtensionContext) => {
79
83
  // No-op outside the TUI, so this is safe to call unconditionally.
80
- ctx.ui.setStatus(STATUS_KEY, ultracodeActive ? "✦ ultracode" : undefined);
84
+ ctx.ui.setStatus(ULTRACODE_STATUS_KEY, ultracodeActive ? "✦ ultracode" : undefined);
81
85
  };
82
86
 
87
+ /**
88
+ * Claude Code's effort-changed note: "● high · /effort", right-aligned above
89
+ * the input for a few seconds after the level changes.
90
+ */
91
+ let noteTimer: ReturnType<typeof setTimeout> | undefined;
92
+ const showEffortNote = (ctx: ExtensionContext, level: ThinkingLevel) => {
93
+ if (!ctx.hasUI) return;
94
+ ctx.ui.setWidget(NOTE_KEY, dimRightAlignedWidget(effortNoteText(level, ultracodeActive)), {
95
+ placement: "aboveEditor",
96
+ });
97
+ if (noteTimer) clearTimeout(noteTimer);
98
+ noteTimer = setTimeout(() => ctx.ui.setWidget(NOTE_KEY, undefined), NOTE_MS);
99
+ noteTimer.unref?.();
100
+ };
101
+ // The timer captured the ctx of the turn that changed the level; a /clear or
102
+ // shutdown inside the note's few seconds invalidates that ctx, and pi throws
103
+ // on a stale ctx use. Drop the timer with the session instead.
104
+ const cancelEffortNote = () => {
105
+ if (noteTimer) clearTimeout(noteTimer);
106
+ noteTimer = undefined;
107
+ };
108
+ pi.on("session_start", cancelEffortNote);
109
+ pi.on("session_shutdown", cancelEffortNote);
110
+
83
111
  const setUltracode = (on: boolean, level?: ThinkingLevel) => {
112
+ const changed = ultracodeActive !== on;
84
113
  ultracodeActive = on;
85
114
  ultracodeLevel = on ? level : undefined;
115
+ if (changed) pi.events.emit(ULTRACODE_MODE_CHANNEL, { active: on });
86
116
  if (on) {
87
117
  pi.events.emit(REMINDER_CHANNEL, {
88
118
  text: ULTRACODE_STANDING_REMINDER,
89
119
  scope: "every-turn",
90
120
  key: REMINDER_KEY,
121
+ placement: "sticky-append",
91
122
  });
92
123
  } else {
93
124
  pi.events.emit(REMINDER_CHANNEL, { remove: true, key: REMINDER_KEY });
@@ -102,6 +133,7 @@ export default function effortExtension(pi: ExtensionAPI) {
102
133
 
103
134
  setUltracode(choice === ULTRACODE, actual);
104
135
  applyStatus(ctx);
136
+ showEffortNote(ctx, actual);
105
137
 
106
138
  // setThinkingLevel clamps to model capability silently. Only flag a genuine
107
139
  // downward clamp (the model can't reach the requested level); an upward clamp
@@ -135,7 +167,11 @@ export default function effortExtension(pi: ExtensionAPI) {
135
167
  const chosen = await ctx.ui.custom<EffortChoice | null>((tui, theme, _keybindings, done) => {
136
168
  const paint = safeThemePaint(theme);
137
169
  return {
138
- render: (width: number) => ["", ...renderEffortSlider({ index, enabled, width, modelLabel: label }, paint), ""],
170
+ // The slider's own layout is column-safe for its ASCII labels, but this
171
+ // dialog has no linesComponent wrap, so a final truncate is the safety
172
+ // net that guarantees no overwide line reaches pi-tui (TUI-REVIEW H1/H2).
173
+ render: (width: number) =>
174
+ ["", ...renderEffortSlider({ index, enabled, width, modelLabel: label }, paint), ""].map((line) => truncateLine(line, width)),
139
175
  handleInput: (data: string) => {
140
176
  const key = decodeKey(data);
141
177
  if (!key) return;
@@ -208,6 +244,7 @@ export default function effortExtension(pi: ExtensionAPI) {
208
244
  applyStatus(ctx);
209
245
  ctx.ui.notify(`Ultracode off — reasoning is now ${event.level}`, "info");
210
246
  }
247
+ showEffortNote(ctx, event.level);
211
248
  });
212
249
 
213
250
  pi.on("session_start", (_event, ctx) => {
@@ -22,6 +22,21 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
22
22
  /** The final stop on the track: top reasoning + workflows armed every turn. */
23
23
  export const ULTRACODE = "ultracode";
24
24
 
25
+ /** UI status key the effort extension sets while ultracode is armed; the footer
26
+ * reads it to show "✦ ultracode" in place of the effort level. Shared via this
27
+ * pure module so both extensions reference one constant, not a bare string. */
28
+ export const ULTRACODE_STATUS_KEY = "ultracode";
29
+
30
+ /**
31
+ * Event-bus channel the effort extension emits `{ active: boolean }` on when
32
+ * ultracode mode switches. The workflow extension listens so the keyword
33
+ * one-shot ("the user included ultracode") is skipped while the standing block
34
+ * already says so — two instructions for one fact, the weaker one on top,
35
+ * steered worse than one. Emitted on change only (the bus does not replay);
36
+ * the mode starts off, so a listener registered at load is never behind.
37
+ */
38
+ export const ULTRACODE_MODE_CHANNEL = "one-code:ultracode-mode";
39
+
25
40
  /** The reasoning level ultracode pins, when the model can reach it. */
26
41
  export const ULTRACODE_LEVEL: ThinkingLevel = "xhigh";
27
42
 
@@ -163,8 +178,10 @@ export interface SliderView {
163
178
  }
164
179
 
165
180
  const TITLE = "Effort";
166
- const HINTS = "←/→ adjust · Enter confirm · Esc cancel · shift+tab cycles the plain levels";
167
- const SHORT_HINTS = "←/→ · Enter · Esc";
181
+ const HINTS = "←/→ or h/l adjust · Enter confirm · Esc cancel · shift+tab cycles the plain levels";
182
+ /** Anthropic's cache hierarchy: a thinking/effort change invalidates the message cache once (CACHE-REVIEW-2026-09-04 M4). */
183
+ const CACHE_NOTE = "changing effort re-caches the conversation (one uncached request)";
184
+ const SHORT_HINTS = "←/→ h/l · Enter · Esc";
168
185
  const ULTRACODE_SUBTITLE = `${ULTRACODE_LEVEL} + workflows`;
169
186
 
170
187
  /**
@@ -236,6 +253,7 @@ export function renderEffortSlider(view: SliderView, paint: Paint): string[] {
236
253
  const anyDisabled = stops.some((_, i) => !view.enabled[i]);
237
254
  const note = anyDisabled ? `dimmed = unsupported by ${view.modelLabel ?? "this model"}` : "";
238
255
  if (note) rows.push([note, paint("dim", note)]);
256
+ rows.push([CACHE_NOTE, paint("dim", CACHE_NOTE)]);
239
257
  rows.push(["", ""], [HINTS, paint("dim", HINTS)]);
240
258
 
241
259
  const fits = rows.every(([plainRow]) => [...plainRow].length <= view.width);
@@ -251,3 +269,16 @@ export function renderEffortSlider(view: SliderView, paint: Paint): string[] {
251
269
  paint("dim", clip(SHORT_HINTS)),
252
270
  ];
253
271
  }
272
+
273
+ /**
274
+ * Claude Code's effort-changed note ("● high · /effort"), shown transiently
275
+ * right-aligned above the input after the level changes. Symbols are CC's
276
+ * (constants/figures.ts: ○ low, ◐ medium, ● high, ◉ max); pi's extra rungs
277
+ * map onto the nearest CC level, and ultracode keeps its own ✦ badge.
278
+ */
279
+ export function effortNoteText(level: ThinkingLevel, ultracode: boolean): string {
280
+ if (ultracode) return "✦ ultracode · /effort";
281
+ const symbol =
282
+ level === "medium" ? "◐" : level === "high" ? "●" : level === "xhigh" || level === "max" ? "◉" : "○";
283
+ return `${symbol} ${level} · /effort`;
284
+ }
@@ -12,14 +12,29 @@
12
12
  * that went through bash and never touched an intercepted tool.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync } from "node:fs";
15
+ import { existsSync, readFileSync, statSync } from "node:fs";
16
16
  import { isAbsolute, resolve } from "node:path";
17
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { pathArgument } from "../auto-mode/paths.ts";
18
19
  import { REMINDER_CHANNEL } from "../lib/reminders.ts";
19
- import { describeChanges, EXTERNAL_CHANGE_REMINDER, FileTracker, STALE_REASON, UNREAD_REASON } from "./tracker.ts";
20
+ import { pathsReadOnBranch } from "./replay.ts";
21
+ import {
22
+ describeChanges,
23
+ EXTERNAL_CHANGE_REMINDER,
24
+ type FileStamp,
25
+ FileTracker,
26
+ STALE_REASON,
27
+ UNREAD_REASON,
28
+ } from "./tracker.ts";
20
29
 
21
30
  const GUARDED_TOOLS = new Set(["edit", "write", "notebook_edit"]);
22
31
  const READ_TOOLS = new Set(["read", "notebook_edit"]);
32
+ /**
33
+ * At most this many changed files get the full excerpt reminder per turn; the
34
+ * rest are named in one line. A formatter pass or `git checkout` touching
35
+ * dozens of tracked files otherwise produced dozens of 40-line blocks (review T8).
36
+ */
37
+ const DETAILED_CHANGE_REMINDERS_PER_TURN = 5;
23
38
 
24
39
  function readIfPresent(path: string): string | undefined {
25
40
  if (!existsSync(path)) return undefined;
@@ -31,14 +46,60 @@ function readIfPresent(path: string): string | undefined {
31
46
  }
32
47
  }
33
48
 
49
+ /** The file's disk stamp, or undefined when it is gone (or not a plain file we could stat). */
50
+ function statIfPresent(path: string): FileStamp | undefined {
51
+ try {
52
+ const stat = statSync(path);
53
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ /** Observe a file's current content together with the stamp it was read at. */
60
+ function observeFromDisk(tracker: FileTracker, path: string): void {
61
+ const stamp = statIfPresent(path);
62
+ const current = readIfPresent(path);
63
+ if (current === undefined) tracker.forget(path);
64
+ else tracker.observe(path, current, Date.now(), stamp);
65
+ }
66
+
34
67
  function pathOf(input: unknown, cwd: string): string | undefined {
35
- const raw = (input as { path?: unknown })?.path;
68
+ // Share the field-name knowledge with the auto-mode gates: `path` for pi's
69
+ // built-ins, `file_path`/`notebook_path` for Claude Code-shaped calls. A
70
+ // notebook_edit using only `notebook_path` would otherwise bypass the
71
+ // read-before-write guard (code-review).
72
+ const raw = pathArgument(input as Record<string, unknown> | undefined);
36
73
  if (typeof raw !== "string" || !raw.trim()) return undefined;
37
74
  return isAbsolute(raw) ? raw : resolve(cwd, raw);
38
75
  }
39
76
 
40
77
  export default function fileTrackerExtension(pi: ExtensionAPI) {
41
- const tracker = new FileTracker();
78
+ let tracker = new FileTracker();
79
+ let sessionId: string | undefined;
80
+
81
+ // A resumed session (or a session_tree switch) carries reads the model did
82
+ // earlier in the transcript; rebuild the tracker from the branch so post-
83
+ // resume edits are not refused as "never read" (replay.ts). A new session id
84
+ // starts from an empty tracker.
85
+ const reconstruct = (ctx: ExtensionContext) => {
86
+ const id = ctx.sessionManager.getSessionId?.() ?? undefined;
87
+ if (id !== sessionId) {
88
+ sessionId = id;
89
+ tracker = new FileTracker();
90
+ }
91
+ let entries: unknown[] = [];
92
+ try {
93
+ entries = ctx.sessionManager.getBranch() as unknown[];
94
+ } catch {
95
+ return;
96
+ }
97
+ for (const raw of pathsReadOnBranch(entries)) {
98
+ observeFromDisk(tracker, isAbsolute(raw) ? raw : resolve(ctx.cwd, raw));
99
+ }
100
+ };
101
+ pi.on("session_start", (_event, ctx) => reconstruct(ctx));
102
+ pi.on("session_tree", (_event, ctx) => reconstruct(ctx));
42
103
 
43
104
  pi.on("tool_call", (event, ctx) => {
44
105
  if (!GUARDED_TOOLS.has(event.toolName)) return undefined;
@@ -63,41 +124,103 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
63
124
 
64
125
  // After a read we know the file; after our own write we know it again, so a
65
126
  // successful edit does not make the file look stale to the next edit.
66
- const current = readIfPresent(path);
67
- if (current === undefined) tracker.forget(path);
68
- else tracker.observe(path, current, Date.now());
127
+ observeFromDisk(tracker, path);
69
128
  return undefined;
70
129
  });
71
130
 
72
131
  /**
73
- * Before each turn, report anything that changed under us. Doing it here rather
74
- * than on a watcher keeps it deterministic and costs one stat+read per tracked
75
- * file, only for files the model actually touched.
132
+ * Report anything that changed under us. Costs one stat per tracked file
133
+ * (only files the model actually touched) and a read only for a file whose
134
+ * stamp moved since we last read it; `alreadyNotified` keeps a repeat scan
135
+ * from repeating a warning.
136
+ *
137
+ * Two triggers:
138
+ * - `agent_start`, for changes made between turns. Not `before_agent_start`:
139
+ * pi emits that only from `prompt()`, so a run opened by a harness
140
+ * notification (a `/loop` tick, an agent report arriving while idle) never
141
+ * fired it and changes went unreported until the next user prompt
142
+ * (STEERING-REVIEW-2026-09-05 H1). `agent_start` fires for every run.
143
+ * - `tool_execution_end`, for changes made DURING a turn — a formatter hook,
144
+ * a watcher, format-on-save in the user's editor, a `git checkout` by a
145
+ * background command. Until 2026-09-05 these waited for the next prompt;
146
+ * mid-turn the model discovered them only by hitting the stale-edit guard
147
+ * (one blocked call plus a re-read per edit for anyone with an asynchronous
148
+ * formatter). Claude Code computes its `edited_text_file` attachments on
149
+ * every tool round, so the model reads the change before its next edit
150
+ * (review M5). The one-shot is pending when the next request goes out and
151
+ * the injector pins it to that tool result — pi runs `tool_result` hooks
152
+ * BEFORE it emits `tool_execution_end` (agent-loop.js
153
+ * finalizeExecutedToolCall → emitToolExecutionEnd), so it cannot be written
154
+ * into the stored result; pinned is the same mechanism tool-search's
155
+ * deferred-tool miss uses. Our own edit/write/read of a file was observed by
156
+ * the `tool_result` handler above before this fires, so it is fresh here
157
+ * and never reported as external — but only for a call that ran alone. pi
158
+ * executes a batch of parallel tool calls CONCURRENTLY, so the first edit's
159
+ * end fires while a sibling edit has already written its file and not yet
160
+ * had its `tool_result` observation, and the scan reported the model's own
161
+ * edit as "modified by the user, a linter, or a command"
162
+ * (WEAK-MODEL-REVIEW-2026-09-06 M2). Hence `executing`: mid-turn the scan
163
+ * runs only once the batch has fully drained.
76
164
  */
77
- pi.on("before_agent_start", () => {
165
+ const reportExternalChanges = () => {
166
+ const detailed: string[] = [];
167
+ const overflow: string[] = [];
78
168
  for (const path of tracker.tracked) {
79
169
  const previous = tracker.lastSeen(path);
80
170
  if (previous === undefined || previous === "") continue;
81
- const current = readIfPresent(path);
82
- if (current === undefined) {
171
+ const stamp = statIfPresent(path);
172
+ if (stamp && tracker.unchangedOnDisk(path, stamp)) continue;
173
+ const current = stamp ? readIfPresent(path) : undefined;
174
+ if (stamp === undefined || current === undefined) {
83
175
  tracker.forget(path);
84
- pi.events.emit(REMINDER_CHANNEL, {
85
- text: `${path} no longer exists; it was deleted or moved after you read it.`,
86
- });
176
+ detailed.push(`${path} no longer exists; it was deleted or moved after you read it.`);
87
177
  continue;
88
178
  }
179
+ tracker.recordStamp(path, stamp);
89
180
  if (current === previous) continue;
90
181
  // Don't repeat the same warning every turn…
91
182
  if (tracker.alreadyNotified(path, current)) continue;
92
183
 
93
- const excerpt = describeChanges(previous, current);
94
- if (excerpt) {
95
- pi.events.emit(REMINDER_CHANNEL, { text: EXTERNAL_CHANGE_REMINDER(path, excerpt) });
184
+ if (detailed.length < DETAILED_CHANGE_REMINDERS_PER_TURN) {
185
+ const excerpt = describeChanges(previous, current);
186
+ if (excerpt) detailed.push(EXTERNAL_CHANGE_REMINDER(path, excerpt));
187
+ } else {
188
+ overflow.push(path);
96
189
  }
97
190
  // …but deliberately do NOT record the new content as seen: the file must
98
191
  // stay stale so the edit guard still forces a re-read. Marking it read
99
192
  // here would announce the change and then permit the clobbering edit.
100
193
  tracker.markNotified(path, current);
101
194
  }
195
+ for (const text of detailed) pi.events.emit(REMINDER_CHANNEL, { text });
196
+ if (overflow.length > 0) {
197
+ pi.events.emit(REMINDER_CHANNEL, {
198
+ text: `${overflow.length} more file(s) you read earlier changed on disk since: ${overflow.join(", ")}. Re-read any of them before editing it.`,
199
+ });
200
+ }
201
+ };
202
+ /**
203
+ * Tool calls currently executing, by call id. A batch issued in one assistant
204
+ * message runs concurrently, and only the last call to finish sees every
205
+ * sibling's write already observed.
206
+ */
207
+ const executing = new Set<string>();
208
+ pi.on("tool_execution_start", (event) => {
209
+ executing.add(event.toolCallId);
210
+ return undefined;
211
+ });
212
+ pi.on("agent_start", () => {
213
+ // A turn aborted mid-batch can leave ids behind; each turn starts from a
214
+ // clean set so the mid-turn scan cannot be wedged off for the rest of the
215
+ // session.
216
+ executing.clear();
217
+ reportExternalChanges();
218
+ return undefined;
219
+ });
220
+ pi.on("tool_execution_end", (event) => {
221
+ executing.delete(event.toolCallId);
222
+ if (executing.size > 0) return undefined;
223
+ reportExternalChanges();
224
+ return undefined;
102
225
  });
103
226
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Rebuild read-before-write state from a session branch (pure): which files
3
+ * the model has read or written this conversation, so a `--resume`d session
4
+ * does not block every edit with "has not been read in this conversation" —
5
+ * a claim the model can see is false in its own transcript (review T9).
6
+ *
7
+ * The content the model saw is not in the transcript, so a replayed file is
8
+ * observed with its CURRENT content: an edit after resume is allowed, and a
9
+ * change made while the session was closed is not flagged. That is Claude
10
+ * Code's behaviour too (its read state persists across resume); the stale-edit
11
+ * guard still catches anything that changes from this point on.
12
+ */
13
+
14
+ const READ_TOOLS = new Set(["read", "notebook_edit"]);
15
+ const WRITE_TOOLS = new Set(["edit", "write", "notebook_edit"]);
16
+
17
+ interface ToolCallBlock {
18
+ type: "toolCall";
19
+ id: string;
20
+ name: string;
21
+ arguments?: Record<string, unknown>;
22
+ }
23
+
24
+ /** Paths (as written in the tool input) of every successful read/edit/write on the branch, in order, deduplicated. */
25
+ export function pathsReadOnBranch(entries: unknown[]): string[] {
26
+ const calls = new Map<string, { name: string; path: string }>();
27
+ const seen: string[] = [];
28
+ for (const entry of entries) {
29
+ const e = entry as { type?: string; message?: { role?: string; content?: unknown; toolCallId?: string; toolName?: string; isError?: boolean } };
30
+ if (e?.type !== "message" || !e.message) continue;
31
+ const m = e.message;
32
+ if (m.role === "assistant" && Array.isArray(m.content)) {
33
+ for (const block of m.content as ToolCallBlock[]) {
34
+ if (block?.type !== "toolCall" || !block.id) continue;
35
+ const path = block.arguments?.path;
36
+ if (typeof path === "string" && path.trim()) calls.set(block.id, { name: block.name, path });
37
+ }
38
+ } else if (m.role === "toolResult" && m.toolCallId && !m.isError) {
39
+ const call = calls.get(m.toolCallId);
40
+ if (!call) continue;
41
+ if (!READ_TOOLS.has(call.name) && !WRITE_TOOLS.has(call.name)) continue;
42
+ if (!seen.includes(call.path)) seen.push(call.path);
43
+ }
44
+ }
45
+ return seen;
46
+ }
@@ -18,12 +18,28 @@ export interface TrackedFile {
18
18
  at: number;
19
19
  }
20
20
 
21
+ /** What a stat says about a file on disk; equal stamps mean "no need to re-read". */
22
+ export interface FileStamp {
23
+ mtimeMs: number;
24
+ size: number;
25
+ }
26
+
21
27
  /** Keeps memory bounded on long sessions with large files. */
22
28
  const MAX_TRACKED_FILES = 300;
23
29
  const MAX_TRACKED_BYTES = 512 * 1024;
30
+ /** Stamps younger than this are re-read regardless (coarse-mtime filesystems, same-tick rewrites). */
31
+ export const RACY_STAMP_MS = 2000;
24
32
 
25
33
  export class FileTracker {
26
34
  private files = new Map<string, TrackedFile>();
35
+ /**
36
+ * The disk stamp of the content last compared per path — recorded when we
37
+ * observe a file and again each time the change scan reads it. The scan runs
38
+ * after every tool call, so a full read of every tracked file each time would
39
+ * cost up to 300 reads per call; a stat per file and a read only when the
40
+ * stamp moved keeps the common "nothing changed" case cheap.
41
+ */
42
+ private stamps = new Map<string, FileStamp>();
27
43
  /**
28
44
  * Content we have already warned about, per path. Kept separate from `files`
29
45
  * because telling the model a file changed must NOT make the file count as
@@ -33,8 +49,8 @@ export class FileTracker {
33
49
  */
34
50
  private notified = new Map<string, string>();
35
51
 
36
- /** Record what we currently believe a file contains (after a read or a write). */
37
- observe(path: string, content: string, at: number): void {
52
+ /** Record what we currently believe a file contains (after a read or a write), and its disk stamp when known. */
53
+ observe(path: string, content: string, at: number, stamp?: FileStamp): void {
38
54
  this.notified.delete(path);
39
55
  if (content.length > MAX_TRACKED_BYTES) {
40
56
  // Too large to diff usefully; remember that we saw it, not its content.
@@ -42,15 +58,36 @@ export class FileTracker {
42
58
  } else {
43
59
  this.files.set(path, { content, at });
44
60
  }
61
+ if (stamp) this.stamps.set(path, stamp);
62
+ else this.stamps.delete(path);
45
63
  if (this.files.size > MAX_TRACKED_FILES) {
46
64
  const oldest = [...this.files.entries()].sort((a, b) => a[1].at - b[1].at)[0];
47
- if (oldest) this.files.delete(oldest[0]);
65
+ if (oldest) this.forget(oldest[0]);
48
66
  }
49
67
  }
50
68
 
51
69
  forget(path: string): void {
52
70
  this.files.delete(path);
53
71
  this.notified.delete(path);
72
+ this.stamps.delete(path);
73
+ }
74
+
75
+ /**
76
+ * True when the disk stamp matches the one recorded at the last read of this
77
+ * path: nothing to re-read. A stamp younger than `RACY_STAMP_MS` is never
78
+ * trusted: a same-size rewrite within the filesystem's mtime granularity of
79
+ * our read would leave the stamp unchanged (git's "racy" rule), so recent
80
+ * files take the full read until they age past the window.
81
+ */
82
+ unchangedOnDisk(path: string, stamp: FileStamp, now = Date.now()): boolean {
83
+ if (now - stamp.mtimeMs < RACY_STAMP_MS) return false;
84
+ const known = this.stamps.get(path);
85
+ return known !== undefined && known.mtimeMs === stamp.mtimeMs && known.size === stamp.size;
86
+ }
87
+
88
+ /** The change scan read the file at this stamp; the next scan skips it until the stamp moves. */
89
+ recordStamp(path: string, stamp: FileStamp): void {
90
+ this.stamps.set(path, stamp);
54
91
  }
55
92
 
56
93
  /** True when this exact content has already been reported for this path. */
@@ -141,5 +178,15 @@ export const UNREAD_REASON = (path: string, tool: string) =>
141
178
  export const STALE_REASON = (path: string) =>
142
179
  `${path} has changed on disk since you last read it — someone else, a formatter, or a command may have modified it. Read it again before editing, or your change would overwrite theirs.`;
143
180
 
181
+ /**
182
+ * Claude Code's `edited_text_file` attachment text, with two additions: the
183
+ * cause list also names a command (our tracker sees bash writes too), and the
184
+ * closing clause tells the model to re-read before editing — CC marks the file
185
+ * as read when it attaches the snippet, we deliberately do not (the stale-edit
186
+ * guard keeps firing), so without that clause the model's next edit is blocked
187
+ * and costs a round trip. The "intentional / don't revert / don't tell the
188
+ * user" steer is CC's: without it a model mid-edit tends to restore its own
189
+ * version and narrate linter noise (STEERING-REVIEW-2026-09-05 M5).
190
+ */
144
191
  export const EXTERNAL_CHANGE_REMINDER = (path: string, excerpt: ChangeExcerpt) =>
145
- `${path} was modified externally (by the user, a formatter, or a command) after you last read it. The file now reads as follows around the change (line numbers shown); re-read it if you need the full contents.\n\n${excerpt.text}`;
192
+ `Note: ${path} was modified, either by the user, a linter, or a command, after you last read it. This change was intentional, so make sure to take it into account as you proceed (ie. don't revert it unless the user asks you to). Don't tell the user this, since they are already aware. Here are the relevant changes (shown with line numbers); re-read the file before editing it:\n${excerpt.text}`;