@yagni-app/code-staging 1.1.4-staging.1386.1 → 1.1.4-staging.1387.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,7 @@ import * as os from "node:os";
28
28
  import * as path from "node:path";
29
29
  import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, getAgentDir, getLanguageFromPath, highlightCode, renderDiff, SettingsManager, } from "@earendil-works/pi-coding-agent";
30
30
  import { Container, Text } from "@earendil-works/pi-tui";
31
+ import { logEvent } from "./errorSink.js";
31
32
  import { ReadStateTracker, READ_FIRST_ERROR, readIsPartial, readTextOf, STALE_ERROR } from "./fileGuards.js";
32
33
  import { isQuiet, kindForTool, ToolRunTracker } from "./toolRuns.js";
33
34
  /** Lines of write content shown collapsed (mirrors Claude Code's preview). */
@@ -388,8 +389,23 @@ export function registerCondensedTools(pi, deps = {}) {
388
389
  // nothing — its content is error text, not the file — and must not
389
390
  // clobber a prior good record of the same path.
390
391
  if (name === "read" && target && !isErrorResult(result)) {
391
- const partial = readIsPartial({ path: args.path, offset: args.offset, limit: args.limit }, result.details ?? undefined);
392
- await readState.recordRead(target, cwd, readTextOf(result), partial);
392
+ // An image read carries an image part whose "content" is a
393
+ // processing note, not the file — never record it (Claude Code
394
+ // never stores image reads in readFileState either). The skip
395
+ // rides the trail so a later read-first refusal on an image-read
396
+ // path is diagnosable instead of confusing.
397
+ if (hasImages(result)) {
398
+ logEvent({
399
+ source: "condensedTools",
400
+ level: "info",
401
+ event: "read_record_skipped_image",
402
+ fields: {},
403
+ });
404
+ }
405
+ else {
406
+ const partial = readIsPartial({ path: args.path, offset: args.offset, limit: args.limit }, result.details ?? undefined);
407
+ await readState.recordRead(target, cwd, readTextOf(result), partial);
408
+ }
393
409
  }
394
410
  else if (name === "write" && target && typeof args.content === "string" && !isErrorResult(result)) {
395
411
  await readState.recordWrite(target, cwd, args.content);
@@ -9,10 +9,17 @@
9
9
  * model has not read in this session, and refuse again when the file
10
10
  * changed on disk since that read (mtime, with a content-compare fallback
11
11
  * so a spurious timestamp bump — cloud sync, antivirus, `utimes` noise —
12
- * does not produce a false refusal on a full read). New-file creates and
13
- * partial reads follow Claude Code exactly: a create needs no prior read;
14
- * an offset/limit or truncated read does NOT satisfy the read-first
15
- * requirement (`isPartialView`).
12
+ * does not produce a false refusal on a full read). New-file creates
13
+ * follow Claude Code exactly: a create needs no prior read. ANY
14
+ * successful read — windowed (offset/limit) or truncated satisfies
15
+ * read-first, matching Claude Code, whose Read tool never marks a
16
+ * partial view (its `isPartialView` exists only for auto-injected memory
17
+ * content that differs from disk, a path this tracker does not record).
18
+ * The `partial` classification instead selects the staleness strategy:
19
+ * a full read's record holds the whole file, so a bumped mtime can be
20
+ * content-compared before refusing; a partial record holds only a slice
21
+ * (with pi's continuation notice appended), so a bumped mtime refuses
22
+ * outright — Claude Code's exact stance for offset/limit reads.
16
23
  *
17
24
  * Fail-open contract: any guard-internal filesystem error that is not one
18
25
  * of the two refusals (EACCES, EMFILE, a racing delete, ...) delegates
@@ -9,10 +9,17 @@
9
9
  * model has not read in this session, and refuse again when the file
10
10
  * changed on disk since that read (mtime, with a content-compare fallback
11
11
  * so a spurious timestamp bump — cloud sync, antivirus, `utimes` noise —
12
- * does not produce a false refusal on a full read). New-file creates and
13
- * partial reads follow Claude Code exactly: a create needs no prior read;
14
- * an offset/limit or truncated read does NOT satisfy the read-first
15
- * requirement (`isPartialView`).
12
+ * does not produce a false refusal on a full read). New-file creates
13
+ * follow Claude Code exactly: a create needs no prior read. ANY
14
+ * successful read — windowed (offset/limit) or truncated satisfies
15
+ * read-first, matching Claude Code, whose Read tool never marks a
16
+ * partial view (its `isPartialView` exists only for auto-injected memory
17
+ * content that differs from disk, a path this tracker does not record).
18
+ * The `partial` classification instead selects the staleness strategy:
19
+ * a full read's record holds the whole file, so a bumped mtime can be
20
+ * content-compared before refusing; a partial record holds only a slice
21
+ * (with pi's continuation notice appended), so a bumped mtime refuses
22
+ * outright — Claude Code's exact stance for offset/limit reads.
16
23
  *
17
24
  * Fail-open contract: any guard-internal filesystem error that is not one
18
25
  * of the two refusals (EACCES, EMFILE, a racing delete, ...) delegates
@@ -167,38 +174,43 @@ export class ReadStateTracker {
167
174
  throw error; // unknown fs failure — the wrapper fails open
168
175
  }
169
176
  const record = this.reads.get(key);
170
- if (!record || record.partial) {
177
+ if (!record) {
171
178
  throw new Error(READ_FIRST_ERROR);
172
179
  }
173
- if (Math.floor(mtimeMs) > record.mtimeMs) {
174
- // Timestamp moved, but on some platforms (cloud sync, antivirus) it
175
- // moves without content changes. For a full read, compare content
176
- // before refusing Claude Code's exact fallback. The record holds
177
- // the read tool's exact output and pi returns file bytes verbatim,
178
- // so identical content compares byte-equal.
179
- let onDisk;
180
- try {
181
- onDisk = await (this.io.readFile ?? readTextFile)(key);
182
- }
183
- catch (error) {
184
- // Unreadable now, but the read succeeded then stale but the real
185
- // cause (EACCES/EIO on the re-read, not an edit) rides the trail so
186
- // the refusal is not misdiagnosed as a content change.
187
- logEvent({
188
- source: "fileGuards",
189
- level: "info",
190
- event: "staleness_reread_failed",
191
- fields: { code: error?.code ?? "unknown" },
192
- });
193
- throw new Error(STALE_ERROR);
194
- }
195
- if (onDisk !== record.content) {
196
- throw new Error(STALE_ERROR);
197
- }
198
- // Content-identical: refresh the record so the next write's mtime
199
- // comparison uses the bumped timestamp instead of refusing again.
200
- this.reads.set(key, { ...record, mtimeMs: Math.floor(mtimeMs) });
180
+ if (Math.floor(mtimeMs) <= record.mtimeMs)
181
+ return; // not stale
182
+ // The timestamp moved. A partial record holds only a slice (plus pi's
183
+ // continuation notice), so it cannot be content-compared refuse
184
+ // outright, Claude Code's stance for offset/limit reads.
185
+ if (record.partial) {
186
+ throw new Error(STALE_ERROR);
187
+ }
188
+ // For a full read, compare content before refusing — Claude Code's exact
189
+ // fallback: on some platforms (cloud sync, antivirus) the timestamp moves
190
+ // without content changes. The record holds the read tool's exact output
191
+ // and pi returns file bytes verbatim, so identical content compares equal.
192
+ let onDisk;
193
+ try {
194
+ onDisk = await (this.io.readFile ?? readTextFile)(key);
195
+ }
196
+ catch (error) {
197
+ // Unreadable now, but the read succeeded then — stale — but the real
198
+ // cause (EACCES/EIO on the re-read, not an edit) rides the trail so
199
+ // the refusal is not misdiagnosed as a content change.
200
+ logEvent({
201
+ source: "fileGuards",
202
+ level: "info",
203
+ event: "staleness_reread_failed",
204
+ fields: { code: error?.code ?? "unknown" },
205
+ });
206
+ throw new Error(STALE_ERROR);
207
+ }
208
+ if (onDisk !== record.content) {
209
+ throw new Error(STALE_ERROR);
201
210
  }
211
+ // Content-identical: refresh the record so the next write's mtime
212
+ // comparison uses the bumped timestamp instead of refusing again.
213
+ this.reads.set(key, { ...record, mtimeMs: Math.floor(mtimeMs) });
202
214
  }
203
215
  isInside(absolute, base) {
204
216
  const slashed = (p) => p.replace(/\\/g, "/");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.4-staging.1386.1",
3
+ "version": "1.1.4-staging.1387.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "0fa67403fcaeaf2bdba61d03dc84eb9816b46a0f"
61
+ "yagniSourceSha": "e8e7f5d88707ad223e9cbeb16216b80239af3224"
62
62
  }