pi-hashline-edit-pro 4.2.5 → 4.2.7

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,8 +218,8 @@ 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
- | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example unwrapped JSON array syntax or embedded newlines split into lines). |
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. |
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. |
224
225
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
@@ -236,6 +237,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
236
237
  | `[E_BOUNDARY_STRICT]` | Strict boundary dedup rejected the edit because replacement lines re-include edge lines; resend without those lines. |
237
238
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
238
239
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
240
+ | `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
239
241
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
240
242
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
241
243
  | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
@@ -246,6 +248,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
246
248
 
247
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.
248
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.
249
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.
250
253
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
251
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.5",
3
+ "version": "4.2.7",
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,10 +25,132 @@ 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;
32
155
  }
33
156
 
@@ -35,9 +158,14 @@ const SIDECAR_SUFFIX = ".registry.jsonl";
35
158
  const SIDECAR_COMPACT_LINES = 5000;
36
159
  const SIDECAR_COMPACT_BYTES = 1024 * 1024;
37
160
  const SIDECAR_COMPACT_CHUNK = 5000;
161
+ const SIDECAR_HEADER_BYTES = 64 * 1024;
162
+ const SIDECAR_HEADER_CHUNK = 4096;
38
163
  let currentKey: string | undefined;
39
- let currentSidecar: string | undefined;
164
+ const sidecarByKey = new Map<string, string>();
165
+ const pendingInits = new Map<string, Promise<void>>();
166
+ const loadTokens = new Map<string, object>();
40
167
  const registries = new Map<string, SessionState>();
168
+ const sessionScope = new AsyncLocalStorage<string>();
41
169
 
42
170
  function newSessionState(seed?: string): SessionState {
43
171
  let probe = 0;
@@ -153,20 +281,12 @@ async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile:
153
281
  }
154
282
  }
155
283
 
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);
284
+ async function loadRegistryState(key: string, sessionFile: string, token: object): Promise<void> {
285
+ const sidecar = sidecarPath(key);
166
286
  let events: RegistryEvent[] = [];
167
287
  let rawLog = "";
168
288
  try {
169
- rawLog = await readFile(currentSidecar, "utf-8");
289
+ rawLog = await readFile(sidecar, "utf-8");
170
290
  events = parseRegistryLog(rawLog);
171
291
  } catch (error) {
172
292
  if (errCode(error) !== "ENOENT") {
@@ -174,16 +294,18 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
174
294
  }
175
295
  }
176
296
  const folded = foldRegistryEvents(events, `${key}:${process.pid}`);
297
+ if (loadTokens.get(key) !== token) return;
177
298
  seedServedFromOwned(folded);
178
299
  registries.set(key, folded);
300
+ sidecarByKey.set(key, sidecar);
179
301
  if (rawLog.length > 0) {
180
- await compactSidecarIfNeeded(currentSidecar, rawLog, sessionFile, folded);
302
+ await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded);
181
303
  }
182
304
  try {
183
305
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
184
306
  if (process.platform !== "win32") {
185
307
  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); }
308
+ try { await chmod(sidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
187
309
  }
188
310
  appendEvent({ kind: "session", sessionFile } satisfies RegistryEvent);
189
311
  } catch (error) {
@@ -191,17 +313,85 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
191
313
  }
192
314
  }
193
315
 
316
+ async function ensureRegistryForKey(key: string, sessionFile: string | undefined): Promise<void> {
317
+ currentKey = key;
318
+ if (registries.has(key)) return;
319
+ const pending = pendingInits.get(key);
320
+ if (pending) {
321
+ await pending;
322
+ return;
323
+ }
324
+ const token: object = {};
325
+ loadTokens.set(key, token);
326
+ const promise = (async () => {
327
+ if (sessionFile === undefined) {
328
+ registries.set(key, newSessionState(key));
329
+ return;
330
+ }
331
+ await loadRegistryState(key, sessionFile, token);
332
+ })();
333
+ pendingInits.set(key, promise);
334
+ try {
335
+ await promise;
336
+ } finally {
337
+ pendingInits.delete(key);
338
+ }
339
+ }
340
+
341
+ export async function initRegistry(sessionFile: string | undefined): Promise<string> {
342
+ if (sessionFile === undefined) {
343
+ const key = `__ephemeral__-${Date.now()}-${Math.random().toString(36).slice(2)}`;
344
+ currentKey = key;
345
+ registries.set(key, newSessionState(key));
346
+ return key;
347
+ }
348
+ const key = sidecarKeyFor(sessionFile);
349
+ await ensureRegistryForKey(key, sessionFile);
350
+ return key;
351
+ }
352
+
353
+ export function sessionKeyFor(ctx: AnchorSessionContext | undefined): string | undefined {
354
+ const sessionFile = sessionFileFor(ctx);
355
+ if (sessionFile !== undefined) return sidecarKeyFor(sessionFile);
356
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
357
+ if (typeof sessionId === "string" && sessionId.length > 0) return `ephemeral:${sessionId}`;
358
+ return undefined;
359
+ }
360
+
361
+ function sessionFileFor(ctx: AnchorSessionContext | undefined): string | undefined {
362
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
363
+ return typeof sessionFile === "string" && sessionFile.length > 0 ? sessionFile : undefined;
364
+ }
365
+
366
+ export async function withAnchorSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
367
+ const key = sessionKeyFor(ctx);
368
+ if (key === undefined) return fn();
369
+ const sessionFile = sessionFileFor(ctx);
370
+ return sessionScope.run(key, async () => {
371
+ await ensureRegistryForKey(key, sessionFile);
372
+ return fn();
373
+ });
374
+ }
375
+
376
+ function activeKey(): string | undefined {
377
+ return sessionScope.getStore() ?? currentKey;
378
+ }
379
+
194
380
  function current(): SessionState | undefined {
195
- if (!currentKey) return undefined;
196
- return registries.get(currentKey);
381
+ const key = activeKey();
382
+ if (!key) return undefined;
383
+ return registries.get(key);
197
384
  }
198
385
 
199
386
  function appendEvent(event: RegistryEvent): void {
200
- if (!currentSidecar) return;
387
+ const key = activeKey();
388
+ if (!key) return;
389
+ const sidecar = sidecarByKey.get(key);
390
+ if (!sidecar) return;
201
391
  try {
202
- appendFileSync(currentSidecar, JSON.stringify(event) + "\n", "utf-8");
392
+ appendFileSync(sidecar, JSON.stringify(event) + "\n", "utf-8");
203
393
  if (process.platform !== "win32") {
204
- try { chmodSync(currentSidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
394
+ try { chmodSync(sidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
205
395
  }
206
396
  } catch (error) {
207
397
  console.error("Failed to append registry event:", error);
@@ -331,15 +521,16 @@ export function ownersForPath(path: string): Map<string, string> {
331
521
  }
332
522
 
333
523
  export function ensureRegistry(): void {
334
- if (currentKey && registries.has(currentKey)) return;
524
+ const key = activeKey();
525
+ if (key && registries.has(key)) return;
335
526
  initRegistry(undefined).catch(() => undefined);
336
527
  }
337
528
 
338
- function cloneState(state: SessionState): SessionState {
529
+ export function shadowStateFrom(state: SessionState): SessionState {
339
530
  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),
531
+ owned: new ShadowOwnedMap(state.owned),
532
+ served: new Map(),
533
+ everMinted: new ShadowMintedSet(state.everMinted),
343
534
  probe: state.probe,
344
535
  };
345
536
  }
@@ -416,7 +607,7 @@ export function alignOwnershipWithSpans(
416
607
  spans: { start: number; end: number; replacementCount: number }[],
417
608
  options?: { shadow?: boolean },
418
609
  ): Aligned {
419
- const state = options?.shadow ? cloneState(current()!) : current()!;
610
+ const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
420
611
  const freed: { anchor: string; checksum: string }[] = [];
421
612
  const minted: MintedAt[] = [];
422
613
  const log = (event: RegistryEvent): void => {
@@ -485,10 +676,11 @@ export function alignOwnership(
485
676
  newChecksums: string[],
486
677
  options?: { shadow?: boolean },
487
678
  ): Aligned {
488
- const state = options?.shadow ? cloneState(current()!) : current()!;
679
+ const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
489
680
  const anchors: string[] = new Array(newChecksums.length);
490
681
  const freed: { anchor: string; checksum: string }[] = [];
491
682
  const minted: MintedAt[] = [];
683
+ const adopted: string[] = [];
492
684
  const log = (event: RegistryEvent): void => {
493
685
  if (!options?.shadow) appendEvent(event);
494
686
  };
@@ -531,6 +723,7 @@ export function alignOwnership(
531
723
  state.owned.set(fresh, { path, checksum });
532
724
  anchors[newIdx + k] = fresh;
533
725
  } else {
726
+ if (!entry) adopted.push(anchor);
534
727
  state.owned.set(anchor, { path, checksum });
535
728
  anchors[newIdx + k] = anchor;
536
729
  }
@@ -544,7 +737,8 @@ export function alignOwnership(
544
737
  for (let i = 0; i < anchors.length; i++) {
545
738
  rows.push([anchors[i]!, newChecksums[i]!]);
546
739
  }
547
- if (minted.length > 0) log({ kind: "allocate", path, rows: rows.filter(([anchor]) => minted.some((m) => m.anchor === anchor)) });
740
+ const logged = new Set<string>([...minted.map((m) => m.anchor), ...adopted]);
741
+ if (logged.size > 0) log({ kind: "allocate", path, rows: rows.filter(([anchor]) => logged.has(anchor)) });
548
742
  if (freed.length > 0) log({ kind: "free", path, anchors: freed.map((f) => f.anchor) });
549
743
  return { anchors, freed: freed.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
550
744
  }
@@ -589,7 +783,7 @@ export async function allocateFileAnchors(
589
783
  const aligned: Aligned = prevAnchors
590
784
  ? alignOwnership(path, prevAnchors, prevChecksums, checksums, { shadow })
591
785
  : (() => {
592
- const state = shadow ? cloneState(current()!) : current()!;
786
+ const state = shadow ? shadowStateFrom(current()!) : current()!;
593
787
  const reuseIndex = fingerprintIndex(state, path);
594
788
  const reuseTaken = new Map<string, number>();
595
789
  const anchors: string[] = checksums.map((checksum) => {
@@ -620,21 +814,54 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
620
814
  served = new Map();
621
815
  state.served.set(path, served);
622
816
  }
817
+ const adopted: Array<[string, string]> = [];
623
818
  for (const [anchor, checksum] of entries) {
819
+ const existing = state.owned.get(anchor);
820
+ if (existing && existing.path !== path) continue;
624
821
  state.owned.set(anchor, { path, checksum });
625
822
  served.set(anchor, checksum);
823
+ adopted.push([anchor, checksum]);
626
824
  }
627
- if (entries.size > 0) {
628
- appendEvent({ kind: "allocate", path, rows: [...entries] });
825
+ if (adopted.length > 0) {
826
+ appendEvent({ kind: "allocate", path, rows: adopted });
629
827
  }
630
828
  }
631
829
 
632
830
  export function resetRegistryForTests(): void {
633
831
  currentKey = undefined;
634
- currentSidecar = undefined;
832
+ sidecarByKey.clear();
833
+ pendingInits.clear();
834
+ loadTokens.clear();
635
835
  registries.clear();
636
836
  }
637
837
 
838
+ export async function readSidecarHeader(sidecar: string): Promise<string> {
839
+ const handle = await open(sidecar, "r");
840
+ const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
841
+ try {
842
+ let readBytes = 0;
843
+ while (readBytes < buffer.length) {
844
+ const { bytesRead } = await handle.read(buffer, readBytes, Math.min(SIDECAR_HEADER_CHUNK, buffer.length - readBytes), readBytes);
845
+ if (bytesRead === 0) break;
846
+ readBytes += bytesRead;
847
+ const text = buffer.subarray(0, readBytes).toString("utf-8");
848
+ const newline = text.indexOf("\n");
849
+ if (newline >= 0) return text.slice(0, newline);
850
+ }
851
+ return buffer.subarray(0, readBytes).toString("utf-8");
852
+ } finally {
853
+ await handle.close();
854
+ }
855
+ }
856
+
857
+ export function releaseRegistrySession(key: string): void {
858
+ loadTokens.delete(key);
859
+ if (currentKey === key) currentKey = undefined;
860
+ pendingInits.delete(key);
861
+ sidecarByKey.delete(key);
862
+ registries.delete(key);
863
+ }
864
+
638
865
  export async function gcRegistrySidecars(): Promise<void> {
639
866
  let names: string[];
640
867
  try {
@@ -657,8 +884,7 @@ export async function gcRegistrySidecars(): Promise<void> {
657
884
  if (!name.endsWith(SIDECAR_SUFFIX)) continue;
658
885
  const sidecar = join(sessionClaimsDir(), name);
659
886
  try {
660
- const raw = await readFile(sidecar, "utf-8");
661
- const header = JSON.parse(raw.split("\n")[0] ?? "{}") as { kind?: string; sessionFile?: string };
887
+ const header = JSON.parse(await readSidecarHeader(sidecar) || "{}") as { kind?: string; sessionFile?: string };
662
888
  if (header.kind !== "session" || !header.sessionFile) continue;
663
889
  await stat(header.sessionFile);
664
890
  } 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