pi-hashline-edit-pro 4.2.6 → 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 +5 -3
- package/index.ts +18 -9
- package/package.json +1 -1
- package/src/anchor-registry.ts +262 -36
- 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.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",
|
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,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:
|
|
151
|
+
owned: OwnedAnchorMap;
|
|
29
152
|
served: Map<string, Map<string, string>>;
|
|
30
|
-
everMinted:
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
387
|
+
const key = activeKey();
|
|
388
|
+
if (!key) return;
|
|
389
|
+
const sidecar = sidecarByKey.get(key);
|
|
390
|
+
if (!sidecar) return;
|
|
201
391
|
try {
|
|
202
|
-
appendFileSync(
|
|
392
|
+
appendFileSync(sidecar, JSON.stringify(event) + "\n", "utf-8");
|
|
203
393
|
if (process.platform !== "win32") {
|
|
204
|
-
try { chmodSync(
|
|
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
|
-
|
|
524
|
+
const key = activeKey();
|
|
525
|
+
if (key && registries.has(key)) return;
|
|
335
526
|
initRegistry(undefined).catch(() => undefined);
|
|
336
527
|
}
|
|
337
528
|
|
|
338
|
-
function
|
|
529
|
+
export function shadowStateFrom(state: SessionState): SessionState {
|
|
339
530
|
return {
|
|
340
|
-
owned: new
|
|
341
|
-
served: new Map(
|
|
342
|
-
everMinted: new
|
|
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 ?
|
|
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 ?
|
|
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
|
-
|
|
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 ?
|
|
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 (
|
|
628
|
-
appendEvent({ kind: "allocate", path, rows:
|
|
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
|
-
|
|
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
|
|
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
|
|