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 +5 -3
- package/index.ts +18 -9
- package/package.json +1 -1
- package/src/anchor-registry.ts +275 -38
- package/src/constants.ts +3 -0
- package/src/grep.ts +154 -152
- package/src/hashline/resolve.ts +2 -1
- package/src/insert.ts +77 -74
- package/src/payload-contract.ts +3 -1
- package/src/read.ts +53 -51
- package/src/replace-diff.ts +8 -1
- package/src/replace-undo.ts +109 -107
- package/src/replace.ts +71 -69
- package/src/utils.ts +6 -0
- package/src/write-hook.ts +3 -3
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,
|
|
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.
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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:
|
|
151
|
+
owned: OwnedAnchorMap;
|
|
29
152
|
served: Map<string, Map<string, string>>;
|
|
30
|
-
everMinted:
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
388
|
+
const key = activeKey();
|
|
389
|
+
if (!key) return;
|
|
390
|
+
const sidecar = sidecarByKey.get(key);
|
|
391
|
+
if (!sidecar) return;
|
|
201
392
|
try {
|
|
202
|
-
appendFileSync(
|
|
393
|
+
appendFileSync(sidecar, JSON.stringify(event) + "\n", "utf-8");
|
|
203
394
|
if (process.platform !== "win32") {
|
|
204
|
-
try { chmodSync(
|
|
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
|
-
|
|
526
|
+
const key = activeKey();
|
|
527
|
+
if (key && registries.has(key)) return;
|
|
335
528
|
initRegistry(undefined).catch(() => undefined);
|
|
336
529
|
}
|
|
337
530
|
|
|
338
|
-
function
|
|
531
|
+
export function shadowStateFrom(state: SessionState): SessionState {
|
|
339
532
|
return {
|
|
340
|
-
owned: new
|
|
341
|
-
served: new Map(
|
|
342
|
-
everMinted: new
|
|
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 ?
|
|
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 ?
|
|
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
|
-
|
|
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 ?
|
|
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 (
|
|
628
|
-
appendEvent({ kind: "allocate", path, rows:
|
|
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
|
-
|
|
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
|
|
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
|
|