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.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- 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 {
|
|
42
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
162
|
-
|
|
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)
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
187
|
-
*
|
|
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
|
|
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
|
|
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
|
|
280
|
-
// earlier in the transcript; rebuild the tracker from
|
|
281
|
-
// still in context (since the latest compaction's
|
|
282
|
-
// edits are not refused as "never read" (replay.ts).
|
|
283
|
-
// from
|
|
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
|
-
|
|
299
|
-
|
|
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([...
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
451
|
-
* the
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
-
|
|
467
|
-
|
|
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(
|
|
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
|
/**
|