one-code-extension 0.1.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 (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The `/effort` slider (pure): choices, key decoding, and layout.
3
+ *
4
+ * Claude Code presents reasoning effort as a Faster→Smarter track with
5
+ * `ultracode` sitting past the top, separated by a divider because it is not
6
+ * simply "more thinking" — it is the top reasoning level plus standing workflow
7
+ * orchestration.
8
+ *
9
+ * The plain stops are pi's own thinking ladder, deliberately *all* of it: pi
10
+ * reserves shift+tab for `app.thinking.cycle` and refuses to let an extension
11
+ * rebind it, so that key keeps cycling the full ladder no matter what we do.
12
+ * A slider offering a different set would mean two dials disagreeing about the
13
+ * same number, so this one covers the same stops and adds ultracode. Everything
14
+ * user-facing calls it "effort"; "thinking level" is pi's internal name for it.
15
+ *
16
+ * Rendering and key handling live here so the layout is unit-testable without a
17
+ * terminal; `index.ts` only wires it to pi.
18
+ */
19
+
20
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
21
+
22
+ /** The final stop on the track: top reasoning + workflows armed every turn. */
23
+ export const ULTRACODE = "ultracode";
24
+
25
+ /** The reasoning level ultracode pins, when the model can reach it. */
26
+ export const ULTRACODE_LEVEL: ThinkingLevel = "xhigh";
27
+
28
+ /** pi's thinking ladder, Faster→Smarter — the same stops shift+tab cycles. */
29
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
30
+
31
+ /** Track stops: pi's ladder, then ultracode past the divider. */
32
+ export const EFFORT_CHOICES = [...THINKING_LEVELS, ULTRACODE] as const;
33
+ export type EffortChoice = (typeof EFFORT_CHOICES)[number];
34
+
35
+ export function isEffortChoice(value: string): value is EffortChoice {
36
+ return (EFFORT_CHOICES as readonly string[]).includes(value);
37
+ }
38
+
39
+ /** Parse a typed argument (`/effort xhigh`). Returns undefined if unrecognised. */
40
+ export function parseEffortArg(arg: string): EffortChoice | undefined {
41
+ const value = arg.trim().toLowerCase();
42
+ return value && isEffortChoice(value) ? value : undefined;
43
+ }
44
+
45
+ export function acceptedEffortArgs(): string[] {
46
+ return [...EFFORT_CHOICES];
47
+ }
48
+
49
+ /** The thinking level a choice maps to; ultracode pins the top plus a mode. */
50
+ export function thinkingLevelFor(choice: EffortChoice): ThinkingLevel {
51
+ return choice === ULTRACODE ? ULTRACODE_LEVEL : (choice as ThinkingLevel);
52
+ }
53
+
54
+ /** Which choice a current thinking level should preselect on the track. */
55
+ export function choiceForState(level: ThinkingLevel | undefined, ultracodeActive: boolean): EffortChoice {
56
+ if (ultracodeActive) return ULTRACODE;
57
+ if (level && isEffortChoice(level)) return level;
58
+ return "medium";
59
+ }
60
+
61
+ export type SliderKey = "left" | "right" | "first" | "last" | "confirm" | "cancel";
62
+
63
+ /**
64
+ * Decode a raw terminal chunk. `Component.handleInput` hands over bytes, not key
65
+ * names, so the sequences are matched directly: CSI C/D for arrows (plus the
66
+ * older SS3 form some terminals still send), CR/LF to confirm, ESC to cancel.
67
+ */
68
+ export function decodeKey(data: string): SliderKey | undefined {
69
+ switch (data) {
70
+ case "\x1b[C":
71
+ case "\x1bOC":
72
+ case "l":
73
+ return "right";
74
+ case "\x1b[D":
75
+ case "\x1bOD":
76
+ case "h":
77
+ return "left";
78
+ case "\x1b[H":
79
+ case "\x1b[1~":
80
+ return "first";
81
+ case "\x1b[F":
82
+ case "\x1b[4~":
83
+ return "last";
84
+ case "\r":
85
+ case "\n":
86
+ return "confirm";
87
+ case "\x1b":
88
+ case "\x03": // ctrl+c — same intent as escape while a picker is focused
89
+ return "cancel";
90
+ default:
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Step the marker to the next *enabled* stop in a direction, skipping levels the
97
+ * current model can't reach so the marker can only ever land on a selectable
98
+ * stop. `first`/`last` jump to the first/last enabled stop.
99
+ */
100
+ export function moveIndex(index: number, key: SliderKey, enabled: boolean[]): number {
101
+ switch (key) {
102
+ case "left":
103
+ for (let i = index - 1; i >= 0; i--) if (enabled[i]) return i;
104
+ return index;
105
+ case "right":
106
+ for (let i = index + 1; i < enabled.length; i++) if (enabled[i]) return i;
107
+ return index;
108
+ case "first": {
109
+ const first = enabled.indexOf(true);
110
+ return first >= 0 ? first : index;
111
+ }
112
+ case "last": {
113
+ const last = enabled.lastIndexOf(true);
114
+ return last >= 0 ? last : index;
115
+ }
116
+ default:
117
+ return index;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Which track stops the active model can reach, aligned to `EFFORT_CHOICES`. A
123
+ * plain level is enabled when the model lists it as supported; `ultracode` is
124
+ * enabled whenever the model can reason at all (its pinned level clamps to the
125
+ * model's ceiling). Unsupported stops stay on the track — dimmed and skipped by
126
+ * navigation — rather than being hidden: shift+tab (pi's own dial, which we
127
+ * can't rebind) still cycles the supported plain levels, so a slider that
128
+ * dropped stops would just disagree with it. We show the whole ladder and grey
129
+ * out what this model can't do.
130
+ */
131
+ export function enabledStops(supported: readonly ThinkingLevel[]): boolean[] {
132
+ const set = new Set(supported);
133
+ const canReason = supported.some((level) => level !== "off");
134
+ return EFFORT_CHOICES.map((choice) => (choice === ULTRACODE ? canReason : set.has(choice as ThinkingLevel)));
135
+ }
136
+
137
+ /** Snap an index onto the nearest enabled stop, preferring the higher side on a tie. */
138
+ export function nearestEnabled(index: number, enabled: boolean[]): number {
139
+ if (enabled[index]) return index;
140
+ for (let d = 1; d < enabled.length; d++) {
141
+ if (index + d < enabled.length && enabled[index + d]) return index + d;
142
+ if (index - d >= 0 && enabled[index - d]) return index - d;
143
+ }
144
+ const first = enabled.indexOf(true);
145
+ return first >= 0 ? first : index;
146
+ }
147
+
148
+ /** Rank of a plain level on the ladder (off=0 … max), or -1 if not a plain level. */
149
+ export function levelRank(level: ThinkingLevel): number {
150
+ return THINKING_LEVELS.indexOf(level);
151
+ }
152
+
153
+ export type Paint = (color: string, text: string) => string;
154
+
155
+ export interface SliderView {
156
+ index: number;
157
+ /** Which stops the active model can reach, aligned to `EFFORT_CHOICES`. */
158
+ enabled: boolean[];
159
+ /** Terminal width, so a narrow pane degrades instead of wrapping. */
160
+ width: number;
161
+ /** Model name for the "unsupported" note; omitted falls back to "this model". */
162
+ modelLabel?: string;
163
+ }
164
+
165
+ const TITLE = "Effort";
166
+ const HINTS = "←/→ adjust · Enter confirm · Esc cancel · shift+tab cycles the plain levels";
167
+ const SHORT_HINTS = "←/→ · Enter · Esc";
168
+ const ULTRACODE_SUBTITLE = `${ULTRACODE_LEVEL} + workflows`;
169
+
170
+ /**
171
+ * One label cell per stop, padded so the marker row lines up under the label
172
+ * it belongs to. The divider before `ultracode` is what tells the user it is a
173
+ * different kind of thing rather than one more notch of thinking.
174
+ */
175
+ function cells(): { label: string; divider: boolean }[] {
176
+ return EFFORT_CHOICES.map((choice) => ({ label: choice, divider: choice === ULTRACODE }));
177
+ }
178
+
179
+ export function renderEffortSlider(view: SliderView, paint: Paint): string[] {
180
+ const stops = cells();
181
+ const gap = 2;
182
+ const divider = " │ ";
183
+
184
+ // Lay the label row out first, recording where each label starts, so the
185
+ // track and marker rows can be built against real column positions.
186
+ let labels = "";
187
+ const positions: number[] = [];
188
+ for (const [i, stop] of stops.entries()) {
189
+ if (i > 0) labels += stop.divider ? divider : " ".repeat(gap);
190
+ positions.push([...labels].length);
191
+ labels += stop.label;
192
+ }
193
+ const plainWidth = [...labels].length;
194
+
195
+ const selected = stops[view.index];
196
+ const markerColumn = positions[view.index] + Math.floor([...selected.label].length / 2);
197
+
198
+ const trackRow = Array.from({ length: plainWidth }, () => "─");
199
+ // Keep the divider visible on the track itself, not just under the labels.
200
+ const dividerIndex = stops.findIndex((s) => s.divider);
201
+ if (dividerIndex > 0) {
202
+ const at = positions[dividerIndex] - 2;
203
+ if (at >= 0 && at < trackRow.length) trackRow[at] = "│";
204
+ }
205
+
206
+ const styledLabels = stops
207
+ .map((stop, i) => {
208
+ // Disabled stops are dimmed even when unselected, so an unsupported level
209
+ // reads as greyed-out rather than merely inactive.
210
+ const color = i === view.index ? "accent" : view.enabled[i] ? "muted" : "dim";
211
+ return { text: paint(color, stop.label), divider: stop.divider };
212
+ })
213
+ .reduce((acc, cur, i) => acc + (i > 0 ? (cur.divider ? paint("dim", divider) : " ".repeat(gap)) : "") + cur.text, "");
214
+
215
+ const endsRow = `${paint("dim", "Faster")}${" ".repeat(Math.max(1, plainWidth - "Faster".length - "Smarter".length))}${paint("dim", "Smarter")}`;
216
+ const markerRow = `${" ".repeat(markerColumn)}${paint("accent", "▲")}`;
217
+ const subtitleRow =
218
+ selected.label === ULTRACODE
219
+ ? `${" ".repeat(Math.max(0, positions[stops.length - 1]))}${paint("dim", ULTRACODE_SUBTITLE)}`
220
+ : "";
221
+
222
+ // Rows are (plain content, styled content) pairs: nothing wraps for us, and a
223
+ // wrapped line would shift the marker out from under its label, so widths are
224
+ // measured on the unstyled text — escape codes would inflate the count.
225
+ const rows: [plain: string, styled: string][] = [
226
+ [TITLE, paint("accent", TITLE)],
227
+ ["", ""],
228
+ [`Faster${" ".repeat(Math.max(1, plainWidth - 13))}Smarter`, endsRow],
229
+ [trackRow.join(""), paint("muted", trackRow.join(""))],
230
+ [`${" ".repeat(markerColumn)}▲`, markerRow],
231
+ [labels, styledLabels],
232
+ ];
233
+ if (subtitleRow) rows.push([`${" ".repeat(Math.max(0, positions[stops.length - 1]))}${ULTRACODE_SUBTITLE}`, subtitleRow]);
234
+ // When the model can't reach every stop, say so — the dimming alone doesn't
235
+ // explain itself, and it points at shift+tab for the ones we grey out.
236
+ const anyDisabled = stops.some((_, i) => !view.enabled[i]);
237
+ const note = anyDisabled ? `dimmed = unsupported by ${view.modelLabel ?? "this model"}` : "";
238
+ if (note) rows.push([note, paint("dim", note)]);
239
+ rows.push(["", ""], [HINTS, paint("dim", HINTS)]);
240
+
241
+ const fits = rows.every(([plainRow]) => [...plainRow].length <= view.width);
242
+ if (fits) return rows.map(([, styled]) => styled);
243
+
244
+ // Too narrow for the track (or for the hint line): drop to one stop list and
245
+ // the shortest usable hint, both clipped, rather than let anything wrap.
246
+ const clip = (text: string) => [...text].slice(0, view.width).join("");
247
+ const list = stops.map((s) => s.label);
248
+ return [
249
+ paint("accent", clip(TITLE)),
250
+ clip(list.join(" · ")).replace(list[view.index], (m) => paint("accent", m)),
251
+ paint("dim", clip(SHORT_HINTS)),
252
+ ];
253
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * exit extension — Claude Code's `/exit`.
3
+ *
4
+ * pi's built-in quit command is `/quit`; Claude Code's is `/exit` (with `quit`
5
+ * shown as an alias). A user coming from Claude Code types `/exit` out of habit
6
+ * and, without this, it isn't a command — so it goes to the model as a message
7
+ * instead of quitting. This registers `/exit` alongside pi's `/quit`, routed
8
+ * through the same graceful path (`ctx.shutdown()` — "Gracefully shutdown pi and
9
+ * exit", the same call `/quit` makes).
10
+ *
11
+ * We cannot reproduce Claude Code's single `/exit (quit)` palette entry that is
12
+ * findable by either word: pi has no command aliases and its command palette
13
+ * fuzzy-matches the command NAME only (`autocomplete.ts` — the description is
14
+ * displayed but never searched). So `/exit` and the built-in `/quit` are two
15
+ * separate entries, each found by its own name; both quit. That's the behaviour
16
+ * a Claude Code user needs, without patching pi.
17
+ */
18
+
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+
21
+ export default function exitExtension(pi: ExtensionAPI) {
22
+ pi.registerCommand("exit", {
23
+ description: "Quit One Code (same as /quit)",
24
+ handler: async (_args, ctx) => {
25
+ ctx.shutdown();
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * file-tracker extension — Claude Code's read-before-write discipline.
3
+ *
4
+ * - `edit`/`write`/`notebook_edit` on a file that exists but was never read is
5
+ * blocked with an instruction to read it first.
6
+ * - An edit to a file that changed since we last saw it is blocked, so the model
7
+ * cannot overwrite someone else's change.
8
+ * - Files that change out of band are reported in a `<system-reminder>` with the
9
+ * new content around the change, line-numbered.
10
+ *
11
+ * Tracking is by content, not by tool call, which is what makes it catch writes
12
+ * that went through bash and never touched an intercepted tool.
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { isAbsolute, resolve } from "node:path";
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { REMINDER_CHANNEL } from "../lib/reminders.ts";
19
+ import { describeChanges, EXTERNAL_CHANGE_REMINDER, FileTracker, STALE_REASON, UNREAD_REASON } from "./tracker.ts";
20
+
21
+ const GUARDED_TOOLS = new Set(["edit", "write", "notebook_edit"]);
22
+ const READ_TOOLS = new Set(["read", "notebook_edit"]);
23
+
24
+ function readIfPresent(path: string): string | undefined {
25
+ if (!existsSync(path)) return undefined;
26
+ try {
27
+ return readFileSync(path, "utf-8");
28
+ } catch {
29
+ // Binary or unreadable: not something we can reason about, so don't guard it.
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function pathOf(input: unknown, cwd: string): string | undefined {
35
+ const raw = (input as { path?: unknown })?.path;
36
+ if (typeof raw !== "string" || !raw.trim()) return undefined;
37
+ return isAbsolute(raw) ? raw : resolve(cwd, raw);
38
+ }
39
+
40
+ export default function fileTrackerExtension(pi: ExtensionAPI) {
41
+ const tracker = new FileTracker();
42
+
43
+ pi.on("tool_call", (event, ctx) => {
44
+ if (!GUARDED_TOOLS.has(event.toolName)) return undefined;
45
+ const path = pathOf(event.input, ctx.cwd);
46
+ if (!path) return undefined;
47
+
48
+ const current = readIfPresent(path);
49
+ const status = tracker.status(path, current);
50
+
51
+ // Creating a new file needs no prior read.
52
+ if (status === "absent" || status === "fresh") return undefined;
53
+ if (status === "unread") return { block: true, reason: UNREAD_REASON(path, event.toolName) };
54
+ return { block: true, reason: STALE_REASON(path) };
55
+ });
56
+
57
+ pi.on("tool_result", (event, ctx) => {
58
+ if (event.isError) return undefined;
59
+ if (!READ_TOOLS.has(event.toolName) && !GUARDED_TOOLS.has(event.toolName)) return undefined;
60
+
61
+ const path = pathOf(event.input, ctx.cwd);
62
+ if (!path) return undefined;
63
+
64
+ // After a read we know the file; after our own write we know it again, so a
65
+ // 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());
69
+ return undefined;
70
+ });
71
+
72
+ /**
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.
76
+ */
77
+ pi.on("before_agent_start", () => {
78
+ for (const path of tracker.tracked) {
79
+ const previous = tracker.lastSeen(path);
80
+ if (previous === undefined || previous === "") continue;
81
+ const current = readIfPresent(path);
82
+ if (current === undefined) {
83
+ 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
+ });
87
+ continue;
88
+ }
89
+ if (current === previous) continue;
90
+ // Don't repeat the same warning every turn…
91
+ if (tracker.alreadyNotified(path, current)) continue;
92
+
93
+ const excerpt = describeChanges(previous, current);
94
+ if (excerpt) {
95
+ pi.events.emit(REMINDER_CHANNEL, { text: EXTERNAL_CHANGE_REMINDER(path, excerpt) });
96
+ }
97
+ // …but deliberately do NOT record the new content as seen: the file must
98
+ // stay stale so the edit guard still forces a re-read. Marking it read
99
+ // here would announce the change and then permit the clobbering edit.
100
+ tracker.markNotified(path, current);
101
+ }
102
+ });
103
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * File freshness tracking (pure).
3
+ *
4
+ * Claude Code refuses to edit a file the model hasn't read, refuses to write over
5
+ * one it hasn't seen, rejects an edit when the file changed after the read, and
6
+ * reports out-of-band changes with a line-numbered excerpt. Together these stop
7
+ * the model from clobbering someone else's edit — including its own edits made
8
+ * through bash, which no tool hook can see.
9
+ *
10
+ * This module holds the state machine; `index.ts` wires it to pi's events.
11
+ */
12
+
13
+ export type FreshnessStatus = "fresh" | "unread" | "stale" | "absent";
14
+
15
+ export interface TrackedFile {
16
+ /** Content as of the last read or write we performed. */
17
+ content: string;
18
+ at: number;
19
+ }
20
+
21
+ /** Keeps memory bounded on long sessions with large files. */
22
+ const MAX_TRACKED_FILES = 300;
23
+ const MAX_TRACKED_BYTES = 512 * 1024;
24
+
25
+ export class FileTracker {
26
+ private files = new Map<string, TrackedFile>();
27
+ /**
28
+ * Content we have already warned about, per path. Kept separate from `files`
29
+ * because telling the model a file changed must NOT make the file count as
30
+ * read: Claude Code reports the change *and* still requires a fresh read
31
+ * before editing. Storing it here suppresses repeat warnings without
32
+ * clearing the stale state.
33
+ */
34
+ private notified = new Map<string, string>();
35
+
36
+ /** Record what we currently believe a file contains (after a read or a write). */
37
+ observe(path: string, content: string, at: number): void {
38
+ this.notified.delete(path);
39
+ if (content.length > MAX_TRACKED_BYTES) {
40
+ // Too large to diff usefully; remember that we saw it, not its content.
41
+ this.files.set(path, { content: "", at });
42
+ } else {
43
+ this.files.set(path, { content, at });
44
+ }
45
+ if (this.files.size > MAX_TRACKED_FILES) {
46
+ const oldest = [...this.files.entries()].sort((a, b) => a[1].at - b[1].at)[0];
47
+ if (oldest) this.files.delete(oldest[0]);
48
+ }
49
+ }
50
+
51
+ forget(path: string): void {
52
+ this.files.delete(path);
53
+ this.notified.delete(path);
54
+ }
55
+
56
+ /** True when this exact content has already been reported for this path. */
57
+ alreadyNotified(path: string, content: string): boolean {
58
+ return this.notified.get(path) === content;
59
+ }
60
+
61
+ markNotified(path: string, content: string): void {
62
+ this.notified.set(path, content);
63
+ }
64
+
65
+ has(path: string): boolean {
66
+ return this.files.has(path);
67
+ }
68
+
69
+ lastSeen(path: string): string | undefined {
70
+ return this.files.get(path)?.content;
71
+ }
72
+
73
+ get tracked(): string[] {
74
+ return [...this.files.keys()];
75
+ }
76
+
77
+ /**
78
+ * `absent` — the file does not exist (a write creates it, which is fine)
79
+ * `unread` — it exists but we have never looked at it
80
+ * `stale` — it changed since we last saw it
81
+ * `fresh` — what we last saw matches what is on disk
82
+ */
83
+ status(path: string, currentContent: string | undefined): FreshnessStatus {
84
+ if (currentContent === undefined) return "absent";
85
+ const known = this.files.get(path);
86
+ if (!known) return "unread";
87
+ // Oversized files are tracked without content; treat them as fresh rather
88
+ // than blocking edits we cannot reason about.
89
+ if (known.content === "" && currentContent !== "") return "fresh";
90
+ return known.content === currentContent ? "fresh" : "stale";
91
+ }
92
+ }
93
+
94
+ export interface ChangeExcerpt {
95
+ firstChangedLine: number;
96
+ text: string;
97
+ }
98
+
99
+ /**
100
+ * Renders the changed region of a file the way Claude Code does: the *new* lines,
101
+ * numbered, with a little context. Deliberately not a full diff — the model needs
102
+ * to know what the file says now, not the history of how it got there.
103
+ */
104
+ export function describeChanges(
105
+ previous: string,
106
+ current: string,
107
+ options: { context?: number; maxLines?: number } = {},
108
+ ): ChangeExcerpt | undefined {
109
+ if (previous === current) return undefined;
110
+
111
+ const contextLines = options.context ?? 2;
112
+ const maxLines = options.maxLines ?? 40;
113
+ const before = previous.split("\n");
114
+ const after = current.split("\n");
115
+
116
+ let prefix = 0;
117
+ while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix++;
118
+
119
+ let suffix = 0;
120
+ while (
121
+ suffix < before.length - prefix &&
122
+ suffix < after.length - prefix &&
123
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
124
+ ) {
125
+ suffix++;
126
+ }
127
+
128
+ const start = Math.max(0, prefix - contextLines);
129
+ const end = Math.min(after.length, after.length - suffix + contextLines);
130
+ const shown = after.slice(start, end).slice(0, maxLines);
131
+
132
+ const numbered = shown.map((line, index) => `${start + index + 1}\t${line}`).join("\n");
133
+ const truncated = end - start > maxLines ? `\n… ${end - start - maxLines} more changed lines` : "";
134
+
135
+ return { firstChangedLine: prefix + 1, text: `${numbered}${truncated}` };
136
+ }
137
+
138
+ export const UNREAD_REASON = (path: string, tool: string) =>
139
+ `Read ${path} before using ${tool} on it. The file exists and has not been read in this conversation, so an edit could silently discard content you have not seen.`;
140
+
141
+ export const STALE_REASON = (path: string) =>
142
+ `${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
+
144
+ 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}`;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Hook observability, mirroring auto-mode's: CC_HOOKS_DEBUG=1 for live
3
+ * per-invocation lines, and an opt-in JSONL (`hooks-decisions.jsonl` next to
4
+ * the approval store) for after-the-fact "what fired and what did it decide".
5
+ * Logging must never break a hook's fail-open guarantee, so append failures
6
+ * are swallowed (same stance as auto-mode/decision-log.ts).
7
+ */
8
+
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { oneCodeStateDir } from "../lib/paths.ts";
12
+
13
+ export interface HookLogEntry {
14
+ ts: string;
15
+ sessionId?: string;
16
+ event: string;
17
+ scope: string;
18
+ command: string;
19
+ decision: "allow" | "block" | "context" | "error";
20
+ reason?: string;
21
+ exitCode: number | null;
22
+ durationMs: number;
23
+ }
24
+
25
+ export function hooksDebugEnabled(): boolean {
26
+ const value = process.env.CC_HOOKS_DEBUG;
27
+ return value === "1" || value === "2";
28
+ }
29
+
30
+ const COMMAND_LIMIT = 120;
31
+
32
+ export function clipCommand(command: string): string {
33
+ const flat = command.replace(/\s+/g, " ").trim();
34
+ return flat.length > COMMAND_LIMIT ? `${flat.slice(0, COMMAND_LIMIT)}…` : flat;
35
+ }
36
+
37
+ export function formatDebugLine(entry: Omit<HookLogEntry, "ts" | "sessionId">): string {
38
+ const reason = entry.reason ? ` — ${entry.reason}` : "";
39
+ return `[hooks] ${entry.event} (${entry.scope}) ${clipCommand(entry.command)} → ${entry.decision}${reason} (${entry.durationMs}ms)`;
40
+ }
41
+
42
+ export function hooksLogPath(): string {
43
+ return join(oneCodeStateDir(), "hooks", "hooks-decisions.jsonl");
44
+ }
45
+
46
+ export function appendHookLog(entry: HookLogEntry, file = hooksLogPath()): void {
47
+ try {
48
+ mkdirSync(dirname(file), { recursive: true });
49
+ appendFileSync(file, `${JSON.stringify(entry)}\n`);
50
+ } catch {
51
+ // Never break a hook over its diary.
52
+ }
53
+ }