pi-hashline-edit-pro 4.2.6 → 4.2.8

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 CHANGED
@@ -90,6 +90,7 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
90
90
  The request is checked before any file I/O, so a bad request never touches the file.
91
91
 
92
92
  Common copy-paste slips are fixed automatically and reported as warnings: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 characters before `│`, for example `ab12│`), diff-preview rows pasted into the replacement, a reversed range, and a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped when that block is unique in the file. The whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config`: `on` strips with a warning, `off` applies edits literally, and `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped.
93
+ Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
93
94
 
94
95
  Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
95
96
 
@@ -164,7 +165,7 @@ All five tools return machine-readable metadata in `details` alongside the model
164
165
  | Tool | `details` |
165
166
  | --- | --- |
166
167
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
167
- | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-turn batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-turn batch membership), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
168
+ | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-turn batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-turn batch membership), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
168
169
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
169
170
  | `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
170
171
 
@@ -198,7 +199,7 @@ The table is curated for tokenizers, not for humans. Every anchor is the concate
198
199
 
199
200
  Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
200
201
 
201
- Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, and sidecars whose session file is gone are garbage-collected at startup.
202
+ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup, and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
202
203
 
203
204
  When a range is edited, the mapping between old and new content is computed per span: lines whose content is unchanged keep their allocated anchors, anchors of removed lines are freed, and every genuinely new line is minted a fresh anchor. Anchors are never assigned by matching content; only positional survival across an edit preserves one.
204
205
 
@@ -217,7 +218,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
217
218
 
218
219
  | Code | Meaning |
219
220
  | --- | --- |
220
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
221
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line), or content contains a NUL byte (`U+0000`), which would make the file binary. |
221
222
  | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example unwrapped JSON array syntax, embedded newlines split into lines, or stringified array text that could not be parsed and was kept as one literal line). |
222
223
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-char anchor. |
223
224
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
@@ -247,6 +248,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
247
248
 
248
249
  - Stale anchors. `[E_STALE_ANCHOR]` means an anchor is not owned in this session: it was never shown to you, or its line was edited or the file was rewritten since. Call `read` for fresh anchors and retry.
249
250
  - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
251
+ - Multi-conversation hosts. Anchors, served records, and ownership logs are resolved per calling session, so a tool call in one conversation is never answered by another conversation's registry; a foreign anchor fails with `[E_STALE_ANCHOR]`. Interactive previews are the one exception: pi does not pass the session into render callbacks, so when one process serves several conversations at once a preview can fall back to the most recently active session and show a stale or wrong-file diff. Previews never write files or claim anchors; run the call for the authoritative result.
250
252
  - Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
251
253
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
252
254
  - Config directory moved. If `XDG_CONFIG_HOME` is set on a non-Windows platform, the config directory (and the anchor state inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
package/index.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  adjustDiffContextLines,
21
21
  } from "./src/config";
22
22
  import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
23
- import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, markServed as markServedScoped } from "./src/anchor-registry";
23
+ import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, markServed as markServedScoped, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
24
24
  import { buildServedMap } from "./src/served";
25
25
  import { clearBoundaryBypass } from "./src/boundary-bypass";
26
26
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
@@ -58,7 +58,7 @@ export default function (pi: ExtensionAPI): void {
58
58
  }
59
59
  }
60
60
 
61
- pi.on("session_start", async (_event, ctx) => {
61
+ pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
62
62
  const active = pi.getActiveTools();
63
63
  grepWasActive = active.includes("grep");
64
64
  pi.setActiveTools(active.filter((t) => t !== "edit"));
@@ -73,7 +73,7 @@ export default function (pi: ExtensionAPI): void {
73
73
  });
74
74
  const sessionManager = (ctx as { sessionManager?: { getSessionFile?: () => string | undefined } }).sessionManager;
75
75
  const sessionFile = sessionManager?.getSessionFile?.();
76
- await initRegistry(sessionFile);
76
+ if (sessionKeyFor(ctx) === undefined) await initRegistry(sessionFile);
77
77
  await gcRegistrySidecars();
78
78
  const { config, corrupted } = await readConfigWithStatus();
79
79
  if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
@@ -88,6 +88,15 @@ export default function (pi: ExtensionAPI): void {
88
88
  if (debugValue === "1" || debugValue === "true") {
89
89
  ctx.ui.notify(`Hashline Edit mode active`, "info");
90
90
  }
91
+ }));
92
+
93
+ pi.on("session_shutdown", async (_event, ctx) => {
94
+ try {
95
+ const key = sessionKeyFor(ctx);
96
+ if (key !== undefined) releaseRegistrySession(key);
97
+ } catch (error) {
98
+ console.error("Failed to release anchor registry session:", error);
99
+ }
91
100
  });
92
101
 
93
102
  pi.registerCommand("hashline-config", {
@@ -127,18 +136,18 @@ export default function (pi: ExtensionAPI): void {
127
136
 
128
137
  pi.registerCommand("clear-anchors", {
129
138
  description: "Clear the session's anchor claims (path-free resolution state); anchors are re-claimed on the next read",
130
- handler: async (_args, ctx) => {
139
+ handler: async (_args, ctx) => withAnchorSession(ctx, async () => {
131
140
  clearRegistry();
132
141
  ctx.ui.notify(`Anchor claims cleared for this session`, "info");
133
- },
142
+ }),
134
143
  });
135
- pi.on("message_end", async (event, ctx) => {
144
+ pi.on("message_end", async (event, ctx) => withAnchorSession(ctx, async () => {
136
145
  try {
137
146
  await planAssistantMessage(event.message, ctx.cwd);
138
147
  } catch (error) {
139
148
  console.error("Failed to plan edit batch:", error);
140
149
  }
141
- });
150
+ }));
142
151
  pi.on("turn_end", async (event) => {
143
152
  try {
144
153
  const ids = (event.toolResults ?? []).map((result) => (result as { toolCallId?: unknown }).toolCallId).filter((id): id is string => typeof id === "string");
@@ -147,7 +156,7 @@ export default function (pi: ExtensionAPI): void {
147
156
  console.error("Failed to finalize edit batch:", error);
148
157
  }
149
158
  });
150
- pi.on("tool_result", async (event, ctx) => {
159
+ pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
151
160
  if (event.isError) return;
152
161
 
153
162
  if (event.toolName === "write") {
@@ -237,5 +246,5 @@ export default function (pi: ExtensionAPI): void {
237
246
  },
238
247
  ],
239
248
  };
240
- });
249
+ }));
241
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.2.6",
3
+ "version": "4.2.8",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1,7 +1,8 @@
1
- import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { appendFileSync, chmodSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
+ import { AsyncLocalStorage } from "node:async_hooks";
5
6
  import { sessionClaimsDir } from "./paths";
6
7
  import { contentChecksum } from "./hashline/hasher";
7
8
  import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
@@ -24,20 +25,148 @@ export interface OwnedAnchor {
24
25
  checksum: string;
25
26
  }
26
27
 
28
+ export interface AnchorSessionContext {
29
+ sessionManager?: {
30
+ getSessionFile?: () => string | undefined;
31
+ getSessionId?: () => string;
32
+ };
33
+ }
34
+
35
+ interface OwnedAnchorMap {
36
+ readonly size: number;
37
+ has(anchor: string): boolean;
38
+ get(anchor: string): OwnedAnchor | undefined;
39
+ set(anchor: string, entry: OwnedAnchor): unknown;
40
+ delete(anchor: string): boolean;
41
+ clear(): void;
42
+ keys(): Iterable<string>;
43
+ values(): Iterable<OwnedAnchor>;
44
+ entries(): Iterable<[string, OwnedAnchor]>;
45
+ [Symbol.iterator](): IterableIterator<[string, OwnedAnchor]>;
46
+ }
47
+
48
+ interface AnchorMintedSet {
49
+ has(anchor: string): boolean;
50
+ add(anchor: string): unknown;
51
+ [Symbol.iterator](): IterableIterator<string>;
52
+ }
53
+
54
+ class ShadowOwnedMap implements OwnedAnchorMap {
55
+ private readonly parent: OwnedAnchorMap;
56
+ private readonly overrides = new Map<string, OwnedAnchor | undefined>();
57
+
58
+ constructor(parent: OwnedAnchorMap) {
59
+ this.parent = parent;
60
+ }
61
+
62
+ has(anchor: string): boolean {
63
+ return this.overrides.has(anchor) ? this.overrides.get(anchor) !== undefined : this.parent.has(anchor);
64
+ }
65
+
66
+ get(anchor: string): OwnedAnchor | undefined {
67
+ return this.overrides.has(anchor) ? this.overrides.get(anchor) : this.parent.get(anchor);
68
+ }
69
+
70
+ set(anchor: string, entry: OwnedAnchor): this {
71
+ this.overrides.set(anchor, entry);
72
+ return this;
73
+ }
74
+
75
+ delete(anchor: string): boolean {
76
+ const owned = this.has(anchor);
77
+ this.overrides.set(anchor, undefined);
78
+ return owned;
79
+ }
80
+
81
+ clear(): void {
82
+ for (const anchor of this.parent.keys()) this.overrides.set(anchor, undefined);
83
+ for (const [anchor, entry] of this.overrides) {
84
+ if (entry !== undefined && !this.parent.has(anchor)) this.overrides.delete(anchor);
85
+ }
86
+ }
87
+
88
+ get size(): number {
89
+ let count = 0;
90
+ for (const _entry of this) count += 1;
91
+ return count;
92
+ }
93
+
94
+ *keys(): IterableIterator<string> {
95
+ for (const [anchor] of this.merged()) yield anchor;
96
+ }
97
+
98
+ *values(): IterableIterator<OwnedAnchor> {
99
+ for (const [, entry] of this.merged()) yield entry;
100
+ }
101
+
102
+ *entries(): IterableIterator<[string, OwnedAnchor]> {
103
+ yield* this.merged();
104
+ }
105
+
106
+ *[Symbol.iterator](): IterableIterator<[string, OwnedAnchor]> {
107
+ yield* this.merged();
108
+ }
109
+
110
+ private *merged(): IterableIterator<[string, OwnedAnchor]> {
111
+ for (const [anchor, entry] of this.parent) {
112
+ if (this.overrides.has(anchor)) {
113
+ const override = this.overrides.get(anchor);
114
+ if (override !== undefined) yield [anchor, override];
115
+ } else {
116
+ yield [anchor, entry];
117
+ }
118
+ }
119
+ for (const [anchor, entry] of this.overrides) {
120
+ if (entry !== undefined && !this.parent.has(anchor)) yield [anchor, entry];
121
+ }
122
+ }
123
+ }
124
+
125
+ class ShadowMintedSet implements AnchorMintedSet {
126
+ private readonly parent: AnchorMintedSet;
127
+ private readonly added = new Set<string>();
128
+
129
+ constructor(parent: AnchorMintedSet) {
130
+ this.parent = parent;
131
+ }
132
+
133
+ has(anchor: string): boolean {
134
+ return this.added.has(anchor) || this.parent.has(anchor);
135
+ }
136
+
137
+ add(anchor: string): this {
138
+ this.added.add(anchor);
139
+ return this;
140
+ }
141
+
142
+ *[Symbol.iterator](): IterableIterator<string> {
143
+ yield* this.parent;
144
+ for (const anchor of this.added) {
145
+ if (!this.parent.has(anchor)) yield anchor;
146
+ }
147
+ }
148
+ }
149
+
27
150
  interface SessionState {
28
- owned: Map<string, OwnedAnchor>;
151
+ owned: OwnedAnchorMap;
29
152
  served: Map<string, Map<string, string>>;
30
- everMinted: Set<string>;
153
+ everMinted: AnchorMintedSet;
31
154
  probe: number;
155
+ allocatedChecksum: Map<string, string>;
32
156
  }
33
157
 
34
158
  const SIDECAR_SUFFIX = ".registry.jsonl";
35
159
  const SIDECAR_COMPACT_LINES = 5000;
36
160
  const SIDECAR_COMPACT_BYTES = 1024 * 1024;
37
161
  const SIDECAR_COMPACT_CHUNK = 5000;
162
+ export const SIDECAR_HEADER_BYTES = 64 * 1024;
163
+ const SIDECAR_HEADER_CHUNK = 4096;
38
164
  let currentKey: string | undefined;
39
- let currentSidecar: string | undefined;
165
+ const sidecarByKey = new Map<string, string>();
166
+ const pendingInits = new Map<string, Promise<void>>();
167
+ const loadTokens = new Map<string, object>();
40
168
  const registries = new Map<string, SessionState>();
169
+ const sessionScope = new AsyncLocalStorage<string>();
41
170
 
42
171
  function newSessionState(seed?: string): SessionState {
43
172
  let probe = 0;
@@ -46,7 +175,7 @@ function newSessionState(seed?: string): SessionState {
46
175
  probe = (probe * 256 + byte) % ANCHOR_COUNT;
47
176
  }
48
177
  }
49
- return { owned: new Map(), served: new Map(), everMinted: new Set(), probe };
178
+ return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map() };
50
179
  }
51
180
 
52
181
  function seedServedFromOwned(state: SessionState): void {
@@ -153,20 +282,12 @@ async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile:
153
282
  }
154
283
  }
155
284
 
156
- export async function initRegistry(sessionFile: string | undefined): Promise<void> {
157
- if (!sessionFile) {
158
- currentKey = `__ephemeral__-${Date.now()}-${Math.random().toString(36).slice(2)}`;
159
- currentSidecar = undefined;
160
- registries.set(currentKey, newSessionState(currentKey));
161
- return;
162
- }
163
- const key = sidecarKeyFor(sessionFile);
164
- currentKey = key;
165
- currentSidecar = sidecarPath(key);
285
+ async function loadRegistryState(key: string, sessionFile: string, token: object): Promise<void> {
286
+ const sidecar = sidecarPath(key);
166
287
  let events: RegistryEvent[] = [];
167
288
  let rawLog = "";
168
289
  try {
169
- rawLog = await readFile(currentSidecar, "utf-8");
290
+ rawLog = await readFile(sidecar, "utf-8");
170
291
  events = parseRegistryLog(rawLog);
171
292
  } catch (error) {
172
293
  if (errCode(error) !== "ENOENT") {
@@ -174,16 +295,18 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
174
295
  }
175
296
  }
176
297
  const folded = foldRegistryEvents(events, `${key}:${process.pid}`);
298
+ if (loadTokens.get(key) !== token) return;
177
299
  seedServedFromOwned(folded);
178
300
  registries.set(key, folded);
301
+ sidecarByKey.set(key, sidecar);
179
302
  if (rawLog.length > 0) {
180
- await compactSidecarIfNeeded(currentSidecar, rawLog, sessionFile, folded);
303
+ await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded);
181
304
  }
182
305
  try {
183
306
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
184
307
  if (process.platform !== "win32") {
185
308
  try { await chmod(sessionClaimsDir(), 0o700); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry directory:", error); }
186
- try { await chmod(currentSidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
309
+ try { await chmod(sidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
187
310
  }
188
311
  appendEvent({ kind: "session", sessionFile } satisfies RegistryEvent);
189
312
  } catch (error) {
@@ -191,17 +314,85 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
191
314
  }
192
315
  }
193
316
 
317
+ async function ensureRegistryForKey(key: string, sessionFile: string | undefined): Promise<void> {
318
+ currentKey = key;
319
+ if (registries.has(key)) return;
320
+ const pending = pendingInits.get(key);
321
+ if (pending) {
322
+ await pending;
323
+ return;
324
+ }
325
+ const token: object = {};
326
+ loadTokens.set(key, token);
327
+ const promise = (async () => {
328
+ if (sessionFile === undefined) {
329
+ registries.set(key, newSessionState(key));
330
+ return;
331
+ }
332
+ await loadRegistryState(key, sessionFile, token);
333
+ })();
334
+ pendingInits.set(key, promise);
335
+ try {
336
+ await promise;
337
+ } finally {
338
+ pendingInits.delete(key);
339
+ }
340
+ }
341
+
342
+ export async function initRegistry(sessionFile: string | undefined): Promise<string> {
343
+ if (sessionFile === undefined) {
344
+ const key = `__ephemeral__-${Date.now()}-${Math.random().toString(36).slice(2)}`;
345
+ currentKey = key;
346
+ registries.set(key, newSessionState(key));
347
+ return key;
348
+ }
349
+ const key = sidecarKeyFor(sessionFile);
350
+ await ensureRegistryForKey(key, sessionFile);
351
+ return key;
352
+ }
353
+
354
+ export function sessionKeyFor(ctx: AnchorSessionContext | undefined): string | undefined {
355
+ const sessionFile = sessionFileFor(ctx);
356
+ if (sessionFile !== undefined) return sidecarKeyFor(sessionFile);
357
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
358
+ if (typeof sessionId === "string" && sessionId.length > 0) return `ephemeral:${sessionId}`;
359
+ return undefined;
360
+ }
361
+
362
+ function sessionFileFor(ctx: AnchorSessionContext | undefined): string | undefined {
363
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
364
+ return typeof sessionFile === "string" && sessionFile.length > 0 ? sessionFile : undefined;
365
+ }
366
+
367
+ export async function withAnchorSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
368
+ const key = sessionKeyFor(ctx);
369
+ if (key === undefined) return fn();
370
+ const sessionFile = sessionFileFor(ctx);
371
+ return sessionScope.run(key, async () => {
372
+ await ensureRegistryForKey(key, sessionFile);
373
+ return fn();
374
+ });
375
+ }
376
+
377
+ function activeKey(): string | undefined {
378
+ return sessionScope.getStore() ?? currentKey;
379
+ }
380
+
194
381
  function current(): SessionState | undefined {
195
- if (!currentKey) return undefined;
196
- return registries.get(currentKey);
382
+ const key = activeKey();
383
+ if (!key) return undefined;
384
+ return registries.get(key);
197
385
  }
198
386
 
199
387
  function appendEvent(event: RegistryEvent): void {
200
- if (!currentSidecar) return;
388
+ const key = activeKey();
389
+ if (!key) return;
390
+ const sidecar = sidecarByKey.get(key);
391
+ if (!sidecar) return;
201
392
  try {
202
- appendFileSync(currentSidecar, JSON.stringify(event) + "\n", "utf-8");
393
+ appendFileSync(sidecar, JSON.stringify(event) + "\n", "utf-8");
203
394
  if (process.platform !== "win32") {
204
- try { chmodSync(currentSidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
395
+ try { chmodSync(sidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
205
396
  }
206
397
  } catch (error) {
207
398
  console.error("Failed to append registry event:", error);
@@ -286,6 +477,7 @@ export function clearRegistry(): void {
286
477
  if (!state) return;
287
478
  state.owned.clear();
288
479
  state.served.clear();
480
+ state.allocatedChecksum.clear();
289
481
  appendEvent({ kind: "clear" });
290
482
  }
291
483
 
@@ -331,16 +523,18 @@ export function ownersForPath(path: string): Map<string, string> {
331
523
  }
332
524
 
333
525
  export function ensureRegistry(): void {
334
- if (currentKey && registries.has(currentKey)) return;
526
+ const key = activeKey();
527
+ if (key && registries.has(key)) return;
335
528
  initRegistry(undefined).catch(() => undefined);
336
529
  }
337
530
 
338
- function cloneState(state: SessionState): SessionState {
531
+ export function shadowStateFrom(state: SessionState): SessionState {
339
532
  return {
340
- owned: new Map(state.owned),
341
- served: new Map([...state.served].map(([path, served]) => [path, new Map(served)])),
342
- everMinted: new Set(state.everMinted),
533
+ owned: new ShadowOwnedMap(state.owned),
534
+ served: new Map(),
535
+ everMinted: new ShadowMintedSet(state.everMinted),
343
536
  probe: state.probe,
537
+ allocatedChecksum: state.allocatedChecksum,
344
538
  };
345
539
  }
346
540
 
@@ -416,7 +610,7 @@ export function alignOwnershipWithSpans(
416
610
  spans: { start: number; end: number; replacementCount: number }[],
417
611
  options?: { shadow?: boolean },
418
612
  ): Aligned {
419
- const state = options?.shadow ? cloneState(current()!) : current()!;
613
+ const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
420
614
  const freed: { anchor: string; checksum: string }[] = [];
421
615
  const minted: MintedAt[] = [];
422
616
  const log = (event: RegistryEvent): void => {
@@ -485,10 +679,11 @@ export function alignOwnership(
485
679
  newChecksums: string[],
486
680
  options?: { shadow?: boolean },
487
681
  ): Aligned {
488
- const state = options?.shadow ? cloneState(current()!) : current()!;
682
+ const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
489
683
  const anchors: string[] = new Array(newChecksums.length);
490
684
  const freed: { anchor: string; checksum: string }[] = [];
491
685
  const minted: MintedAt[] = [];
686
+ const adopted: string[] = [];
492
687
  const log = (event: RegistryEvent): void => {
493
688
  if (!options?.shadow) appendEvent(event);
494
689
  };
@@ -531,6 +726,7 @@ export function alignOwnership(
531
726
  state.owned.set(fresh, { path, checksum });
532
727
  anchors[newIdx + k] = fresh;
533
728
  } else {
729
+ if (!entry) adopted.push(anchor);
534
730
  state.owned.set(anchor, { path, checksum });
535
731
  anchors[newIdx + k] = anchor;
536
732
  }
@@ -544,11 +740,17 @@ export function alignOwnership(
544
740
  for (let i = 0; i < anchors.length; i++) {
545
741
  rows.push([anchors[i]!, newChecksums[i]!]);
546
742
  }
547
- if (minted.length > 0) log({ kind: "allocate", path, rows: rows.filter(([anchor]) => minted.some((m) => m.anchor === anchor)) });
743
+ const logged = new Set<string>([...minted.map((m) => m.anchor), ...adopted]);
744
+ if (logged.size > 0) log({ kind: "allocate", path, rows: rows.filter(([anchor]) => logged.has(anchor)) });
548
745
  if (freed.length > 0) log({ kind: "free", path, anchors: freed.map((f) => f.anchor) });
549
746
  return { anchors, freed: freed.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
550
747
  }
551
748
 
749
+ function snapshotMatchesAllocation(state: SessionState | undefined, path: string, checksum: string): boolean {
750
+ const allocated = state?.allocatedChecksum.get(path);
751
+ return allocated === undefined || allocated === checksum;
752
+ }
753
+
552
754
  export async function allocateFileAnchors(
553
755
  store: HashStore,
554
756
  path: string,
@@ -560,12 +762,14 @@ export async function allocateFileAnchors(
560
762
  },
561
763
  ): Promise<string[]> {
562
764
  ensureRegistry();
765
+ const registry = current();
563
766
  const shadow = options?.shadow === true;
564
767
  const lines = splitLines(content);
565
768
  const checksums = lines.map((line) => contentChecksum(hashSource(line)));
566
769
  if (options?.previous?.spans) {
567
770
  const prevChecksums = splitLines(options.previous.content).map((line) => contentChecksum(hashSource(line)));
568
771
  const aligned = alignOwnershipWithSpans(path, options.previous.hashes, prevChecksums, checksums, options.previous.spans, { shadow });
772
+ if (!shadow && registry) registry.allocatedChecksum.set(path, contentChecksum(content));
569
773
  if (!shadow && options.persist !== false) {
570
774
  persistSnapshot(store, path, content, aligned.anchors, checksums);
571
775
  }
@@ -578,7 +782,7 @@ export async function allocateFileAnchors(
578
782
  prevChecksums = splitLines(options.previous.content).map((line) => contentChecksum(hashSource(line)));
579
783
  } else {
580
784
  const previousState = getAllocatedState(store, path, !shadow);
581
- if (previousState) {
785
+ if (previousState && snapshotMatchesAllocation(registry, path, previousState.contentChecksum)) {
582
786
  prevAnchors = previousState.anchors;
583
787
  prevChecksums = previousState.checksums;
584
788
  if (!prevChecksums && previousState.contentChecksum === contentChecksum(content)) {
@@ -589,7 +793,7 @@ export async function allocateFileAnchors(
589
793
  const aligned: Aligned = prevAnchors
590
794
  ? alignOwnership(path, prevAnchors, prevChecksums, checksums, { shadow })
591
795
  : (() => {
592
- const state = shadow ? cloneState(current()!) : current()!;
796
+ const state = shadow ? shadowStateFrom(current()!) : current()!;
593
797
  const reuseIndex = fingerprintIndex(state, path);
594
798
  const reuseTaken = new Map<string, number>();
595
799
  const anchors: string[] = checksums.map((checksum) => {
@@ -606,6 +810,7 @@ export async function allocateFileAnchors(
606
810
  }
607
811
  return { anchors, freed: [], minted: anchors };
608
812
  })();
813
+ if (!shadow && registry) registry.allocatedChecksum.set(path, contentChecksum(content));
609
814
  if (!shadow && options?.persist !== false) {
610
815
  persistSnapshot(store, path, content, aligned.anchors, checksums);
611
816
  }
@@ -620,21 +825,54 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
620
825
  served = new Map();
621
826
  state.served.set(path, served);
622
827
  }
828
+ const adopted: Array<[string, string]> = [];
623
829
  for (const [anchor, checksum] of entries) {
830
+ const existing = state.owned.get(anchor);
831
+ if (existing && existing.path !== path) continue;
624
832
  state.owned.set(anchor, { path, checksum });
625
833
  served.set(anchor, checksum);
834
+ adopted.push([anchor, checksum]);
626
835
  }
627
- if (entries.size > 0) {
628
- appendEvent({ kind: "allocate", path, rows: [...entries] });
836
+ if (adopted.length > 0) {
837
+ appendEvent({ kind: "allocate", path, rows: adopted });
629
838
  }
630
839
  }
631
840
 
632
841
  export function resetRegistryForTests(): void {
633
842
  currentKey = undefined;
634
- currentSidecar = undefined;
843
+ sidecarByKey.clear();
844
+ pendingInits.clear();
845
+ loadTokens.clear();
635
846
  registries.clear();
636
847
  }
637
848
 
849
+ export async function readSidecarHeader(sidecar: string): Promise<string> {
850
+ const handle = await open(sidecar, "r");
851
+ const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
852
+ try {
853
+ let readBytes = 0;
854
+ while (readBytes < buffer.length) {
855
+ const { bytesRead } = await handle.read(buffer, readBytes, Math.min(SIDECAR_HEADER_CHUNK, buffer.length - readBytes), readBytes);
856
+ if (bytesRead === 0) break;
857
+ readBytes += bytesRead;
858
+ const text = buffer.subarray(0, readBytes).toString("utf-8");
859
+ const newline = text.indexOf("\n");
860
+ if (newline >= 0) return text.slice(0, newline);
861
+ }
862
+ return readBytes === buffer.length ? "" : buffer.subarray(0, readBytes).toString("utf-8");
863
+ } finally {
864
+ await handle.close();
865
+ }
866
+ }
867
+
868
+ export function releaseRegistrySession(key: string): void {
869
+ loadTokens.delete(key);
870
+ if (currentKey === key) currentKey = undefined;
871
+ pendingInits.delete(key);
872
+ sidecarByKey.delete(key);
873
+ registries.delete(key);
874
+ }
875
+
638
876
  export async function gcRegistrySidecars(): Promise<void> {
639
877
  let names: string[];
640
878
  try {
@@ -657,8 +895,7 @@ export async function gcRegistrySidecars(): Promise<void> {
657
895
  if (!name.endsWith(SIDECAR_SUFFIX)) continue;
658
896
  const sidecar = join(sessionClaimsDir(), name);
659
897
  try {
660
- const raw = await readFile(sidecar, "utf-8");
661
- const header = JSON.parse(raw.split("\n")[0] ?? "{}") as { kind?: string; sessionFile?: string };
898
+ const header = JSON.parse(await readSidecarHeader(sidecar) || "{}") as { kind?: string; sessionFile?: string };
662
899
  if (header.kind !== "session" || !header.sessionFile) continue;
663
900
  await stat(header.sessionFile);
664
901
  } catch (error) {
package/src/constants.ts CHANGED
@@ -13,6 +13,9 @@ export const HASH_STORE_VERSION = 8;
13
13
  export const NEW_CONTENT_NOT_ARRAY_MSG =
14
14
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
15
15
 
16
+ export const NUL_CONTENT_MSG =
17
+ `[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry. An empty replacement ([]) deletes a range or inserts nothing.`;
18
+
16
19
  export const ANCHOR_POOL_EXHAUSTED_PREFIX =
17
20
  "[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
18
21