one-code-extension 0.5.0 → 0.6.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 (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -38,9 +38,8 @@
38
38
  * for an anthropic-messages endpoint you have confirmed accepts it.
39
39
  */
40
40
 
41
- import { readFileSync, statSync } from "node:fs";
42
- import { join } from "node:path";
43
- import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
41
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
42
+ import { isAnthropicOAuth } from "../lib/anthropic-auth.ts";
44
43
  import { looksLikeAnthropicRequest } from "../lib/anthropic-payload.ts";
45
44
  import { CACHE_RETENTION_ENV, mainSessionCacheRetention } from "../lib/cache-retention.ts";
46
45
 
@@ -93,9 +92,16 @@ export function anthropicBetas(oauth: boolean, compat: AnthropicModelCompat | un
93
92
  // tool-search); re-exported so existing importers keep working.
94
93
  export { looksLikeAnthropicRequest } from "../lib/anthropic-payload.ts";
95
94
 
96
- /** The edit is rejected unless thinking is enabled or the model is adaptive. */
95
+ /**
96
+ * The edit is rejected unless thinking is on: enabled or adaptive in the
97
+ * request, or left to an adaptive model's default. A request that turns
98
+ * thinking off (`{ type: "disabled" }`, "off" effort on Opus 5 and Haiku 5.5)
99
+ * takes no edit, adaptive model or not (round-2 review H3).
100
+ */
97
101
  export function clearThinkingApplies(payload: Record<string, unknown>, forceAdaptiveThinking: boolean): boolean {
98
- return payload.thinking !== undefined || forceAdaptiveThinking;
102
+ const type = (payload.thinking as { type?: unknown } | undefined)?.type;
103
+ if (type === undefined) return payload.thinking === undefined && forceAdaptiveThinking;
104
+ return type === "enabled" || type === "adaptive";
99
105
  }
100
106
 
101
107
  export function withClearThinking(payload: Record<string, unknown>): Record<string, unknown> {
@@ -109,29 +115,6 @@ export function withClearThinking(payload: Record<string, unknown>): Record<stri
109
115
  };
110
116
  }
111
117
 
112
- /** mtime cache: this runs on every provider request, the hottest path there is. */
113
- let authCache: { mtimeMs: number; oauth: boolean } | undefined;
114
-
115
- /** OAuth logins need pi's identity betas kept in the header we overwrite. */
116
- export function isAnthropicOAuth(): boolean {
117
- // getAgentDir() honours PI_CODING_AGENT_DIR, so an isolated one-code app
118
- // reads its own auth.json rather than ~/.pi's.
119
- const path = join(getAgentDir(), "auth.json");
120
- try {
121
- const mtimeMs = statSync(path).mtimeMs;
122
- const cached = authCache;
123
- if (cached && cached.mtimeMs === mtimeMs) return cached.oauth;
124
- const auth = JSON.parse(readFileSync(path, "utf8"));
125
- const entry = auth?.anthropic;
126
- const oauth = entry?.type === "oauth" || typeof entry?.refresh === "string";
127
- authCache = { mtimeMs, oauth };
128
- return oauth;
129
- } catch {
130
- authCache = undefined;
131
- return false;
132
- }
133
- }
134
-
135
118
  interface AnthropicishModel {
136
119
  api?: string;
137
120
  provider?: string;
@@ -21,12 +21,14 @@
21
21
  * same shape the permissions extension uses for plan mode.
22
22
  */
23
23
 
24
+ import os from "node:os";
24
25
  import { join } from "node:path";
25
26
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
26
27
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat";
27
28
  import { getAgentDir, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
28
29
  import { effortSetMessage, isPersistedEffort, type PersistedEffort } from "./settings.ts";
29
- import { REMINDER_CHANNEL } from "../lib/reminders.ts";
30
+ import { REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest, stickyFact, toldBlockFact } from "../lib/reminders.ts";
31
+ import { readState, recordState } from "../lib/ledger/state.ts";
30
32
  import { dimRightAlignedWidget, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
31
33
  import {
32
34
  acceptedEffortArgs,
@@ -49,6 +51,7 @@ import {
49
51
  ULTRACODE_STATUS_KEY,
50
52
  } from "./slider.ts";
51
53
  import { registerLocalCommand } from "../lib/local-command.ts";
54
+ import { readWorkflowSettings } from "../lib/one-code-settings.ts";
52
55
  import { panelUi } from "../lib/panel-focus.ts";
53
56
  import { extendingCompletions } from "../lib/argument-hints.ts";
54
57
 
@@ -78,6 +81,28 @@ const ULTRACODE_STANDING_REMINDER =
78
81
  "Token cost is not the constraint here; the most exhaustive correct answer is. " +
79
82
  "Work solo only on conversational turns and trivial mechanical edits.";
80
83
 
84
+ /**
85
+ * Said once when ultracode is off while the model has read its standing block:
86
+ * the earlier messages keep the block in the record, so without this the model
87
+ * reads the opt-in as standing (lifecycle-grid-hunt round 3, R3-M21).
88
+ */
89
+ const ULTRACODE_OFF_REMINDER =
90
+ "Ultracode mode is off now: the user's standing opt-in to multi-agent orchestration has ended. Run a workflow only when the task calls for one or the user asks.";
91
+ /**
92
+ * The told key of the standing block: a request that carries its first
93
+ * delivery records the block's hash under it (system-reminder).
94
+ */
95
+ const STANDING_TOLD_KEY = stickyFact({ key: REMINDER_KEY, text: "", since: 0 }).key;
96
+ /**
97
+ * What the off notice tells, under the same key: the model last read "off".
98
+ * The standing block switched on again is then a first delivery once more,
99
+ * and a later off is told again only after that delivery.
100
+ */
101
+ const OFF_TOLD = toldBlockFact(STANDING_TOLD_KEY, ULTRACODE_OFF_REMINDER);
102
+
103
+ /** The `one-code:state` key ultracode is recorded under on the branch. */
104
+ const ULTRACODE_STATE = "ultracode";
105
+
81
106
  export default function effortExtension(pi: ExtensionAPI) {
82
107
  let ultracodeActive = false;
83
108
  /**
@@ -117,15 +142,22 @@ export default function effortExtension(pi: ExtensionAPI) {
117
142
  pi.on("session_start", cancelEffortNote);
118
143
  pi.on("session_shutdown", cancelEffortNote);
119
144
 
120
- const setUltracode = (on: boolean, level?: ThinkingLevel) => {
121
- const changed = ultracodeActive !== on;
145
+ /** Whether workflows are on for this session, read with workflow's own settings at session start. */
146
+ let workflowsOn = true;
147
+ /** `record` false when the state comes from the branch itself (a resume, a /tree switch). */
148
+ const setUltracode = (on: boolean, level?: ThinkingLevel, { record = true }: { record?: boolean } = {}) => {
149
+ const changed = ultracodeActive !== on || (on && ultracodeLevel !== level);
122
150
  ultracodeActive = on;
123
151
  ultracodeLevel = on ? level : undefined;
124
152
  if (changed) pi.events.emit(ULTRACODE_MODE_CHANNEL, { active: on });
125
- if (on) {
153
+ // Ultracode follows the branch: recorded on every change.
154
+ if (changed && record) recordState(pi, ULTRACODE_STATE, on ? { on, level } : { on });
155
+ // With workflows turned off (enableWorkflows/disableWorkflows) the block's
156
+ // "run a workflow for every task" would steer to a tool the session does
157
+ // not offer, so ultracode is only its reasoning level there (R3-M21).
158
+ if (on && workflowsOn) {
126
159
  pi.events.emit(REMINDER_CHANNEL, {
127
160
  text: ULTRACODE_STANDING_REMINDER,
128
- scope: "every-turn",
129
161
  key: REMINDER_KEY,
130
162
  placement: "sticky-append",
131
163
  });
@@ -134,6 +166,18 @@ export default function effortExtension(pi: ExtensionAPI) {
134
166
  }
135
167
  };
136
168
 
169
+ // The off notice derives from what the epoch told, when a request goes out:
170
+ // only a model that read the standing block hears it ended, a switch off and
171
+ // on again before the request says nothing, and the notice's own told fact
172
+ // keeps a /reload or a resume from saying it twice.
173
+ pi.events.on(RENDER_CHANNEL, (data) => {
174
+ const request = data as RenderRequest;
175
+ if (request.epoch || ultracodeActive) return;
176
+ const told = request.toldBlocks.get(STANDING_TOLD_KEY);
177
+ if (told === undefined || told === OFF_TOLD.hash) return;
178
+ request.blocks.push({ text: ULTRACODE_OFF_REMINDER, facts: [OFF_TOLD] });
179
+ });
180
+
137
181
  const canPersist = (ctx: ExtensionContext) => ctx.hasUI && (ctx.mode === "tui" || ctx.mode === "rpc") && !!ctx.model;
138
182
  /** Through pi's own settings writer: its key format, file lock and merge of changed fields. */
139
183
  const persist = async (ctx: ExtensionContext, level: PersistedEffort | undefined): Promise<string | undefined> => {
@@ -250,7 +294,7 @@ export default function effortExtension(pi: ExtensionAPI) {
250
294
  const error = await persist(ctx, undefined);
251
295
  if (error) return report(ctx, error, true);
252
296
  }
253
- // getSettings arrived in pi 1.0.1; the package still admits older pi.
297
+ // getSettings arrived in pi 1.0.1; a 1.0.0 install falls back to pi's default.
254
298
  pi.setThinkingLevel(pi.getSettings?.().defaultThinkingLevel ?? "medium");
255
299
  setUltracode(false);
256
300
  applyStatus(ctx);
@@ -301,7 +345,26 @@ export default function effortExtension(pi: ExtensionAPI) {
301
345
  showEffortNote(ctx, event.level);
302
346
  });
303
347
 
304
- pi.on("session_start", (_event, ctx) => {
348
+ /**
349
+ * Take the branch's ultracode state (a resume, /reload, a fork, a /tree
350
+ * switch). At a session start it is on only while pi's reasoning level,
351
+ * which pi restores from the branch itself, is still the level ultracode
352
+ * settled on (a launch `--thinking` flag wins). pi restores no level at a
353
+ * /tree switch, so there the branch's level is put back with the mode.
354
+ * When the branch's model read it on and it resolves off, the next request
355
+ * tells it (the RENDER_CHANNEL listener above).
356
+ */
357
+ const followBranch = (ctx: ExtensionContext, tree: boolean) => {
358
+ const stored = readState(ctx, ULTRACODE_STATE) as { on?: unknown; level?: unknown } | undefined;
359
+ const storedOn = stored?.on === true && typeof stored.level === "string";
360
+ const on = storedOn && (tree || stored.level === pi.getThinkingLevel());
361
+ setUltracode(on, on ? (stored?.level as ThinkingLevel) : undefined, { record: false });
362
+ if (on && tree && pi.getThinkingLevel() !== stored?.level) pi.setThinkingLevel(stored?.level as ThinkingLevel);
305
363
  applyStatus(ctx);
364
+ };
365
+ pi.on("session_start", (_event, ctx) => {
366
+ workflowsOn = readWorkflowSettings(ctx.cwd, os.homedir()).enabled;
367
+ followBranch(ctx, false);
306
368
  });
369
+ pi.on("session_tree", (_event, ctx) => followBranch(ctx, true));
307
370
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { CHILD_WROTE_CHANNEL, type ChildWrote } from "../lib/child-writes.ts";
20
+ import { createHash } from "node:crypto";
20
21
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
22
  import { homedir } from "node:os";
22
23
  import { join } from "node:path";
@@ -35,7 +36,7 @@ import { REMINDER_CHANNEL, type ReminderPayload } from "../lib/reminders.ts";
35
36
  import { resolveToolPath } from "../lib/tool-path.ts";
36
37
  import { parseRules, ruleMatches } from "../permissions/matcher.ts";
37
38
  import { loadPermissionSettings } from "../permissions/settings.ts";
38
- import { keptReadPaths, keptShellReadPaths, lastTouchesOnBranch, pathsReadOnBranch, shellCallsOnBranch } from "./replay.ts";
39
+ import { type FileObservation, filesSeenOnBranch, keptReadPaths, keptShellReadPaths, shellCallsOnBranch } from "./replay.ts";
39
40
  import { type RestoredRead, restoreBlocks, restoreCandidates } from "./restore.ts";
40
41
  import { fileToolResultContent } from "./results.ts";
41
42
  import { shellReadPaths, shownInFull } from "./shell-reads.ts";
@@ -158,13 +159,24 @@ function observeShellReads(tracker: FileTracker, before: Map<string, string>, ou
158
159
  /** Above this a shell read's output is persisted, not shown, so the file cannot have been seen whole. */
159
160
  const MAX_SHELL_READ_BYTES = 256 * 1024;
160
161
 
161
- /** Observe a file's current content together with the stamp it was read at, and return that stamp. */
162
- function observeFromDisk(tracker: FileTracker, path: string): FileStamp | undefined {
162
+ /**
163
+ * Observe a file's current content together with the stamp it was read at.
164
+ * Returns that stamp, and what was seen for the result that read it
165
+ * (undefined when the file is gone).
166
+ */
167
+ function observeFromDisk(tracker: FileTracker, path: string): { stamp?: FileStamp; seen?: FileObservation } {
163
168
  const stamp = statIfPresent(path);
164
169
  const current = readIfPresent(path);
165
- if (current === undefined) tracker.forget(path);
166
- else tracker.observe(path, current, Date.now(), stamp);
167
- return stamp;
170
+ if (current === undefined) {
171
+ tracker.forget(path);
172
+ return stamp ? { stamp } : {};
173
+ }
174
+ tracker.observe(path, current, Date.now(), stamp);
175
+ return stamp ? { stamp, seen: { path, size: stamp.size, mtimeMs: stamp.mtimeMs, sha256: sha256(current) } } : {};
176
+ }
177
+
178
+ function sha256(text: string): string {
179
+ return createHash("sha256").update(text).digest("hex");
168
180
  }
169
181
 
170
182
  function pathOf(input: unknown, cwd: string): string | undefined {
@@ -183,9 +195,9 @@ function pathOf(input: unknown, cwd: string): string | undefined {
183
195
  * The files the context still shows after a compaction, as comparable paths:
184
196
  * the context stack (the CLAUDE.md family as claude-context discovers it, what
185
197
  * they `@`-import, and the auto-memory index), the plan file, the files a
186
- * `read` in the kept turns returned (`kept`, as written) and the files a kept
187
- * bash call showed in full (`keptShell`, absolute). Claude Code's restore
188
- * skips them; they stay read.
198
+ * `read` in the kept turns returned (`kept`) and the files a kept bash call
199
+ * showed in full (`keptShell`), both absolute as recorded. Claude Code's
200
+ * restore skips them; they stay read.
189
201
  */
190
202
  function inContextFiles(ctx: ExtensionContext, branch: readonly { type: string; customType?: string; data?: unknown }[], kept: string[], keptShell: string[]): Set<string> {
191
203
  const home = homedir();
@@ -195,7 +207,7 @@ function inContextFiles(ctx: ExtensionContext, branch: readonly { type: string;
195
207
  includeExternal: readExternalIncludesApproval(ctx.cwd, home).approved,
196
208
  }).flatMap((file) => [file.path, ...(file.imported ?? [])]);
197
209
  return new Set(
198
- [...stack, autoMemoryEnabled(ctx.cwd) ? join(projectMemoryDir(ctx.cwd, home), "MEMORY.md") : undefined, planFileOnBranch(branch), ...kept.map((path) => resolveToolPath(path, ctx.cwd)), ...keptShell]
210
+ [...stack, autoMemoryEnabled(ctx.cwd) ? join(projectMemoryDir(ctx.cwd, home), "MEMORY.md") : undefined, planFileOnBranch(branch), ...kept, ...keptShell]
199
211
  .filter((path): path is string => typeof path === "string")
200
212
  .map(comparablePath),
201
213
  );
@@ -255,8 +267,8 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
255
267
  let sessionId: string | undefined;
256
268
  /**
257
269
  * Every file a read or write touched on this branch, with its modification
258
- * time as stat'ed then (the transcript time for one replayed after a resume):
259
- * the post-compaction restore's order.
270
+ * time as stat'ed then (as the result recorded it, for one replayed after a
271
+ * resume): the post-compaction restore's order.
260
272
  */
261
273
  let touched = new Map<string, number>();
262
274
  /**
@@ -272,15 +284,18 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
272
284
  /** Observe a file the model read or wrote, and record it as touched at its current modification time. */
273
285
  const touch = (path: string) => {
274
286
  childWrites.delete(path);
275
- const stamp = observeFromDisk(tracker, path);
287
+ const { stamp, seen } = observeFromDisk(tracker, path);
276
288
  if (stamp) touched.set(path, stamp.mtimeMs);
289
+ return seen;
277
290
  };
278
291
 
279
- // A resumed session (or a session_tree switch) carries reads the model did
280
- // earlier in the transcript; rebuild the tracker from the part of the branch
281
- // still in context (since the latest compaction's kept tail) so post-resume
282
- // edits are not refused as "never read" (replay.ts). A new session id starts
283
- // from an empty tracker.
292
+ // A resumed session (or a /reload, a fork, a session_tree switch) carries
293
+ // reads the model did earlier in the transcript; rebuild the tracker from
294
+ // the part of the branch still in context (since the latest compaction's
295
+ // kept tail) so later edits are not refused as "never read" (replay.ts).
296
+ // Everything comes from the branch, as the results and pending entries
297
+ // recorded it, never from this instance: pi runs the factory again on each
298
+ // of those. A new session id starts from an empty tracker.
284
299
  const reconstruct = (ctx: ExtensionContext, fresh = false) => {
285
300
  const id = ctx.sessionManager.getSessionId?.() ?? undefined;
286
301
  if (fresh || id !== sessionId) {
@@ -295,8 +310,31 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
295
310
  } catch {
296
311
  return;
297
312
  }
298
- for (const raw of pathsReadOnBranch(entries)) {
299
- observeFromDisk(tracker, resolveToolPath(raw, ctx.cwd));
313
+ // What each result or restore saw, recorded at the time and by the path
314
+ // the tool resolved: a file that changed since is stale, never taken as
315
+ // read in its current form (round-2 review H1), and a gone one untracked.
316
+ const files = filesSeenOnBranch(entries);
317
+ const changed: Pick<FileObservation, "path" | "sha256">[] = [];
318
+ for (const [path, { seen, notified }] of files) {
319
+ const stamp = statIfPresent(path);
320
+ const current = readIfPresent(path);
321
+ if (current === undefined) continue;
322
+ const hash = sha256(current);
323
+ if (hash === seen.sha256) {
324
+ tracker.observe(path, current, Date.now(), stamp);
325
+ continue;
326
+ }
327
+ tracker.observeChanged(path, Date.now());
328
+ // A version a notice already named is not announced again (round-3 review L9).
329
+ if (!notified.has(hash)) changed.push({ path, sha256: hash });
330
+ }
331
+ if (changed.length > 0) {
332
+ // Unkeyed: a key would let this notice replace an undelivered earlier one
333
+ // whose versions the branch already counts as told.
334
+ pi.events.emit(REMINDER_CHANNEL, {
335
+ text: `These files changed on disk since you last read them in this conversation: ${changed.map((file) => file.path).join(", ")}. Read them again before editing them.`,
336
+ notified: changed,
337
+ } satisfies ReminderPayload);
300
338
  }
301
339
  // Shell reads need the bash grammar, which may still be loading this early.
302
340
  const target = tracker;
@@ -309,7 +347,7 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
309
347
  for (const call of shellCalls) recordShellReads(call.id, observeShellReads(target, shellReadSnapshot(call.command, sessionWorkCwd(entered, ctx.cwd)), call.output));
310
348
  });
311
349
  }
312
- touched = new Map([...lastTouchesOnBranch(entries)].map(([raw, at]) => [resolveToolPath(raw, ctx.cwd), at]));
350
+ touched = new Map([...files].map(([path, { seen }]) => [path, seen.mtimeMs]));
313
351
  };
314
352
  pi.on("session_start", (_event, ctx) => reconstruct(ctx));
315
353
  // A branch switch keeps the session id, but the reads of the branch left
@@ -363,7 +401,8 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
363
401
 
364
402
  // After a read we know the file; after our own write we know it again, so a
365
403
  // successful edit does not make the file look stale to the next edit.
366
- if (!event.isError) touch(path);
404
+ // What the model saw, on the result: a resume compares it with the disk.
405
+ const observation = event.isError ? undefined : touch(path);
367
406
  const content = fileToolResultContent({
368
407
  toolName: event.toolName,
369
408
  isError: event.isError,
@@ -373,20 +412,27 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
373
412
  existedBefore,
374
413
  isEmptyFile: () => statIfPresent(path)?.size === 0,
375
414
  });
376
- return content ? { content } : undefined;
415
+ if (!content && !observation) return undefined;
416
+ return {
417
+ ...(content ? { content } : {}),
418
+ ...(observation ? { details: { ...((event.details as Record<string, unknown> | undefined) ?? {}), oneCodeFile: observation } } : {}),
419
+ };
377
420
  });
378
421
 
379
422
  /**
380
423
  * Claude Code's post-compaction file restore (`restore.ts`). The
381
- * blocks open the next user message (`user-prepend`); an overflow compaction
382
- * that retries the turn has no such message, so they ride the request's tail
383
- * instead.
424
+ * blocks open the next user message (`user-prepend`); a request that opens
425
+ * with none (the rest of a run after a mid-turn compaction, an overflow
426
+ * retry) carries them on its tail (`orTail`), so the model sees a restored
427
+ * file before it can edit it. A later compaction drops an undelivered one
428
+ * (`untilCompaction`): it restores for the epoch the one before it opened.
384
429
  *
385
430
  * Like Claude Code, the compaction also clears what the session has read:
386
431
  * the summary no longer holds those files, so an edit needs a fresh read.
387
432
  * A file the context still shows stays read (a kept turn's read or whole-file
388
433
  * shell read, the context stack, the plan file), and a restored file counts
389
- * as read and as touched for the next compaction's restore; a note never
434
+ * as read and as touched for the next compaction's restore, here and after
435
+ * a reload or a resume (its block records what it showed); a note never
390
436
  * marks its file read. Contents come back only for a file the gate would
391
437
  * let through without a question (`restoreAllowed`).
392
438
  */
@@ -416,10 +462,29 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
416
462
  const candidates = restoreCandidates(before, (path) => shown(path) || !allowed(path));
417
463
  const read = createReadToolDefinition(ctx.cwd);
418
464
  const reads: RestoredRead[] = await Promise.all(candidates.map(async (path) => ({ path, text: await readAgain(read, path, ctx) })));
465
+ const shownText = new Map(reads.map(({ path, text }) => [path, text]));
419
466
  const { blocks, restored } = restoreBlocks(reads, estimateTextTokens);
420
- const placement = event.willRetry ? "last-append" : "user-prepend";
421
- for (const text of blocks) pi.events.emit(REMINDER_CHANNEL, { text, placement } satisfies ReminderPayload);
422
- for (const path of restored) touch(path);
467
+ // The block that shows a file records what it showed, so a reload or a
468
+ // resume counts the file read exactly where the branch holds the block
469
+ // (replay.ts filesSeenOnBranch). The file counts as read only when the
470
+ // disk now holds exactly what the block shows: one that changed while
471
+ // it was read back (even back to where it started) fails safe, and a
472
+ // fresh read is asked for instead of passing the stale-edit guard.
473
+ const seen = new Map(
474
+ restored.map((path) => {
475
+ const observed = touch(path);
476
+ const text = shownText.get(path);
477
+ if (observed && (text === undefined || sha256(text) !== observed.sha256)) {
478
+ tracker.forget(path);
479
+ return [path, undefined] as const;
480
+ }
481
+ return [path, observed] as const;
482
+ }),
483
+ );
484
+ for (const { text, restores } of blocks) {
485
+ const observation = restores ? seen.get(restores) : undefined;
486
+ pi.events.emit(REMINDER_CHANNEL, { text, placement: "user-prepend", orTail: true, untilCompaction: true, ...(observation ? { observations: [observation] } : {}) } satisfies ReminderPayload);
487
+ }
423
488
  } catch {
424
489
  // The restore is best effort: nothing comes back, and an edit needs a fresh read.
425
490
  tracker = new FileTracker();
@@ -447,12 +512,10 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
447
512
  * (one blocked call plus a re-read per edit for anyone with an asynchronous
448
513
  * formatter). Claude Code computes its `edited_text_file` attachments on
449
514
  * every tool round, so the model reads the change before its next edit
450
- * (review M5). The one-shot is pending when the next request goes out and
451
- * the injector pins it to that tool result — pi runs `tool_result` hooks
452
- * BEFORE it emits `tool_execution_end` (agent-loop.js
453
- * finalizeExecutedToolCall → emitToolExecutionEnd), so it cannot be written
454
- * into the stored result; pinned is the same mechanism tool-search's
455
- * deferred-tool miss uses. Our own edit/write/read of a file was observed by
515
+ * (review M5). The one-shot is a pending entry when the next request goes
516
+ * out, and the renderer places it on that request's trailing tool result
517
+ * (lib/reminders.ts), the same mechanism tool-search's deferred-tool miss
518
+ * uses; reminders never enter a stored result. Our own edit/write/read of a file was observed by
456
519
  * the `tool_result` handler above before this fires, so it is fresh here
457
520
  * and never reported as external — but only for a call that ran alone. pi
458
521
  * executes a batch of parallel tool calls CONCURRENTLY, so the first edit's
@@ -463,8 +526,10 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
463
526
  * runs only once the batch has fully drained.
464
527
  */
465
528
  const reportExternalChanges = () => {
466
- const detailed: string[] = [];
467
- const overflow: string[] = [];
529
+ // Each notice records the versions it names on its pending entry, so a
530
+ // reload or a resume does not announce them again (replay.ts).
531
+ const detailed: { text: string; notified?: Pick<FileObservation, "path" | "sha256"> }[] = [];
532
+ const overflow: Pick<FileObservation, "path" | "sha256">[] = [];
468
533
  for (const path of tracker.tracked) {
469
534
  // Undefined for a file tracked without content (oversized); an empty
470
535
  // file is compared like any other.
@@ -481,7 +546,7 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
481
546
  const current = stamp ? readIfPresent(path) : undefined;
482
547
  if (stamp === undefined || current === undefined) {
483
548
  tracker.forget(path);
484
- detailed.push(`${path} no longer exists; it was deleted or moved after you last read or wrote it.`);
549
+ detailed.push({ text: `${path} no longer exists; it was deleted or moved after you last read or wrote it.` });
485
550
  continue;
486
551
  }
487
552
  tracker.recordStamp(path, stamp);
@@ -489,22 +554,24 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
489
554
  // Don't repeat the same warning every turn…
490
555
  if (tracker.alreadyNotified(path, current)) continue;
491
556
 
557
+ const version = { path, sha256: sha256(current) };
492
558
  if (detailed.length < DETAILED_CHANGE_REMINDERS_PER_TURN) {
493
559
  const excerpt = describeChanges(previous, current);
494
- if (excerpt) detailed.push(byChild ? CHILD_CHANGE_REMINDER(path, childAgent, excerpt) : EXTERNAL_CHANGE_REMINDER(path, excerpt));
560
+ if (excerpt) detailed.push({ text: byChild ? CHILD_CHANGE_REMINDER(path, childAgent, excerpt) : EXTERNAL_CHANGE_REMINDER(path, excerpt), notified: version });
495
561
  } else {
496
- overflow.push(path);
562
+ overflow.push(version);
497
563
  }
498
564
  // …but deliberately do NOT record the new content as seen: the file must
499
565
  // stay stale so the edit guard still forces a re-read. Marking it read
500
566
  // here would announce the change and then permit the clobbering edit.
501
567
  tracker.markNotified(path, current);
502
568
  }
503
- for (const text of detailed) pi.events.emit(REMINDER_CHANNEL, { text });
569
+ for (const { text, notified } of detailed) pi.events.emit(REMINDER_CHANNEL, { text, ...(notified ? { notified: [notified] } : {}) } satisfies ReminderPayload);
504
570
  if (overflow.length > 0) {
505
571
  pi.events.emit(REMINDER_CHANNEL, {
506
- text: `${overflow.length} more file(s) you read earlier changed on disk since: ${overflow.join(", ")}. Re-read any of them before editing it.`,
507
- });
572
+ text: `${overflow.length} more file(s) you read earlier changed on disk since: ${overflow.map((file) => file.path).join(", ")}. Re-read any of them before editing it.`,
573
+ notified: overflow,
574
+ } satisfies ReminderPayload);
508
575
  }
509
576
  };
510
577
  /**