@vincemakes/kiso-runtime 0.1.37 → 0.2.0

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/dist/agent.d.ts CHANGED
@@ -62,6 +62,7 @@ export interface AgentDefinition {
62
62
  * loop's policy chain. */
63
63
  readonly extensions?: readonly KisoExtension[];
64
64
  }
65
+ /** @deprecated the canonical name is `Agent` (root export, 1.1.0); this alias is removed in the next major. */
65
66
  export declare class AgentRuntime {
66
67
  #private;
67
68
  constructor(definition: AgentDefinition);
package/dist/agent.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { EventLog, ToolRegistry } from "@vincemakes/kiso-core";
15
15
  import { AgentSession } from "./session.js";
16
+ /** @deprecated the canonical name is `Agent` (root export, 1.1.0); this alias is removed in the next major. */
16
17
  export class AgentRuntime {
17
18
  #definition;
18
19
  #registry;
@@ -54,6 +55,7 @@ export class AgentRuntime {
54
55
  const adapter = await this.#adapterPromise;
55
56
  const config = {
56
57
  model: this.#definition.model,
58
+ ...(this.#definition.provider !== undefined ? { provider: this.#definition.provider } : {}),
57
59
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
58
60
  registry: this.#registry,
59
61
  ...(this.#definition.permissionPolicy !== undefined || this.#definition.hooks !== undefined
package/dist/index.d.ts CHANGED
@@ -1,10 +1,32 @@
1
- export * from "./agent.js";
2
- export * from "./session.js";
3
- export * from "./run.js";
4
- export * from "./recovery.js";
5
- export * from "./compose.js";
6
- export * from "./summarize.js";
7
- export * from "./store.js";
8
- export * from "./ledger.js";
9
- export * from "./extensions.js";
10
- export * from "./trust.js";
1
+ /**
2
+ * The SDK surface — the curated root manifest (S1, 2026-08-12).
3
+ *
4
+ * Every name here is a review-approved contract: adding or removing one
5
+ * WITHOUT the ritual (the review's sign-off + a public-surface.json
6
+ * refresh) turns the public-surface gate red. See docs/sdk.md for the
7
+ * contract, the aliases, and what deliberately lives behind the
8
+ * first-party door.
9
+ *
10
+ * What is NOT here is not the SDK. The recovery / compose / summarize /
11
+ * lock-adapter machinery and buildAdapter live behind "./internal"
12
+ * (unstable, first-party only — see internal.ts).
13
+ *
14
+ * Canonical names: Agent = AgentRuntime, Session = AgentSession. The old
15
+ * names are deprecated aliases, removed in the next major (additive,
16
+ * ADR-0051 Amendment 1 — release rounds move the cli minor).
17
+ */
18
+ export { AgentRuntime, AgentRuntime as Agent, createAgent } from "./agent.js";
19
+ export type { AgentDefinition, PermissionPolicy, PermissionRule } from "./agent.js";
20
+ export { AgentSession, AgentSession as Session } from "./session.js";
21
+ export { PoisonedSessionError, ResumeBlockedError } from "./session.js";
22
+ export type { ApprovalRequest, CompactInfo, SessionConfig, SummarizeResult } from "./session.js";
23
+ export { Run } from "./run.js";
24
+ export { SessionStore, StaleWriterError, StoreCorruptionError } from "./store.js";
25
+ export type { Event, SessionMeta, StoreRecord } from "./store.js";
26
+ export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
27
+ export type { KisoExtension } from "./extensions.js";
28
+ export { executionForCallId, executionLedger } from "./ledger.js";
29
+ export type { ExecutionRecord, ExecutionStatus } from "./ledger.js";
30
+ export { kisoHome, projectArtifacts, recordTrust, trustFor } from "./trust.js";
31
+ export type { ProjectArtifact, ProjectArtifacts, TrustDecision, TrustRecord } from "./trust.js";
32
+ export { canonicalizeUsage } from "./usage/canonical.js";
package/dist/index.js CHANGED
@@ -1,10 +1,41 @@
1
- export * from "./agent.js";
2
- export * from "./session.js";
3
- export * from "./run.js";
4
- export * from "./recovery.js";
5
- export * from "./compose.js";
6
- export * from "./summarize.js";
7
- export * from "./store.js";
8
- export * from "./ledger.js";
9
- export * from "./extensions.js";
10
- export * from "./trust.js";
1
+ /**
2
+ * The SDK surface — the curated root manifest (S1, 2026-08-12).
3
+ *
4
+ * Every name here is a review-approved contract: adding or removing one
5
+ * WITHOUT the ritual (the review's sign-off + a public-surface.json
6
+ * refresh) turns the public-surface gate red. See docs/sdk.md for the
7
+ * contract, the aliases, and what deliberately lives behind the
8
+ * first-party door.
9
+ *
10
+ * What is NOT here is not the SDK. The recovery / compose / summarize /
11
+ * lock-adapter machinery and buildAdapter live behind "./internal"
12
+ * (unstable, first-party only — see internal.ts).
13
+ *
14
+ * Canonical names: Agent = AgentRuntime, Session = AgentSession. The old
15
+ * names are deprecated aliases, removed in the next major (additive,
16
+ * ADR-0051 Amendment 1 — release rounds move the cli minor).
17
+ */
18
+ // agent
19
+ export { AgentRuntime, AgentRuntime as Agent, createAgent } from "./agent.js";
20
+ // session
21
+ export { AgentSession, AgentSession as Session } from "./session.js";
22
+ export { PoisonedSessionError, ResumeBlockedError } from "./session.js";
23
+ // run
24
+ export { Run } from "./run.js";
25
+ // store
26
+ export { SessionStore, StaleWriterError, StoreCorruptionError } from "./store.js";
27
+ // extensions
28
+ export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
29
+ // ledger
30
+ export { executionForCallId, executionLedger } from "./ledger.js";
31
+ // trust
32
+ export { kisoHome, projectArtifacts, recordTrust, trustFor } from "./trust.js";
33
+ // usage — the canonical accounting schema (E2/1.3.0, R4b-1 ruling:
34
+ // additive minor, one function; the signature is frozen the moment this
35
+ // line lands — changing it is a MAJOR ritual). R5b-④a set the final
36
+ // shape at freeze time: the trailing `table?: PricingTable` injection
37
+ // slot defaults to the builtin v1 table, so the R5a-1-commercial table
38
+ // rides day one without ever widening this signature. Raw stays
39
+ // provider observation; canonicalizeUsage derives at the accounting
40
+ // boundary (R4 Case B — the frozen usage union does not move).
41
+ export { canonicalizeUsage } from "./usage/canonical.js";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The FIRST-PARTY door — NOT part of the public SDK contract (S1,
3
+ * 2026-08-12, adjudicated).
4
+ *
5
+ * Unstable: anything here can move or vanish in any release without a
6
+ * major bump. External consumers must import the curated root
7
+ * (index.ts); this file exists for the in-repo consumers (apps/cli,
8
+ * extensions, tests) that need machinery the SDK deliberately does not
9
+ * expose — the recovery / compose / summarize / lock-adapter internals
10
+ * and buildAdapter.
11
+ *
12
+ * This is today's pre-S1 index.ts verbatim: removing the door would be a
13
+ * breaking change for the first-party consumers mid-migration.
14
+ */
15
+ export * from "./agent.js";
16
+ export * from "./session.js";
17
+ export * from "./run.js";
18
+ export * from "./recovery.js";
19
+ export * from "./compose.js";
20
+ export * from "./summarize.js";
21
+ export * from "./store.js";
22
+ export * from "./lock-adapter.js";
23
+ export * from "./ledger.js";
24
+ export * from "./extensions.js";
25
+ export * from "./trust.js";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The FIRST-PARTY door — NOT part of the public SDK contract (S1,
3
+ * 2026-08-12, adjudicated).
4
+ *
5
+ * Unstable: anything here can move or vanish in any release without a
6
+ * major bump. External consumers must import the curated root
7
+ * (index.ts); this file exists for the in-repo consumers (apps/cli,
8
+ * extensions, tests) that need machinery the SDK deliberately does not
9
+ * expose — the recovery / compose / summarize / lock-adapter internals
10
+ * and buildAdapter.
11
+ *
12
+ * This is today's pre-S1 index.ts verbatim: removing the door would be a
13
+ * breaking change for the first-party consumers mid-migration.
14
+ */
15
+ export * from "./agent.js";
16
+ export * from "./session.js";
17
+ export * from "./run.js";
18
+ export * from "./recovery.js";
19
+ export * from "./compose.js";
20
+ export * from "./summarize.js";
21
+ export * from "./store.js";
22
+ export * from "./lock-adapter.js";
23
+ export * from "./ledger.js";
24
+ export * from "./extensions.js";
25
+ export * from "./trust.js";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
3
+ * ADR-0050).
4
+ *
5
+ * The default adapter is the identity-confirmed link lock — a pure Node
6
+ * mechanism. Node's stdlib has no advisory-lock primitive (the round-4
7
+ * python3 flock helper is the dependency this round retires), so possession
8
+ * of `<id>.lock` is decided by atomic filesystem operations, and the file
9
+ * itself carries the holder's identity.
10
+ *
11
+ * The protocol (the decision path is synchronous — no awaits between the
12
+ * read and the act):
13
+ *
14
+ * acquire — read the identity; if it is absent or names a dead pid (fresh
15
+ * path / dead holder / stale residue): rename the path away (atomic —
16
+ * exactly one contender wins), re-read what was moved, and if it differs
17
+ * from what was judged (a rival's LIVE file got moved) abort the takeover
18
+ * and restore-or-keep. Then write MY identity to a temp file, fsync it,
19
+ * and LINK it at the final path (atomic create-if-absent). The link is
20
+ * the only way the final path ever exists — a kill can never leave an
21
+ * empty or half-written lock (see the fsync note below). A live foreign
22
+ * identity refuses immediately; a live identity naming OUR OWN process is
23
+ * a same-process writer's residue (round 5) and is retried until its
24
+ * release or the cap.
25
+ *
26
+ * verify — possession is re-checked at every append: the file must still
27
+ * name this handle's pid AND token. Failure is a STRICT refusal — no
28
+ * retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
29
+ * displaced holder fails honestly and the session resumes from a fresh
30
+ * store — never two writers).
31
+ *
32
+ * release — rename my file away, confirm it is mine, leave the EMPTY
33
+ * released marker (the path is never deleted — a contender must be able
34
+ * to read it), remove the tombstone. A moved rival's file is
35
+ * restored-or-kept, never clobbered.
36
+ *
37
+ * The fsync-before-link order is LOAD-BEARING: the temp file is fully
38
+ * written AND fsynced before the final name is linked, so a power loss can
39
+ * never produce an empty or half-written file at the final path — the
40
+ * inode's data is durable before the name exists. The final path itself
41
+ * needs no directory fsync: if the name is lost in a crash, the holder
42
+ * died with it, and the residue is acquirable (ADR-0050 §crash-durability).
43
+ *
44
+ * The identity FILE format is the cross-version channel (unchanged from
45
+ * round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
46
+ * (string or JSON number), empty (the released marker / a legacy-format
47
+ * writer's create window), half-written (crash residue). A legacy-format
48
+ * writer sees a live modern identity and refuses to take over; we refuse a
49
+ * live foreign legacy pid. Empty and half-written files are taken over as
50
+ * residue — under the documented QUARANTINE upgrade contract (round 5
51
+ * P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
52
+ *
53
+ * Hard-link dependence: linkSync requires a link-capable filesystem
54
+ * (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
55
+ * LockUnavailableError carrying the errno — never a silent degradation to
56
+ * a weaker scheme (that would re-open the empty-file window, ADR-0050).
57
+ *
58
+ * Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
59
+ * gates (native-lock-race.test.ts) freeze a contender between the read and
60
+ * the rename-away, and between the rename-away and the verify, via fixed
61
+ * pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
62
+ * ready-marker files (ADR-0050 §test affordances).
63
+ */
64
+ /** A live foreign writer owns the lock — never taken over. */
65
+ export declare class LockedError extends Error {
66
+ constructor(message: string);
67
+ }
68
+ /** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
69
+ export declare class LockUnavailableError extends Error {
70
+ constructor(reason: string);
71
+ }
72
+ export interface LockHandle {
73
+ readonly pid: number;
74
+ readonly token: string;
75
+ /** True iff the lock file at the path still names THIS identity. */
76
+ verify(): boolean;
77
+ /** Idempotent release; the path is left as the empty released marker. */
78
+ release(): void;
79
+ }
80
+ export interface LockAdapter {
81
+ readonly name: string;
82
+ /**
83
+ * Take (or take over) the lock at lockPath. Rejects with LockedError
84
+ * when a live foreign writer owns it (or the same-process-residue retry
85
+ * cap is hit), LockUnavailableError when the mechanism cannot operate.
86
+ * cancelled() is invoked at each retry decision and may throw to abort
87
+ * the acquisition (the store's lifecycle barrier).
88
+ */
89
+ acquire(lockPath: string, sessionId: string, cancelled: () => void): Promise<LockHandle>;
90
+ }
91
+ /** The default adapter: the identity-confirmed link lock (ADR-0050). */
92
+ export declare const nativeLockAdapter: LockAdapter;
@@ -0,0 +1,384 @@
1
+ /**
2
+ * LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
3
+ * ADR-0050).
4
+ *
5
+ * The default adapter is the identity-confirmed link lock — a pure Node
6
+ * mechanism. Node's stdlib has no advisory-lock primitive (the round-4
7
+ * python3 flock helper is the dependency this round retires), so possession
8
+ * of `<id>.lock` is decided by atomic filesystem operations, and the file
9
+ * itself carries the holder's identity.
10
+ *
11
+ * The protocol (the decision path is synchronous — no awaits between the
12
+ * read and the act):
13
+ *
14
+ * acquire — read the identity; if it is absent or names a dead pid (fresh
15
+ * path / dead holder / stale residue): rename the path away (atomic —
16
+ * exactly one contender wins), re-read what was moved, and if it differs
17
+ * from what was judged (a rival's LIVE file got moved) abort the takeover
18
+ * and restore-or-keep. Then write MY identity to a temp file, fsync it,
19
+ * and LINK it at the final path (atomic create-if-absent). The link is
20
+ * the only way the final path ever exists — a kill can never leave an
21
+ * empty or half-written lock (see the fsync note below). A live foreign
22
+ * identity refuses immediately; a live identity naming OUR OWN process is
23
+ * a same-process writer's residue (round 5) and is retried until its
24
+ * release or the cap.
25
+ *
26
+ * verify — possession is re-checked at every append: the file must still
27
+ * name this handle's pid AND token. Failure is a STRICT refusal — no
28
+ * retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
29
+ * displaced holder fails honestly and the session resumes from a fresh
30
+ * store — never two writers).
31
+ *
32
+ * release — rename my file away, confirm it is mine, leave the EMPTY
33
+ * released marker (the path is never deleted — a contender must be able
34
+ * to read it), remove the tombstone. A moved rival's file is
35
+ * restored-or-kept, never clobbered.
36
+ *
37
+ * The fsync-before-link order is LOAD-BEARING: the temp file is fully
38
+ * written AND fsynced before the final name is linked, so a power loss can
39
+ * never produce an empty or half-written file at the final path — the
40
+ * inode's data is durable before the name exists. The final path itself
41
+ * needs no directory fsync: if the name is lost in a crash, the holder
42
+ * died with it, and the residue is acquirable (ADR-0050 §crash-durability).
43
+ *
44
+ * The identity FILE format is the cross-version channel (unchanged from
45
+ * round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
46
+ * (string or JSON number), empty (the released marker / a legacy-format
47
+ * writer's create window), half-written (crash residue). A legacy-format
48
+ * writer sees a live modern identity and refuses to take over; we refuse a
49
+ * live foreign legacy pid. Empty and half-written files are taken over as
50
+ * residue — under the documented QUARANTINE upgrade contract (round 5
51
+ * P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
52
+ *
53
+ * Hard-link dependence: linkSync requires a link-capable filesystem
54
+ * (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
55
+ * LockUnavailableError carrying the errno — never a silent degradation to
56
+ * a weaker scheme (that would re-open the empty-file window, ADR-0050).
57
+ *
58
+ * Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
59
+ * gates (native-lock-race.test.ts) freeze a contender between the read and
60
+ * the rename-away, and between the rename-away and the verify, via fixed
61
+ * pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
62
+ * ready-marker files (ADR-0050 §test affordances).
63
+ */
64
+ import { execFileSync } from "node:child_process";
65
+ import { closeSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
66
+ import { randomUUID } from "node:crypto";
67
+ import { join } from "node:path";
68
+ /** A live foreign writer owns the lock — never taken over. */
69
+ export class LockedError extends Error {
70
+ constructor(message) {
71
+ super(message);
72
+ this.name = "LockedError";
73
+ }
74
+ }
75
+ /** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
76
+ export class LockUnavailableError extends Error {
77
+ constructor(reason) {
78
+ super(`session locking unavailable: ${reason}`);
79
+ this.name = "LockUnavailableError";
80
+ }
81
+ }
82
+ /**
83
+ * ADR-0050 Amendment 1 (Finding R-I-1): the liveness probe is
84
+ * state-aware. A dead holder can linger in the process table after a
85
+ * kill — the exiting state (STAT E on macOS: the kill landing while a
86
+ * pty syscall is blocked, the dead session's terminal left open) or an
87
+ * un-reaped zombie (STAT Z). POSIX reports BOTH alive to kill(pid, 0)
88
+ * (they exist until reaped), so the takeover refused a holder that can
89
+ * never execute another session write. Both states are probed and
90
+ * judged DEAD. A probe failure (unreadable state) maintains the
91
+ * pre-amendment behavior — alive, the fail-safe refusal (prefer a false
92
+ * refusal over a double-write). Live-process semantics and the PID-reuse
93
+ * rules do not move: a live foreign writer is still refused.
94
+ *
95
+ * ADR-0050 Amendment 2 (Finding R-I-p-3): the state letters are matched
96
+ * ANYWHERE in the state string, not as the first character. The
97
+ * exit-path linger's ps output is "?E" — the first character is the
98
+ * no-controlling-terminal marker "?", with the E sitting AFTER it — so
99
+ * first-character matching judged the finding's own documented shape
100
+ * alive. The ps state alphabet (macOS + Linux) has no flag letters
101
+ * "E"/"Z": an occurrence anywhere is the process-state code, and the
102
+ * holder is dead.
103
+ */
104
+ function isAlive(pid) {
105
+ try {
106
+ process.kill(pid, 0);
107
+ }
108
+ catch (err) {
109
+ return err.code === "EPERM";
110
+ }
111
+ const state = processState(pid);
112
+ // A null state (probe failure) keeps the pre-amendment behavior —
113
+ // judged alive, the fail-safe refusal.
114
+ return state === null || (!state.includes("E") && !state.includes("Z"));
115
+ }
116
+ /**
117
+ * The process state via `ps -o state= -p <pid>` (macOS/Linux) — the
118
+ * whole state string (a multi-char string like "Ss+" or "?E" is a
119
+ * combined flag set; the E/Z process-state codes may sit after the "?"
120
+ * no-tty marker or other flag letters, so the matching in isAlive scans
121
+ * the whole string). An empty or unreadable state — the process
122
+ * vanished between the kill and the probe, or ps itself failed —
123
+ * returns null; isAlive's fail-safe branch then judges the holder alive
124
+ * (the pre-amendment behavior).
125
+ */
126
+ function processState(pid) {
127
+ try {
128
+ const state = execFileSync("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf8" }).trim();
129
+ return state.length > 0 ? state : null;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ /**
136
+ * Read a lock file's holder identity (round 4 formats, unchanged — the
137
+ * cross-version channel). Empty, unreadable, or half-written locks have no
138
+ * identity: they are residue, taken over (ADR-0050 §migration).
139
+ */
140
+ function readLockIdentity(lockPath) {
141
+ let raw;
142
+ try {
143
+ raw = readFileSync(lockPath, "utf8");
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ const trimmed = raw.trim();
149
+ if (trimmed === "")
150
+ return null;
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(trimmed);
154
+ }
155
+ catch {
156
+ parsed = trimmed; // half-written JSON — try as a bare pid
157
+ }
158
+ if (typeof parsed === "number" && Number.isInteger(parsed)) {
159
+ return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
160
+ }
161
+ if (typeof parsed === "string") {
162
+ const pid = Number.parseInt(parsed, 10);
163
+ return Number.isFinite(pid) ? { pid } : null;
164
+ }
165
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
166
+ const v = parsed;
167
+ return {
168
+ ...(typeof v.pid === "number" ? { pid: v.pid } : {}),
169
+ ...(typeof v.token === "string" ? { token: v.token } : {}),
170
+ };
171
+ }
172
+ return null;
173
+ }
174
+ function sameIdentity(a, b) {
175
+ if (a === null && b === null)
176
+ return true;
177
+ if (a === null || b === null)
178
+ return false;
179
+ return a.pid === b.pid && (a.token ?? null) === (b.token ?? null);
180
+ }
181
+ function unavailable(err) {
182
+ const e = err;
183
+ return new LockUnavailableError(`${e.code ?? "?"}: ${e.message}`);
184
+ }
185
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
186
+ class NativeLockHandle {
187
+ pid;
188
+ token;
189
+ #lockPath;
190
+ #tombstone;
191
+ #released = false;
192
+ constructor(lockPath, tombstone, identity) {
193
+ this.#lockPath = lockPath;
194
+ this.#tombstone = tombstone;
195
+ this.pid = identity.pid;
196
+ this.token = identity.token;
197
+ }
198
+ verify() {
199
+ if (this.#released)
200
+ return false;
201
+ const now = readLockIdentity(this.#lockPath);
202
+ return now !== null && now.pid === this.pid && now.token === this.token;
203
+ }
204
+ release() {
205
+ if (this.#released)
206
+ return;
207
+ this.#released = true;
208
+ try {
209
+ renameSync(this.#lockPath, this.#tombstone);
210
+ }
211
+ catch (err) {
212
+ if (err.code === "ENOENT")
213
+ return; // nothing of ours at the path
214
+ // A fs-level failure: fall back to clearing in place (advisory
215
+ // best effort — a missing marker can only wedge same-process
216
+ // residue, never split a writer).
217
+ try {
218
+ writeFileSync(this.#lockPath, "");
219
+ }
220
+ catch {
221
+ // advisory only
222
+ }
223
+ return;
224
+ }
225
+ const moved = readLockIdentity(this.#tombstone);
226
+ if (moved !== null && moved.pid === this.pid && moved.token === this.token) {
227
+ // Mine. Leave the EMPTY released marker — the path is never
228
+ // deleted (a contender must be able to read it; the
229
+ // storage-identity suite pins it) — created ONLY-if-absent, so
230
+ // a rival that linked in the window keeps its lock untouched.
231
+ try {
232
+ const fd = openSync(this.#lockPath, "wx");
233
+ closeSync(fd);
234
+ }
235
+ catch (err) {
236
+ // EEXIST → a rival's lock stands; anything else → advisory.
237
+ }
238
+ try {
239
+ unlinkSync(this.#tombstone);
240
+ }
241
+ catch {
242
+ // the stray tombstone is inert
243
+ }
244
+ return;
245
+ }
246
+ // A rival's file was moved (the displacement cascade, ADR-0050
247
+ // §residual) — restore-or-keep, never clobber: link it back
248
+ // only-if-absent; on EEXIST the path already carries a newer lock
249
+ // and the moved file stays at its distinct name (inert residue).
250
+ try {
251
+ linkSync(this.#tombstone, this.#lockPath);
252
+ }
253
+ catch {
254
+ // EEXIST or fs failure — inert
255
+ }
256
+ }
257
+ }
258
+ class NativeLock {
259
+ name = "link-lock";
260
+ async acquire(lockPath, sessionId, cancelled) {
261
+ const pid = process.pid;
262
+ const token = randomUUID();
263
+ // The staging name is PER-ATTEMPT: a later rename-away in the same
264
+ // acquire must never atomically overwrite an earlier attempt's
265
+ // abandoned staging (that would clobber the displacement residue,
266
+ // ADR-0050 §residual). tmp/tombstone are unique per acquire and are
267
+ // always cleaned or inert.
268
+ const tmp = `${lockPath}.tmp-${pid}-${token}`;
269
+ const tombstone = `${lockPath}.tomb-${pid}-${token}`;
270
+ // Test-only affordances (ADR-0050 §test affordances): the cascade
271
+ // gate freezes a contender at the two decision points.
272
+ const readyDir = process.env.KISO_LOCK_TEST_READY_DIR;
273
+ const readPause = Number(process.env.KISO_LOCK_TEST_PAUSE_READ_MS ?? 0) || 0;
274
+ const takeoverPause = Number(process.env.KISO_LOCK_TEST_PAUSE_TAKEOVER_MS ?? 0) || 0;
275
+ for (let attempt = 0;; attempt++) {
276
+ cancelled();
277
+ const staging = `${lockPath}.staging-${pid}-${token}-${attempt}`;
278
+ const seen = readLockIdentity(lockPath);
279
+ if (seen === null || seen.pid === undefined || !isAlive(seen.pid)) {
280
+ // Fresh path, dead holder, or stale residue — take it over
281
+ // by identity confirmation: rename-away → verify → link.
282
+ if (readyDir !== undefined)
283
+ writeFileSync(join(readyDir, `read-${pid}`), "");
284
+ if (readPause > 0)
285
+ await sleep(readPause);
286
+ let moved = false;
287
+ try {
288
+ renameSync(lockPath, staging);
289
+ moved = true;
290
+ }
291
+ catch (err) {
292
+ if (err.code !== "ENOENT")
293
+ throw unavailable(err);
294
+ // ENOENT — the path is already absent; proceed to the link.
295
+ }
296
+ if (moved) {
297
+ if (readyDir !== undefined)
298
+ writeFileSync(join(readyDir, `takeover-${pid}`), "");
299
+ if (takeoverPause > 0)
300
+ await sleep(takeoverPause);
301
+ const s = readLockIdentity(staging);
302
+ if (!sameIdentity(s, seen)) {
303
+ // The path was replaced between my read and my rename
304
+ // — a RIVAL'S LIVE file was moved. Abort the takeover:
305
+ // restore-or-keep (link back only-if-absent; on EEXIST
306
+ // the moved file is abandoned — inert residue at its
307
+ // distinct name, ADR-0050 §residual).
308
+ try {
309
+ linkSync(staging, lockPath);
310
+ }
311
+ catch (err) {
312
+ if (err.code !== "EEXIST")
313
+ throw unavailable(err);
314
+ }
315
+ continue;
316
+ }
317
+ }
318
+ // Write my identity fully, fsync it, THEN link — the
319
+ // load-bearing order (ADR-0050 §crash-durability): the final
320
+ // path can never exist empty or half-written, even across a
321
+ // power loss.
322
+ try {
323
+ const fd = openSync(tmp, "w");
324
+ try {
325
+ writeFileSync(fd, JSON.stringify({ pid, token }), "utf8");
326
+ fsyncSync(fd);
327
+ }
328
+ finally {
329
+ closeSync(fd);
330
+ }
331
+ linkSync(tmp, lockPath);
332
+ }
333
+ catch (err) {
334
+ const code = err.code;
335
+ if (code === "EEXIST") {
336
+ // A rival linked first — retry from a fresh read.
337
+ try {
338
+ unlinkSync(tmp);
339
+ }
340
+ catch {
341
+ // the stray is inert
342
+ }
343
+ continue;
344
+ }
345
+ throw unavailable(err);
346
+ }
347
+ try {
348
+ unlinkSync(tmp);
349
+ }
350
+ catch {
351
+ // the stray is inert
352
+ }
353
+ // The takeover succeeded — our identity owns the path, and the
354
+ // verified-dead file we moved away may be cleaned up (its
355
+ // name is distinct; unlinking it can never touch the lock
356
+ // path). Only the EEXIST-abandoned staging of a FAILED
357
+ // takeover lingers — the displacement fingerprint.
358
+ if (moved) {
359
+ try {
360
+ unlinkSync(staging);
361
+ }
362
+ catch {
363
+ // the stray is inert
364
+ }
365
+ }
366
+ return new NativeLockHandle(lockPath, tombstone, { pid, token });
367
+ }
368
+ if (seen.pid === pid && seen.token !== undefined) {
369
+ // Same-process writer's residue (round 5): another store in
370
+ // THIS process holds the lock — it will release; retry until
371
+ // it does (never a spurious self-conflict). A legacy
372
+ // bare-pid lock naming our own process is refused like any
373
+ // live foreign owner.
374
+ if (attempt >= 25)
375
+ throw new LockedError(`session ${sessionId} is locked by another writer`);
376
+ await sleep(20);
377
+ continue;
378
+ }
379
+ throw new LockedError(`session ${sessionId} is locked by another writer (pid ${seen.pid})`);
380
+ }
381
+ }
382
+ }
383
+ /** The default adapter: the identity-confirmed link lock (ADR-0050). */
384
+ export const nativeLockAdapter = new NativeLock();