sentinelayer-cli 0.40.0 → 0.42.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.
Files changed (69) hide show
  1. package/README.md +22 -4
  2. package/package.json +4 -4
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +1094 -279
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/governance.js +67 -0
  16. package/src/engram/index.js +73 -0
  17. package/src/engram/ingest-document.js +107 -0
  18. package/src/engram/namespace.js +89 -0
  19. package/src/engram/observations.js +34 -0
  20. package/src/engram/sla.js +104 -0
  21. package/src/engram/store.js +120 -0
  22. package/src/engram/tools.js +152 -0
  23. package/src/engram/trust.js +105 -0
  24. package/src/events/schema.js +18 -0
  25. package/src/legacy-cli.js +6 -3
  26. package/src/mcp/registry.js +27 -10
  27. package/src/mcp/session-stdio-server.js +209 -18
  28. package/src/net/test-egress-guard.js +158 -0
  29. package/src/review/investor-dd-orchestrator.js +14 -2
  30. package/src/session/admission-auth.js +312 -0
  31. package/src/session/admission-preimage.js +90 -0
  32. package/src/session/admission.js +620 -0
  33. package/src/session/agent-registry.js +22 -63
  34. package/src/session/checkpoints.js +25 -1
  35. package/src/session/codebase-context.js +54 -0
  36. package/src/session/coordination-guidance.js +6 -6
  37. package/src/session/daemon-spawn.js +20 -2
  38. package/src/session/daemon.js +26 -125
  39. package/src/session/event-identity.js +9 -2
  40. package/src/session/file-lease-integrations.js +1300 -0
  41. package/src/session/file-locks.js +967 -464
  42. package/src/session/first-message.js +11 -3
  43. package/src/session/listener.js +246 -20
  44. package/src/session/listeners.js +71 -80
  45. package/src/session/live-source.js +47 -16
  46. package/src/session/message-edits.js +89 -0
  47. package/src/session/paths.js +2 -0
  48. package/src/session/recall/embedder.js +181 -0
  49. package/src/session/recall/entities.js +0 -0
  50. package/src/session/recall/index-build.js +38 -0
  51. package/src/session/recall/index-core.js +108 -0
  52. package/src/session/recall/index.js +131 -0
  53. package/src/session/recall/needle-eval.js +279 -0
  54. package/src/session/recall/observation-core.js +183 -0
  55. package/src/session/recall/observations.js +21 -0
  56. package/src/session/recall/provenance.js +153 -0
  57. package/src/session/recall/retrieve.js +374 -0
  58. package/src/session/recall/text.js +0 -0
  59. package/src/session/recall/token-stats.js +81 -0
  60. package/src/session/recap.js +54 -26
  61. package/src/session/remote-hydrate.js +12 -20
  62. package/src/session/store.js +13 -14
  63. package/src/session/stream.js +23 -3
  64. package/src/session/sync.js +789 -22
  65. package/src/session/tickets.js +316 -0
  66. package/src/session/title-sync.js +2 -1
  67. package/src/session/usage-report.js +41 -2
  68. package/src/session/wake/pump.js +8 -3
  69. package/src/session/wake/resolve-target.js +10 -5
@@ -1,371 +1,662 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import fsp from "node:fs/promises";
2
- import os from "node:os";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { setTimeout as sleep } from "node:timers/promises";
6
6
 
7
- import { createAgentEvent } from "../events/schema.js";
7
+ import {
8
+ SentinelayerApiError,
9
+ requestJson,
10
+ requestJsonMutation,
11
+ } from "../auth/http.js";
12
+ import { resolveActiveAuthSession } from "../auth/service.js";
8
13
  import { resolveSessionPaths } from "./paths.js";
9
- import { appendToStream } from "./stream.js";
10
14
 
11
- const FILE_LOCK_SCHEMA_VERSION = "1.0.0";
15
+ const FILE_LEASE_CAPABILITY_SCHEMA_VERSION = "1.0.0";
12
16
  const DEFAULT_FILE_LOCK_TTL_SECONDS = 300;
13
- const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
14
- const DEFAULT_LOCK_STALE_MS = 30_000;
15
- const DEFAULT_LOCK_POLL_MS = 25;
16
- const SENTI_AGENT_ID = "senti";
17
+ const MIN_FILE_LOCK_TTL_SECONDS = 15;
18
+ const MAX_FILE_LOCK_TTL_SECONDS = 3_600;
19
+ const DEFAULT_CAPABILITY_LOCK_TIMEOUT_MS = 10_000;
20
+ const DEFAULT_CAPABILITY_LOCK_STALE_MS = 30_000;
21
+ const DEFAULT_CAPABILITY_LOCK_POLL_MS = 25;
22
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23
+ const AGENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,31}$/;
24
+ const LEASE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43,128}$/;
25
+ const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[/\\]/;
17
26
 
18
27
  function normalizeString(value) {
19
28
  return String(value || "").trim();
20
29
  }
21
30
 
22
- function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
31
+ function normalizeSessionId(value) {
23
32
  const normalized = normalizeString(value);
24
- if (!normalized) {
25
- return fallbackIso;
26
- }
27
- const epoch = Date.parse(normalized);
28
- if (!Number.isFinite(epoch)) {
29
- return fallbackIso;
33
+ if (!SESSION_ID_PATTERN.test(normalized)) {
34
+ throw new Error(
35
+ "sessionId must be 1-64 repository-safe letters, numbers, dots, underscores, or hyphens.",
36
+ );
30
37
  }
31
- return new Date(epoch).toISOString();
38
+ return normalized;
32
39
  }
33
40
 
34
- function normalizePositiveInteger(value, fallbackValue) {
35
- if (value === undefined || value === null || normalizeString(value) === "") {
36
- return fallbackValue;
37
- }
38
- const normalized = Number(value);
39
- if (!Number.isFinite(normalized) || normalized <= 0) {
40
- throw new Error("Value must be a positive integer.");
41
+ function normalizeAgentId(value) {
42
+ const normalized = normalizeString(value).toLowerCase();
43
+ if (!AGENT_ID_PATTERN.test(normalized)) {
44
+ throw new Error("agentId must match ^[A-Za-z0-9][A-Za-z0-9._:-]{0,31}$.");
41
45
  }
42
- return Math.floor(normalized);
43
- }
44
-
45
- function toIsoAfterSeconds(nowIso, seconds) {
46
- const nowEpoch = Date.parse(normalizeIsoTimestamp(nowIso, nowIso));
47
- return new Date(nowEpoch + Math.max(1, Math.floor(Number(seconds) || 0)) * 1000).toISOString();
46
+ return normalized;
48
47
  }
49
48
 
50
- function parseEpoch(value, fallbackIso = new Date().toISOString()) {
51
- return Date.parse(normalizeIsoTimestamp(value, fallbackIso)) || 0;
49
+ function normalizeTtlSeconds(value) {
50
+ const normalized =
51
+ value === undefined || value === null || normalizeString(value) === ""
52
+ ? DEFAULT_FILE_LOCK_TTL_SECONDS
53
+ : Number(value);
54
+ if (
55
+ !Number.isInteger(normalized) ||
56
+ normalized < MIN_FILE_LOCK_TTL_SECONDS ||
57
+ normalized > MAX_FILE_LOCK_TTL_SECONDS
58
+ ) {
59
+ throw new Error(
60
+ `ttlSeconds must be an integer between ${MIN_FILE_LOCK_TTL_SECONDS} and ${MAX_FILE_LOCK_TTL_SECONDS}.`,
61
+ );
62
+ }
63
+ return normalized;
52
64
  }
53
65
 
54
- function formatSince(fromIso, nowIso = new Date().toISOString()) {
55
- const nowEpoch = parseEpoch(nowIso, nowIso);
56
- const fromEpoch = parseEpoch(fromIso, nowIso);
57
- const deltaMs = Math.max(0, nowEpoch - fromEpoch);
58
- const deltaSeconds = Math.max(0, Math.floor(deltaMs / 1000));
59
- if (deltaSeconds < 60) {
60
- return `${deltaSeconds}s ago`;
66
+ function normalizeBoundedText(value, { field, maxLength }) {
67
+ const normalized = normalizeString(value);
68
+ if (normalized.length > maxLength) {
69
+ throw new Error(`${field} must be ${maxLength} characters or fewer.`);
61
70
  }
62
- const deltaMinutes = Math.floor(deltaSeconds / 60);
63
- if (deltaMinutes < 60) {
64
- return `${deltaMinutes}m ago`;
71
+ if (/[\u0000-\u001f\u007f]/u.test(normalized)) {
72
+ throw new Error(`${field} must not contain control characters.`);
65
73
  }
66
- const deltaHours = Math.floor(deltaMinutes / 60);
67
- if (deltaHours < 24) {
68
- return `${deltaHours}h ago`;
74
+ return normalized;
75
+ }
76
+
77
+ function normalizeApiUrl(value) {
78
+ const normalized = normalizeString(value).replace(/\/+$/u, "");
79
+ if (!normalized) {
80
+ throw new Error("Sentinelayer API URL is unavailable.");
69
81
  }
70
- const deltaDays = Math.floor(deltaHours / 24);
71
- return `${deltaDays}d ago`;
82
+ return normalized;
72
83
  }
73
84
 
74
85
  function normalizeFilePath(filePath, { targetPath = process.cwd() } = {}) {
75
- const raw = normalizeString(filePath);
86
+ const raw = normalizeString(filePath).normalize("NFC");
76
87
  if (!raw) {
77
88
  throw new Error("filePath is required.");
78
89
  }
90
+ if (raw.length > 1_024) {
91
+ throw new Error("filePath must be 1024 characters or fewer.");
92
+ }
93
+ if (/[\u0000-\u001f\u007f]/u.test(raw)) {
94
+ throw new Error("filePath must not contain control characters.");
95
+ }
79
96
 
80
- let normalized = raw;
97
+ const workspaceRoot = path.resolve(String(targetPath || "."));
98
+ let candidate = raw;
81
99
  if (path.isAbsolute(raw)) {
82
- normalized = path.relative(path.resolve(String(targetPath || ".")), path.resolve(raw));
100
+ const relative = path.relative(workspaceRoot, path.resolve(raw));
101
+ if (
102
+ !relative ||
103
+ path.isAbsolute(relative) ||
104
+ relative === ".." ||
105
+ relative.startsWith(`..${path.sep}`)
106
+ ) {
107
+ throw new Error("filePath must resolve to a file inside the workspace.");
108
+ }
109
+ candidate = relative;
110
+ } else if (raw.startsWith(("/", "\\")) || WINDOWS_ABSOLUTE_PATH_PATTERN.test(raw)) {
111
+ throw new Error("filePath must be repository-relative.");
112
+ }
113
+
114
+ const segments = [];
115
+ for (const segment of candidate.replace(/\\/gu, "/").split("/")) {
116
+ const normalizedSegment = segment.normalize("NFC");
117
+ if (!normalizedSegment || normalizedSegment === ".") {
118
+ continue;
119
+ }
120
+ if (normalizedSegment === "..") {
121
+ throw new Error("filePath must not traverse outside the workspace.");
122
+ }
123
+ segments.push(normalizedSegment);
83
124
  }
84
- normalized = normalizeString(normalized).replace(/\\/g, "/");
85
- normalized = normalized.replace(/^\.\/+/, "");
86
- if (!normalized || normalized === ".") {
125
+ const normalized = segments.join("/");
126
+ if (!normalized) {
87
127
  throw new Error("filePath is required.");
88
128
  }
129
+ if (normalized.length > 1_024) {
130
+ throw new Error("normalized filePath must be 1024 characters or fewer.");
131
+ }
89
132
  return normalized;
90
133
  }
91
134
 
92
- function normalizeAgentId(agentId) {
93
- const normalized = normalizeString(agentId).toLowerCase();
94
- if (!normalized) {
95
- throw new Error("agentId is required.");
135
+ async function resolveRealPathThroughNearestAncestor(absolutePath) {
136
+ let cursor = path.resolve(absolutePath);
137
+ const missingSegments = [];
138
+ while (true) {
139
+ try {
140
+ const realAncestor = await fsp.realpath(cursor);
141
+ return path.join(realAncestor, ...missingSegments);
142
+ } catch (error) {
143
+ if (
144
+ !error ||
145
+ typeof error !== "object" ||
146
+ !["ENOENT", "ENOTDIR"].includes(error.code)
147
+ ) {
148
+ throw error;
149
+ }
150
+ const parent = path.dirname(cursor);
151
+ if (parent === cursor) {
152
+ throw new Error("Unable to resolve filePath through an existing workspace ancestor.", {
153
+ cause: error,
154
+ });
155
+ }
156
+ missingSegments.unshift(path.basename(cursor));
157
+ cursor = parent;
158
+ }
96
159
  }
97
- return normalized;
98
160
  }
99
161
 
100
- function isLockExpired(lockRecord, nowIso = new Date().toISOString()) {
101
- const nowEpoch = parseEpoch(nowIso, nowIso);
102
- const expiresAtEpoch = parseEpoch(lockRecord?.expiresAt, nowIso);
103
- if (!Number.isFinite(nowEpoch) || !Number.isFinite(expiresAtEpoch)) {
104
- return false;
162
+ async function canonicalizeFilePath(
163
+ filePath,
164
+ { targetPath = process.cwd() } = {},
165
+ ) {
166
+ const workspaceRoot = path.resolve(String(targetPath || "."));
167
+ const realWorkspaceRoot = await fsp.realpath(workspaceRoot);
168
+ const lexicalPath = normalizeFilePath(filePath, { targetPath: workspaceRoot });
169
+ const realCandidate = await resolveRealPathThroughNearestAncestor(
170
+ path.join(workspaceRoot, ...lexicalPath.split("/")),
171
+ );
172
+ const relative = path.relative(realWorkspaceRoot, realCandidate);
173
+ if (
174
+ !relative ||
175
+ path.isAbsolute(relative) ||
176
+ relative === ".." ||
177
+ relative.startsWith(`..${path.sep}`)
178
+ ) {
179
+ throw new Error(
180
+ "filePath resolves through a symlink or junction outside the workspace.",
181
+ );
105
182
  }
106
- return nowEpoch >= expiresAtEpoch;
183
+ return normalizeFilePath(relative, { targetPath: realWorkspaceRoot });
184
+ }
185
+
186
+ function pathKey(value) {
187
+ return normalizeString(value).normalize("NFC").toLowerCase().replace(/\/+$/u, "");
188
+ }
189
+
190
+ function fileLeasePathCovers(leasePath, targetPath) {
191
+ const lease = pathKey(leasePath);
192
+ const target = pathKey(targetPath);
193
+ return Boolean(lease && target && (target === lease || target.startsWith(`${lease}/`)));
194
+ }
195
+
196
+ function fileLeasePathsOverlap(leftPath, rightPath) {
197
+ return fileLeasePathCovers(leftPath, rightPath) || fileLeasePathCovers(rightPath, leftPath);
198
+ }
199
+
200
+ function parseEpoch(value) {
201
+ const parsed = Date.parse(normalizeString(value));
202
+ return Number.isFinite(parsed) ? parsed : 0;
107
203
  }
108
204
 
109
- function normalizeLockRecord(filePath, raw = {}, { nowIso = new Date().toISOString() } = {}) {
110
- const normalizedFile = normalizeString(filePath);
111
- if (!normalizedFile) {
112
- return null;
205
+ function formatSince(fromIso, nowMs = Date.now()) {
206
+ const fromMs = parseEpoch(fromIso);
207
+ const deltaSeconds = Math.max(0, Math.floor((nowMs - fromMs) / 1_000));
208
+ if (deltaSeconds < 60) {
209
+ return `${deltaSeconds}s ago`;
210
+ }
211
+ const deltaMinutes = Math.floor(deltaSeconds / 60);
212
+ if (deltaMinutes < 60) {
213
+ return `${deltaMinutes}m ago`;
113
214
  }
114
- const agentId = normalizeString(raw.agentId).toLowerCase();
115
- if (!agentId) {
116
- return null;
215
+ const deltaHours = Math.floor(deltaMinutes / 60);
216
+ if (deltaHours < 24) {
217
+ return `${deltaHours}h ago`;
117
218
  }
118
- const ttlSeconds = normalizePositiveInteger(raw.ttlSeconds, DEFAULT_FILE_LOCK_TTL_SECONDS);
119
- const lockedAt = normalizeIsoTimestamp(raw.lockedAt, nowIso);
120
- const expiresAt = normalizeIsoTimestamp(raw.expiresAt, toIsoAfterSeconds(lockedAt, ttlSeconds));
219
+ return `${Math.floor(deltaHours / 24)}d ago`;
220
+ }
221
+
222
+ function presentLease(rawLease = {}) {
223
+ const source = rawLease && typeof rawLease === "object" ? rawLease : {};
224
+ const file = normalizeString(source.path || source.file || source.filePath);
225
+ const agentId = normalizeString(source.holderId || source.agentId).toLowerCase();
226
+ const lockedAt = normalizeString(source.acquiredAt || source.lockedAt);
227
+ const ttlSeconds = Number(source.ttlSeconds);
228
+ const revision = Number(source.revision);
121
229
  return {
122
- file: normalizedFile,
230
+ leaseId: normalizeString(source.leaseId || source.id) || null,
231
+ sessionId: normalizeString(source.sessionId) || null,
232
+ file,
233
+ path: file,
123
234
  agentId,
124
- intent: normalizeString(raw.intent),
125
- lockedAt,
126
- expiresAt,
127
- ttlSeconds,
235
+ heldBy: agentId,
236
+ intent: normalizeString(source.intent),
237
+ status: normalizeString(source.status) || "active",
238
+ lockedAt: lockedAt || null,
239
+ acquiredAt: lockedAt || null,
240
+ renewedAt: normalizeString(source.renewedAt) || null,
241
+ expiresAt: normalizeString(source.expiresAt) || null,
242
+ releasedAt: normalizeString(source.releasedAt) || null,
243
+ releaseReason: normalizeString(source.releaseReason) || null,
244
+ ttlSeconds: Number.isInteger(ttlSeconds) ? ttlSeconds : DEFAULT_FILE_LOCK_TTL_SECONDS,
245
+ revision: Number.isInteger(revision) ? revision : 1,
246
+ since: lockedAt ? formatSince(lockedAt) : null,
128
247
  };
129
248
  }
130
249
 
131
- function normalizeRegistry(raw = {}, { sessionId, nowIso = new Date().toISOString() } = {}) {
250
+ function newCapabilityToken() {
251
+ return randomBytes(32).toString("base64url");
252
+ }
253
+
254
+ function normalizeCapabilityClaim(raw, { sessionId } = {}) {
132
255
  const source = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
133
- const inputLocks = source.locks && typeof source.locks === "object" ? source.locks : {};
134
- const locks = {};
135
- for (const [filePath, value] of Object.entries(inputLocks)) {
136
- const normalizedFilePath = normalizeString(filePath);
137
- if (!normalizedFilePath) {
138
- continue;
139
- }
140
- const record = normalizeLockRecord(normalizedFilePath, value, { nowIso });
141
- if (!record) {
142
- continue;
143
- }
144
- locks[normalizedFilePath] = record;
256
+ const leaseId = normalizeString(source.leaseId);
257
+ const claimSessionId = normalizeSessionId(source.sessionId || sessionId);
258
+ const holderId = normalizeAgentId(source.holderId);
259
+ const claimPath = normalizeFilePath(source.path, { targetPath: "." });
260
+ const leaseToken = normalizeString(source.leaseToken);
261
+ if (!leaseId || !/^[0-9a-f-]{36}$/iu.test(leaseId)) {
262
+ throw new Error("File-lease capability cache contains an invalid lease id.");
263
+ }
264
+ if (!LEASE_TOKEN_PATTERN.test(leaseToken)) {
265
+ throw new Error("File-lease capability cache contains an invalid capability token.");
145
266
  }
267
+ return {
268
+ leaseId,
269
+ sessionId: claimSessionId,
270
+ path: claimPath,
271
+ holderId,
272
+ leaseToken,
273
+ expiresAt: normalizeString(source.expiresAt) || null,
274
+ ttlSeconds: normalizeTtlSeconds(source.ttlSeconds),
275
+ revision: Math.max(1, Math.floor(Number(source.revision) || 1)),
276
+ updatedAt: normalizeString(source.updatedAt) || new Date().toISOString(),
277
+ };
278
+ }
146
279
 
280
+ function emptyCapabilityStore(sessionId) {
147
281
  return {
148
- schemaVersion: FILE_LOCK_SCHEMA_VERSION,
149
- sessionId: normalizeString(source.sessionId) || normalizeString(sessionId),
150
- updatedAt: normalizeIsoTimestamp(source.updatedAt, nowIso),
151
- locks,
282
+ schemaVersion: FILE_LEASE_CAPABILITY_SCHEMA_VERSION,
283
+ authoritative: false,
284
+ sessionId,
285
+ updatedAt: new Date().toISOString(),
286
+ claims: [],
152
287
  };
153
288
  }
154
289
 
155
- async function readJsonFile(filePath, { allowMissing = true } = {}) {
290
+ async function readCapabilityStore(sessionId, { targetPath = process.cwd() } = {}) {
291
+ const normalizedSessionId = normalizeSessionId(sessionId);
292
+ const paths = resolveSessionPaths(normalizedSessionId, { targetPath });
293
+ let parsed;
156
294
  try {
157
- const raw = await fsp.readFile(filePath, "utf-8");
158
- return JSON.parse(raw);
295
+ parsed = JSON.parse(await fsp.readFile(paths.fileLeaseCapabilitiesPath, "utf-8"));
159
296
  } catch (error) {
160
- if (allowMissing && error && typeof error === "object" && error.code === "ENOENT") {
161
- return null;
297
+ if (error && typeof error === "object" && error.code === "ENOENT") {
298
+ return emptyCapabilityStore(normalizedSessionId);
299
+ }
300
+ if (error instanceof SyntaxError) {
301
+ throw new Error(
302
+ "File-lease capability cache is corrupt; refusing to bypass the authoritative guard.",
303
+ { cause: error },
304
+ );
162
305
  }
163
306
  throw error;
164
307
  }
308
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
309
+ throw new Error("File-lease capability cache has an invalid root object.");
310
+ }
311
+ if (parsed.authoritative !== false) {
312
+ throw new Error("File-lease capability cache must be explicitly non-authoritative.");
313
+ }
314
+ if (normalizeSessionId(parsed.sessionId) !== normalizedSessionId) {
315
+ throw new Error("File-lease capability cache belongs to a different session.");
316
+ }
317
+ if (!Array.isArray(parsed.claims)) {
318
+ throw new Error("File-lease capability cache claims must be an array.");
319
+ }
320
+ return {
321
+ schemaVersion: FILE_LEASE_CAPABILITY_SCHEMA_VERSION,
322
+ authoritative: false,
323
+ sessionId: normalizedSessionId,
324
+ updatedAt: normalizeString(parsed.updatedAt) || new Date().toISOString(),
325
+ claims: parsed.claims.map((claim) =>
326
+ normalizeCapabilityClaim(claim, { sessionId: normalizedSessionId }),
327
+ ),
328
+ };
165
329
  }
166
330
 
167
- async function writeJsonFile(filePath, payload) {
168
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
169
- const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
170
- await fsp.writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
171
- await fsp.rename(tmpPath, filePath);
172
- }
173
-
174
- async function ensureSessionExists(paths) {
331
+ async function writeCapabilityStore(paths, store) {
332
+ await fsp.mkdir(paths.sessionDir, { recursive: true, mode: 0o700 });
333
+ const tmpPath = `${paths.fileLeaseCapabilitiesPath}.${process.pid}.${Date.now()}.tmp`;
334
+ const payload = {
335
+ schemaVersion: FILE_LEASE_CAPABILITY_SCHEMA_VERSION,
336
+ authoritative: false,
337
+ sessionId: paths.sessionId,
338
+ updatedAt: new Date().toISOString(),
339
+ claims: store.claims,
340
+ };
175
341
  try {
176
- await fsp.access(paths.metadataPath);
342
+ await fsp.writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, {
343
+ encoding: "utf-8",
344
+ mode: 0o600,
345
+ });
346
+ await fsp.chmod(tmpPath, 0o600).catch(() => {});
347
+ await fsp.rename(tmpPath, paths.fileLeaseCapabilitiesPath);
348
+ await fsp.chmod(paths.fileLeaseCapabilitiesPath, 0o600).catch(() => {});
177
349
  } catch (error) {
178
- if (error && typeof error === "object" && error.code === "ENOENT") {
179
- throw new Error(`Session '${paths.sessionId}' was not found.`);
180
- }
350
+ await fsp.rm(tmpPath, { force: true }).catch(() => {});
181
351
  throw error;
182
352
  }
183
353
  }
184
354
 
185
- async function acquireLock(lockPath, {
186
- timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
187
- staleMs = DEFAULT_LOCK_STALE_MS,
188
- pollMs = DEFAULT_LOCK_POLL_MS,
189
- } = {}) {
190
- const start = Date.now();
355
+ async function acquireCapabilityMutex(
356
+ lockPath,
357
+ {
358
+ timeoutMs = DEFAULT_CAPABILITY_LOCK_TIMEOUT_MS,
359
+ staleMs = DEFAULT_CAPABILITY_LOCK_STALE_MS,
360
+ pollMs = DEFAULT_CAPABILITY_LOCK_POLL_MS,
361
+ } = {},
362
+ ) {
363
+ const startedAt = Date.now();
191
364
  while (true) {
192
365
  try {
193
- await fsp.mkdir(lockPath);
366
+ await fsp.mkdir(lockPath, { recursive: false, mode: 0o700 });
194
367
  return;
195
368
  } catch (error) {
196
- const code = error && typeof error === "object" ? error.code : "";
197
- if (!(code === "EEXIST" || code === "EPERM" || code === "EACCES")) {
369
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") {
198
370
  throw error;
199
371
  }
200
-
201
372
  try {
202
373
  const stat = await fsp.stat(lockPath);
203
- const ageMs = Date.now() - Number(stat.mtimeMs || 0);
204
- if (Number.isFinite(ageMs) && ageMs > staleMs) {
374
+ if (Date.now() - Number(stat.mtimeMs || 0) > staleMs) {
205
375
  await fsp.rm(lockPath, { recursive: true, force: true });
206
376
  continue;
207
377
  }
208
378
  } catch {
209
- // Continue waiting.
379
+ continue;
210
380
  }
211
-
212
- if (Date.now() - start >= timeoutMs) {
213
- throw new Error("Timed out waiting for session file lock registry.");
381
+ if (Date.now() - startedAt >= timeoutMs) {
382
+ throw new Error("Timed out waiting for the local file-lease capability cache.");
214
383
  }
215
384
  await sleep(pollMs);
216
385
  }
217
386
  }
218
387
  }
219
388
 
220
- async function releaseLock(lockPath) {
221
- await fsp.rm(lockPath, { recursive: true, force: true }).catch(() => {});
389
+ async function mutateCapabilityStore(
390
+ sessionId,
391
+ { targetPath = process.cwd() } = {},
392
+ mutator = async () => {},
393
+ ) {
394
+ const normalizedSessionId = normalizeSessionId(sessionId);
395
+ const paths = resolveSessionPaths(normalizedSessionId, { targetPath });
396
+ await fsp.mkdir(paths.sessionDir, { recursive: true, mode: 0o700 });
397
+ await acquireCapabilityMutex(paths.fileLeaseCapabilitiesLockPath);
398
+ try {
399
+ const store = await readCapabilityStore(normalizedSessionId, { targetPath });
400
+ const result = await mutator(store);
401
+ await writeCapabilityStore(paths, store);
402
+ return result;
403
+ } finally {
404
+ await fsp.rm(paths.fileLeaseCapabilitiesLockPath, {
405
+ recursive: true,
406
+ force: true,
407
+ }).catch(() => {});
408
+ }
222
409
  }
223
410
 
224
- function pruneExpiredLocks(registry, { nowIso = new Date().toISOString() } = {}) {
225
- const expired = [];
226
- for (const [filePath, lockRecord] of Object.entries(registry.locks || {})) {
227
- if (!isLockExpired(lockRecord, nowIso)) {
228
- continue;
229
- }
230
- expired.push({
231
- ...lockRecord,
232
- file: filePath,
233
- expiredAt: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
234
- });
235
- delete registry.locks[filePath];
236
- }
237
- return expired;
411
+ function findExactClaim(claims, holderId, filePath) {
412
+ const holder = normalizeAgentId(holderId);
413
+ const targetKey = pathKey(filePath);
414
+ return (
415
+ claims.find(
416
+ (claim) =>
417
+ normalizeAgentId(claim.holderId) === holder && pathKey(claim.path) === targetKey,
418
+ ) || null
419
+ );
238
420
  }
239
421
 
240
- function presentLock(lockRecord, { nowIso = new Date().toISOString() } = {}) {
241
- if (!lockRecord) {
242
- return null;
243
- }
244
- return {
245
- file: normalizeString(lockRecord.file),
246
- agentId: normalizeString(lockRecord.agentId),
247
- intent: normalizeString(lockRecord.intent),
248
- lockedAt: normalizeIsoTimestamp(lockRecord.lockedAt, nowIso),
249
- expiresAt: normalizeIsoTimestamp(lockRecord.expiresAt, nowIso),
250
- ttlSeconds: normalizePositiveInteger(lockRecord.ttlSeconds, DEFAULT_FILE_LOCK_TTL_SECONDS),
251
- since: formatSince(lockRecord.lockedAt, nowIso),
252
- };
422
+ function findCoveringClaim(claims, holderId, filePath) {
423
+ const holder = normalizeAgentId(holderId);
424
+ return (
425
+ claims
426
+ .filter(
427
+ (claim) =>
428
+ normalizeAgentId(claim.holderId) === holder &&
429
+ fileLeasePathCovers(claim.path, filePath),
430
+ )
431
+ .sort((left, right) => pathKey(right.path).length - pathKey(left.path).length)[0] || null
432
+ );
253
433
  }
254
434
 
255
- async function appendLockEvent(
435
+ async function saveCapabilityClaim(sessionId, claim, { targetPath = process.cwd() } = {}) {
436
+ const normalized = normalizeCapabilityClaim(claim, { sessionId });
437
+ await mutateCapabilityStore(
438
+ sessionId,
439
+ { targetPath },
440
+ async (store) => {
441
+ store.claims = store.claims.filter(
442
+ (existing) =>
443
+ existing.leaseId !== normalized.leaseId &&
444
+ !(
445
+ normalizeAgentId(existing.holderId) === normalized.holderId &&
446
+ pathKey(existing.path) === pathKey(normalized.path)
447
+ ),
448
+ );
449
+ store.claims.push(normalized);
450
+ },
451
+ );
452
+ return normalized;
453
+ }
454
+
455
+ async function removeCapabilityClaims(
256
456
  sessionId,
257
- event,
258
- agentId,
259
- payload,
260
- {
261
- targetPath = process.cwd(),
262
- nowIso = new Date().toISOString(),
263
- syncRemote = true,
264
- awaitRemoteSync = false,
265
- } = {}
457
+ predicate,
458
+ { targetPath = process.cwd() } = {},
266
459
  ) {
267
- return appendToStream(
460
+ return mutateCapabilityStore(
268
461
  sessionId,
269
- createAgentEvent({
270
- event,
271
- agentId: normalizeAgentId(agentId),
272
- sessionId,
273
- ts: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
274
- payload,
275
- }),
276
- {
277
- targetPath,
278
- syncRemote,
279
- awaitRemoteSync,
280
- }
462
+ { targetPath },
463
+ async (store) => {
464
+ const removed = store.claims.filter(predicate);
465
+ store.claims = store.claims.filter((claim) => !predicate(claim));
466
+ return removed;
467
+ },
281
468
  );
282
469
  }
283
470
 
284
- async function emitExpiredLockEvents(
471
+ async function resolveLeaseApi({
472
+ targetPath = process.cwd(),
473
+ resolveAuthSession = resolveActiveAuthSession,
474
+ } = {}) {
475
+ const auth = await resolveAuthSession({
476
+ cwd: targetPath,
477
+ env: process.env,
478
+ autoRotate: false,
479
+ });
480
+ if (!auth?.token || !auth?.apiUrl) {
481
+ throw new Error(
482
+ "Authoritative session file leases require Sentinelayer auth. Run `sl auth login` first.",
483
+ );
484
+ }
485
+ return {
486
+ apiUrl: normalizeApiUrl(auth.apiUrl),
487
+ headers: { Authorization: `Bearer ${auth.token}` },
488
+ };
489
+ }
490
+
491
+ function leaseCollectionUrl(apiUrl, sessionId) {
492
+ return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/file-leases`;
493
+ }
494
+
495
+ function leaseMemberUrl(apiUrl, sessionId, leaseId, action) {
496
+ return `${leaseCollectionUrl(apiUrl, sessionId)}/${encodeURIComponent(leaseId)}/${action}`;
497
+ }
498
+
499
+ async function listRemoteLeases(
285
500
  sessionId,
286
- expiredLocks = [],
287
501
  {
288
502
  targetPath = process.cwd(),
289
- nowIso = new Date().toISOString(),
290
- actorAgentId = SENTI_AGENT_ID,
291
- } = {}
503
+ resolveAuthSession = resolveActiveAuthSession,
504
+ request = requestJson,
505
+ } = {},
292
506
  ) {
293
- const events = [];
294
- for (const lockRecord of expiredLocks) {
295
- const payload = {
296
- file: normalizeString(lockRecord.file),
297
- heldBy: normalizeString(lockRecord.agentId),
298
- intent: normalizeString(lockRecord.intent),
299
- lockedAt: normalizeIsoTimestamp(lockRecord.lockedAt, nowIso),
300
- expiresAt: normalizeIsoTimestamp(lockRecord.expiresAt, nowIso),
301
- expiredAt: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
302
- };
303
- const event = await appendLockEvent(
304
- sessionId,
305
- "file_lock_expired",
306
- actorAgentId,
307
- payload,
308
- {
309
- targetPath,
310
- nowIso,
311
- }
312
- );
313
- events.push(event);
507
+ const normalizedSessionId = normalizeSessionId(sessionId);
508
+ const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
509
+ const response = await request(leaseCollectionUrl(apiUrl, normalizedSessionId), {
510
+ method: "GET",
511
+ headers,
512
+ });
513
+ if (response?.authoritative !== true || !Array.isArray(response?.leases)) {
514
+ throw new Error("File-lease authority returned an invalid list response; refusing local fallback.");
314
515
  }
315
- return events;
516
+ return response.leases.map((lease) => presentLease(lease));
316
517
  }
317
518
 
318
- async function mutateRegistry(
519
+ async function guardRemoteClaims(
319
520
  sessionId,
521
+ holderId,
522
+ paths,
523
+ claims,
320
524
  {
321
525
  targetPath = process.cwd(),
322
- nowIso = new Date().toISOString(),
323
- emitExpiredEvents = true,
324
- expiredEventAgentId = SENTI_AGENT_ID,
526
+ resolveAuthSession = resolveActiveAuthSession,
527
+ requestMutation = requestJsonMutation,
325
528
  } = {},
326
- mutator = async () => ({})
327
529
  ) {
328
- const paths = resolveSessionPaths(sessionId, { targetPath });
329
- await ensureSessionExists(paths);
330
- const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
331
-
332
- await acquireLock(paths.fileLocksLockPath);
333
- let result = null;
334
- let expiredLocks = [];
335
- try {
336
- const rawRegistry = await readJsonFile(paths.fileLocksPath, { allowMissing: true });
337
- const registry = normalizeRegistry(rawRegistry || {}, {
338
- sessionId: paths.sessionId,
339
- nowIso: normalizedNow,
340
- });
341
- expiredLocks = pruneExpiredLocks(registry, {
342
- nowIso: normalizedNow,
343
- });
344
- result = await mutator(registry, {
345
- nowIso: normalizedNow,
346
- paths,
347
- });
348
- registry.updatedAt = normalizedNow;
349
- await writeJsonFile(paths.fileLocksPath, registry);
350
- } finally {
351
- await releaseLock(paths.fileLocksLockPath);
530
+ const normalizedSessionId = normalizeSessionId(sessionId);
531
+ const normalizedHolderId = normalizeAgentId(holderId);
532
+ const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
533
+ const response = await requestMutation(
534
+ `${leaseCollectionUrl(apiUrl, normalizedSessionId)}/guard`,
535
+ {
536
+ method: "POST",
537
+ operationName: "session-file-lease-guard",
538
+ headers,
539
+ body: {
540
+ holderId: normalizedHolderId,
541
+ claims: paths.map((file, index) => ({
542
+ path: file,
543
+ leaseId: claims[index]?.leaseId || null,
544
+ leaseToken: claims[index]?.leaseToken || null,
545
+ })),
546
+ },
547
+ maxRetries: 1,
548
+ },
549
+ );
550
+ if (
551
+ response?.authoritative !== true ||
552
+ typeof response?.allowed !== "boolean" ||
553
+ normalizeString(response?.sessionId) !== normalizedSessionId ||
554
+ normalizeString(response?.holderId).toLowerCase() !== normalizedHolderId ||
555
+ !Array.isArray(response?.guarded) ||
556
+ !Array.isArray(response?.denials)
557
+ ) {
558
+ throw new Error("File-lease authority returned an invalid guard response; edit blocked.");
352
559
  }
560
+ if (response.allowed) {
561
+ const requestedByPath = new Map(
562
+ paths.map((file, index) => [
563
+ normalizeString(file).normalize("NFC"),
564
+ { claim: claims[index] },
565
+ ]),
566
+ );
567
+ const guardedPaths = new Set();
568
+ let bindingIsValid =
569
+ response.denials.length === 0 &&
570
+ response.guarded.length === paths.length &&
571
+ requestedByPath.size === paths.length;
353
572
 
354
- const expiredEvents = emitExpiredEvents
355
- ? await emitExpiredLockEvents(sessionId, expiredLocks, {
356
- targetPath,
357
- nowIso: normalizedNow,
358
- actorAgentId: expiredEventAgentId,
359
- })
360
- : [];
573
+ for (const guarded of response.guarded) {
574
+ const guardedPath = normalizeString(guarded?.path).normalize("NFC");
575
+ const requested = requestedByPath.get(guardedPath);
576
+ if (!requested || guardedPaths.has(guardedPath)) {
577
+ bindingIsValid = false;
578
+ continue;
579
+ }
580
+ guardedPaths.add(guardedPath);
581
+
582
+ const submittedLeaseId = normalizeString(requested.claim?.leaseId);
583
+ const submittedLeaseToken = normalizeString(requested.claim?.leaseToken);
584
+ if (!submittedLeaseId || !submittedLeaseToken) {
585
+ bindingIsValid = false;
586
+ continue;
587
+ }
361
588
 
589
+ const responseLease =
590
+ guarded?.lease && typeof guarded.lease === "object" && !Array.isArray(guarded.lease)
591
+ ? guarded.lease
592
+ : {};
593
+ const returnedLeaseIds = [
594
+ guarded?.leaseId,
595
+ responseLease.leaseId,
596
+ responseLease.id,
597
+ ]
598
+ .filter((value) => value !== undefined && value !== null)
599
+ .map((value) => normalizeString(value).toLowerCase());
600
+ const returnedLeaseTokens = [
601
+ guarded?.leaseToken,
602
+ responseLease.leaseToken,
603
+ ]
604
+ .filter((value) => value !== undefined && value !== null)
605
+ .map((value) => normalizeString(value));
606
+ if (
607
+ returnedLeaseIds.some(
608
+ (leaseId) => !leaseId || leaseId !== submittedLeaseId.toLowerCase(),
609
+ ) ||
610
+ returnedLeaseTokens.some(
611
+ (leaseToken) => !leaseToken || leaseToken !== submittedLeaseToken,
612
+ )
613
+ ) {
614
+ bindingIsValid = false;
615
+ }
616
+ }
617
+ if (!bindingIsValid || guardedPaths.size !== paths.length) {
618
+ throw new Error(
619
+ "File-lease authority returned an inconsistent allow decision; edit blocked.",
620
+ );
621
+ }
622
+ }
362
623
  return {
363
- result,
364
- expiredLocks,
365
- expiredEvents,
624
+ ...response,
625
+ files: paths,
366
626
  };
367
627
  }
368
628
 
629
+ export async function guardFileLeases(
630
+ sessionId,
631
+ agentId,
632
+ filePaths,
633
+ {
634
+ targetPath = process.cwd(),
635
+ resolveAuthSession = resolveActiveAuthSession,
636
+ requestMutation = requestJsonMutation,
637
+ } = {},
638
+ ) {
639
+ const normalizedSessionId = normalizeSessionId(sessionId);
640
+ const normalizedAgentId = normalizeAgentId(agentId);
641
+ const files = await Promise.all(
642
+ (Array.isArray(filePaths) ? filePaths : [filePaths]).map((file) =>
643
+ canonicalizeFilePath(file, { targetPath }),
644
+ ),
645
+ );
646
+ if (files.length === 0) {
647
+ throw new Error("At least one file path is required for the file-lease guard.");
648
+ }
649
+ const store = await readCapabilityStore(normalizedSessionId, { targetPath });
650
+ const claims = files.map((file) =>
651
+ findCoveringClaim(store.claims, normalizedAgentId, file),
652
+ );
653
+ return guardRemoteClaims(normalizedSessionId, normalizedAgentId, files, claims, {
654
+ targetPath,
655
+ resolveAuthSession,
656
+ requestMutation,
657
+ });
658
+ }
659
+
369
660
  export async function lockFile(
370
661
  sessionId,
371
662
  agentId,
@@ -374,239 +665,426 @@ export async function lockFile(
374
665
  intent = "",
375
666
  ttlSeconds = DEFAULT_FILE_LOCK_TTL_SECONDS,
376
667
  targetPath = process.cwd(),
377
- nowIso = new Date().toISOString(),
378
- syncRemote = true,
379
- awaitRemoteSync = false,
380
- } = {}
668
+ resolveAuthSession = resolveActiveAuthSession,
669
+ request = requestJson,
670
+ requestMutation = requestJsonMutation,
671
+ } = {},
381
672
  ) {
673
+ const normalizedSessionId = normalizeSessionId(sessionId);
382
674
  const normalizedAgentId = normalizeAgentId(agentId);
383
- const normalizedFilePath = normalizeFilePath(filePath, { targetPath });
384
- const normalizedIntent = normalizeString(intent);
385
- const normalizedTtlSeconds = normalizePositiveInteger(ttlSeconds, DEFAULT_FILE_LOCK_TTL_SECONDS);
386
- const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
387
-
388
- const mutation = await mutateRegistry(
389
- sessionId,
390
- {
391
- targetPath,
392
- nowIso: normalizedNow,
393
- emitExpiredEvents: true,
394
- expiredEventAgentId: SENTI_AGENT_ID,
395
- },
396
- async (registry) => {
397
- const existing = registry.locks[normalizedFilePath] || null;
398
- if (existing && existing.agentId !== normalizedAgentId) {
399
- return {
400
- locked: false,
401
- file: normalizedFilePath,
402
- heldBy: normalizeString(existing.agentId),
403
- since: formatSince(existing.lockedAt, normalizedNow),
404
- lock: presentLock({ ...existing, file: normalizedFilePath }, { nowIso: normalizedNow }),
405
- };
406
- }
675
+ const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
676
+ const normalizedIntent = normalizeBoundedText(intent, {
677
+ field: "intent",
678
+ maxLength: 512,
679
+ });
680
+ const normalizedTtlSeconds = normalizeTtlSeconds(ttlSeconds);
681
+ const store = await readCapabilityStore(normalizedSessionId, { targetPath });
682
+ const coveringClaim = findCoveringClaim(
683
+ store.claims,
684
+ normalizedAgentId,
685
+ normalizedFilePath,
686
+ );
687
+ const exactClaim = findExactClaim(
688
+ store.claims,
689
+ normalizedAgentId,
690
+ normalizedFilePath,
691
+ );
407
692
 
408
- const lockedAt = normalizedNow;
409
- const expiresAt = toIsoAfterSeconds(lockedAt, normalizedTtlSeconds);
410
- const lockRecord = {
411
- file: normalizedFilePath,
412
- agentId: normalizedAgentId,
413
- intent: normalizedIntent,
414
- lockedAt,
415
- expiresAt,
416
- ttlSeconds: normalizedTtlSeconds,
417
- // Forensic holder metadata. Not used for correctness (TTL-based
418
- // reclaim handles liveness), but lets operators see which process
419
- // on which host took a lock when debugging stale-lock incidents.
420
- holderPid: process.pid,
421
- holderHostname: os.hostname(),
422
- };
423
- registry.locks[normalizedFilePath] = lockRecord;
693
+ if (coveringClaim && !exactClaim) {
694
+ const guard = await guardRemoteClaims(
695
+ normalizedSessionId,
696
+ normalizedAgentId,
697
+ [normalizedFilePath],
698
+ [coveringClaim],
699
+ { targetPath, resolveAuthSession, requestMutation },
700
+ );
701
+ if (guard.allowed) {
702
+ const lease = presentLease(guard.guarded[0]?.lease || {});
424
703
  return {
425
704
  locked: true,
705
+ duplicate: true,
706
+ inheritedScope: true,
426
707
  file: normalizedFilePath,
427
- lock: presentLock(lockRecord, { nowIso: normalizedNow }),
708
+ lock: lease,
709
+ lease,
428
710
  };
429
711
  }
430
- );
712
+ }
431
713
 
432
- if (mutation.result?.locked) {
433
- const event = await appendLockEvent(
434
- sessionId,
435
- "file_lock",
436
- normalizedAgentId,
714
+ const leaseToken = exactClaim?.leaseToken || newCapabilityToken();
715
+ const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
716
+ let response;
717
+ try {
718
+ response = await requestMutation(
719
+ leaseCollectionUrl(apiUrl, normalizedSessionId),
437
720
  {
438
- file: normalizedFilePath,
439
- intent: normalizedIntent,
440
- ttlSeconds: normalizedTtlSeconds,
441
- expiresAt: mutation.result.lock?.expiresAt || toIsoAfterSeconds(normalizedNow, normalizedTtlSeconds),
721
+ method: "POST",
722
+ operationName: "session-file-lease-acquire",
723
+ headers,
724
+ body: {
725
+ path: normalizedFilePath,
726
+ holderId: normalizedAgentId,
727
+ leaseToken,
728
+ ttlSeconds: normalizedTtlSeconds,
729
+ intent: normalizedIntent || null,
730
+ },
442
731
  },
443
- {
732
+ );
733
+ } catch (error) {
734
+ if (
735
+ error instanceof SentinelayerApiError &&
736
+ error.code === "FILE_LEASE_CONFLICT"
737
+ ) {
738
+ const leases = await listRemoteLeases(normalizedSessionId, {
444
739
  targetPath,
445
- nowIso: normalizedNow,
446
- syncRemote,
447
- awaitRemoteSync,
448
- }
740
+ resolveAuthSession,
741
+ request,
742
+ });
743
+ const conflict =
744
+ leases.find((lease) => fileLeasePathsOverlap(lease.file, normalizedFilePath)) ||
745
+ null;
746
+ return {
747
+ locked: false,
748
+ file: normalizedFilePath,
749
+ reason:
750
+ conflict?.agentId === normalizedAgentId
751
+ ? "holder_capability_unavailable"
752
+ : "held_by_other_agent",
753
+ heldBy: conflict?.agentId || null,
754
+ since: conflict?.since || null,
755
+ lock: conflict,
756
+ };
757
+ }
758
+ throw error;
759
+ }
760
+ if (
761
+ response?.ok !== true
762
+ || response?.authoritative !== true
763
+ || !response?.lease
764
+ ) {
765
+ throw new Error("File-lease authority returned an invalid acquire response.");
766
+ }
767
+ const lease = presentLease(response.lease);
768
+ if (
769
+ !lease.leaseId ||
770
+ pathKey(lease.file) !== pathKey(normalizedFilePath) ||
771
+ lease.agentId !== normalizedAgentId
772
+ ) {
773
+ throw new Error("File-lease authority returned a mismatched acquire response.");
774
+ }
775
+
776
+ try {
777
+ await saveCapabilityClaim(
778
+ normalizedSessionId,
779
+ {
780
+ leaseId: lease.leaseId,
781
+ sessionId: normalizedSessionId,
782
+ path: lease.file,
783
+ holderId: normalizedAgentId,
784
+ leaseToken,
785
+ expiresAt: lease.expiresAt,
786
+ ttlSeconds: lease.ttlSeconds,
787
+ revision: lease.revision,
788
+ updatedAt: new Date().toISOString(),
789
+ },
790
+ { targetPath },
791
+ );
792
+ } catch (cacheError) {
793
+ let compensated = false;
794
+ try {
795
+ const release = await requestMutation(
796
+ leaseMemberUrl(apiUrl, normalizedSessionId, lease.leaseId, "release"),
797
+ {
798
+ method: "POST",
799
+ operationName: "session-file-lease-acquire-compensation",
800
+ headers,
801
+ body: {
802
+ holderId: normalizedAgentId,
803
+ leaseToken,
804
+ reason: "capability_cache_write_failed",
805
+ },
806
+ },
807
+ );
808
+ compensated = Boolean(
809
+ release?.authoritative === true
810
+ && (release?.released || release?.alreadyReleased || release?.expired),
811
+ );
812
+ } catch {
813
+ compensated = false;
814
+ }
815
+ throw new Error(
816
+ compensated
817
+ ? "File lease was released because its local capability could not be stored."
818
+ : "File-lease capability storage failed; edit blocked and the lease will expire by TTL.",
819
+ { cause: cacheError },
449
820
  );
450
- return {
451
- ...mutation.result,
452
- event,
453
- expiredEvents: mutation.expiredEvents,
454
- };
455
821
  }
456
822
 
457
823
  return {
458
- ...mutation.result,
459
- expiredEvents: mutation.expiredEvents,
824
+ locked: true,
825
+ duplicate: Boolean(response.duplicate),
826
+ file: normalizedFilePath,
827
+ lock: lease,
828
+ lease,
460
829
  };
461
830
  }
462
831
 
832
+ async function findClaimForRelease(sessionId, agentId, filePath, { targetPath }) {
833
+ const store = await readCapabilityStore(sessionId, { targetPath });
834
+ return findExactClaim(store.claims, agentId, filePath);
835
+ }
836
+
837
+ async function releaseCapabilityClaim(
838
+ sessionId,
839
+ agentId,
840
+ claim,
841
+ {
842
+ reason,
843
+ targetPath,
844
+ resolveAuthSession,
845
+ requestMutation,
846
+ },
847
+ ) {
848
+ const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
849
+ let response;
850
+ try {
851
+ response = await requestMutation(
852
+ leaseMemberUrl(apiUrl, sessionId, claim.leaseId, "release"),
853
+ {
854
+ method: "POST",
855
+ operationName: "session-file-lease-release",
856
+ headers,
857
+ body: {
858
+ holderId: agentId,
859
+ leaseToken: claim.leaseToken,
860
+ reason,
861
+ },
862
+ },
863
+ );
864
+ } catch (error) {
865
+ if (
866
+ error instanceof SentinelayerApiError &&
867
+ error.code === "FILE_LEASE_NOT_FOUND"
868
+ ) {
869
+ response = {
870
+ ok: true,
871
+ authoritative: true,
872
+ released: false,
873
+ alreadyReleased: true,
874
+ };
875
+ } else {
876
+ throw error;
877
+ }
878
+ }
879
+ if (response?.ok !== true || response?.authoritative !== true) {
880
+ throw new Error("File-lease authority returned an invalid release response.");
881
+ }
882
+ await removeCapabilityClaims(
883
+ sessionId,
884
+ (existing) => existing.leaseId === claim.leaseId,
885
+ { targetPath },
886
+ );
887
+ return response;
888
+ }
889
+
463
890
  export async function unlockFile(
464
891
  sessionId,
465
892
  agentId,
466
893
  filePath,
467
894
  {
468
895
  reason = "manual_release",
469
- force = false,
470
896
  targetPath = process.cwd(),
471
- nowIso = new Date().toISOString(),
472
- actorAgentId = null,
473
- syncRemote = true,
474
- awaitRemoteSync = false,
475
- } = {}
897
+ resolveAuthSession = resolveActiveAuthSession,
898
+ request = requestJson,
899
+ requestMutation = requestJsonMutation,
900
+ } = {},
476
901
  ) {
902
+ const normalizedSessionId = normalizeSessionId(sessionId);
477
903
  const normalizedAgentId = normalizeAgentId(agentId);
478
- const normalizedFilePath = normalizeFilePath(filePath, { targetPath });
479
- const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
480
- const normalizedReason = normalizeString(reason) || "manual_release";
481
-
482
- const mutation = await mutateRegistry(
483
- sessionId,
484
- {
904
+ const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
905
+ const normalizedReason =
906
+ normalizeBoundedText(reason || "manual_release", {
907
+ field: "reason",
908
+ maxLength: 128,
909
+ }) || "manual_release";
910
+ const claim = await findClaimForRelease(
911
+ normalizedSessionId,
912
+ normalizedAgentId,
913
+ normalizedFilePath,
914
+ { targetPath },
915
+ );
916
+ if (!claim) {
917
+ const leases = await listRemoteLeases(normalizedSessionId, {
485
918
  targetPath,
486
- nowIso: normalizedNow,
487
- emitExpiredEvents: true,
488
- expiredEventAgentId: SENTI_AGENT_ID,
489
- },
490
- async (registry) => {
491
- const existing = registry.locks[normalizedFilePath] || null;
492
- if (!existing) {
493
- return {
494
- unlocked: false,
495
- file: normalizedFilePath,
496
- reason: "not_locked",
497
- };
498
- }
499
- if (!force && normalizeString(existing.agentId) !== normalizedAgentId) {
500
- return {
501
- unlocked: false,
502
- file: normalizedFilePath,
503
- reason: "held_by_other_agent",
504
- heldBy: normalizeString(existing.agentId),
505
- since: formatSince(existing.lockedAt, normalizedNow),
506
- lock: presentLock({ ...existing, file: normalizedFilePath }, { nowIso: normalizedNow }),
507
- };
508
- }
509
-
510
- delete registry.locks[normalizedFilePath];
919
+ resolveAuthSession,
920
+ request,
921
+ });
922
+ const exact = leases.find(
923
+ (lease) => pathKey(lease.file) === pathKey(normalizedFilePath),
924
+ );
925
+ const covering = leases.find((lease) =>
926
+ fileLeasePathCovers(lease.file, normalizedFilePath),
927
+ );
928
+ if (covering && pathKey(covering.file) !== pathKey(normalizedFilePath)) {
511
929
  return {
512
- unlocked: true,
930
+ unlocked: false,
513
931
  file: normalizedFilePath,
514
- lock: presentLock({ ...existing, file: normalizedFilePath }, { nowIso: normalizedNow }),
932
+ reason: "covered_by_parent_scope",
933
+ heldBy: covering.agentId,
934
+ lock: covering,
935
+ };
936
+ }
937
+ if (!exact) {
938
+ return {
939
+ unlocked: false,
940
+ file: normalizedFilePath,
941
+ reason: "not_locked",
515
942
  };
516
943
  }
517
- );
518
-
519
- if (!mutation.result?.unlocked) {
520
944
  return {
521
- ...mutation.result,
522
- expiredEvents: mutation.expiredEvents,
945
+ unlocked: false,
946
+ file: normalizedFilePath,
947
+ reason:
948
+ exact.agentId === normalizedAgentId
949
+ ? "holder_capability_unavailable"
950
+ : "held_by_other_agent",
951
+ heldBy: exact.agentId,
952
+ since: exact.since,
953
+ lock: exact,
523
954
  };
524
955
  }
525
956
 
526
- const emittedBy = normalizeAgentId(actorAgentId || (force ? SENTI_AGENT_ID : normalizedAgentId));
527
- const event = await appendLockEvent(
528
- sessionId,
529
- "file_unlock",
530
- emittedBy,
957
+ const response = await releaseCapabilityClaim(
958
+ normalizedSessionId,
959
+ normalizedAgentId,
960
+ claim,
531
961
  {
532
- file: normalizedFilePath,
533
- heldBy: normalizeString(mutation.result.lock?.agentId) || normalizedAgentId,
534
- intent: normalizeString(mutation.result.lock?.intent),
535
962
  reason: normalizedReason,
536
- },
537
- {
538
963
  targetPath,
539
- nowIso: normalizedNow,
540
- syncRemote,
541
- awaitRemoteSync,
542
- }
964
+ resolveAuthSession,
965
+ requestMutation,
966
+ },
543
967
  );
544
-
968
+ const lease = response.lease ? presentLease(response.lease) : null;
545
969
  return {
546
- ...mutation.result,
547
- event,
548
- expiredEvents: mutation.expiredEvents,
970
+ unlocked: Boolean(response.released),
971
+ file: normalizedFilePath,
972
+ reason: response.released
973
+ ? "unlocked"
974
+ : response.expired
975
+ ? "expired"
976
+ : "already_released",
977
+ lock: lease,
549
978
  };
550
979
  }
551
980
 
552
- export async function checkFileLock(
981
+ export async function renewFileLease(
553
982
  sessionId,
983
+ agentId,
554
984
  filePath,
555
985
  {
986
+ ttlSeconds = DEFAULT_FILE_LOCK_TTL_SECONDS,
556
987
  targetPath = process.cwd(),
557
- nowIso = new Date().toISOString(),
558
- emitExpiredEvents = true,
559
- } = {}
988
+ resolveAuthSession = resolveActiveAuthSession,
989
+ requestMutation = requestJsonMutation,
990
+ } = {},
560
991
  ) {
561
- const normalizedFilePath = normalizeFilePath(filePath, { targetPath });
562
- const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
992
+ const normalizedSessionId = normalizeSessionId(sessionId);
993
+ const normalizedAgentId = normalizeAgentId(agentId);
994
+ const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
995
+ const normalizedTtlSeconds = normalizeTtlSeconds(ttlSeconds);
996
+ const store = await readCapabilityStore(normalizedSessionId, { targetPath });
997
+ const claim = findCoveringClaim(
998
+ store.claims,
999
+ normalizedAgentId,
1000
+ normalizedFilePath,
1001
+ );
1002
+ if (!claim) {
1003
+ return {
1004
+ renewed: false,
1005
+ file: normalizedFilePath,
1006
+ reason: "holder_capability_unavailable",
1007
+ };
1008
+ }
563
1009
 
564
- const mutation = await mutateRegistry(
565
- sessionId,
1010
+ const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
1011
+ const response = await requestMutation(
1012
+ leaseMemberUrl(apiUrl, normalizedSessionId, claim.leaseId, "renew"),
566
1013
  {
567
- targetPath,
568
- nowIso: normalizedNow,
569
- emitExpiredEvents,
570
- expiredEventAgentId: SENTI_AGENT_ID,
1014
+ method: "POST",
1015
+ operationName: "session-file-lease-renew",
1016
+ headers,
1017
+ body: {
1018
+ holderId: normalizedAgentId,
1019
+ leaseToken: claim.leaseToken,
1020
+ ttlSeconds: normalizedTtlSeconds,
1021
+ },
571
1022
  },
572
- async (registry) => {
573
- const existing = registry.locks[normalizedFilePath] || null;
574
- if (!existing) {
575
- return null;
576
- }
577
- return presentLock({ ...existing, file: normalizedFilePath }, { nowIso: normalizedNow });
578
- }
579
1023
  );
580
-
581
- return mutation.result;
1024
+ if (
1025
+ response?.ok !== true
1026
+ || response?.authoritative !== true
1027
+ || response?.renewed !== true
1028
+ || !response?.lease
1029
+ ) {
1030
+ throw new Error("File-lease authority returned an invalid renew response.");
1031
+ }
1032
+ const lease = presentLease(response.lease);
1033
+ await saveCapabilityClaim(
1034
+ normalizedSessionId,
1035
+ {
1036
+ ...claim,
1037
+ path: lease.file,
1038
+ expiresAt: lease.expiresAt,
1039
+ ttlSeconds: lease.ttlSeconds,
1040
+ revision: lease.revision,
1041
+ updatedAt: new Date().toISOString(),
1042
+ },
1043
+ { targetPath },
1044
+ );
1045
+ return {
1046
+ renewed: true,
1047
+ file: normalizedFilePath,
1048
+ lease,
1049
+ lock: lease,
1050
+ };
582
1051
  }
583
1052
 
584
- export async function listFileLocks(
1053
+ export async function checkFileLock(
585
1054
  sessionId,
1055
+ filePath,
586
1056
  {
587
1057
  targetPath = process.cwd(),
588
- nowIso = new Date().toISOString(),
589
- emitExpiredEvents = true,
590
- } = {}
1058
+ resolveAuthSession = resolveActiveAuthSession,
1059
+ request = requestJson,
1060
+ } = {},
591
1061
  ) {
592
- const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
593
-
594
- const mutation = await mutateRegistry(
595
- sessionId,
596
- {
597
- targetPath,
598
- nowIso: normalizedNow,
599
- emitExpiredEvents,
600
- expiredEventAgentId: SENTI_AGENT_ID,
601
- },
602
- async (registry) =>
603
- Object.entries(registry.locks || {})
604
- .map(([file, lockRecord]) => presentLock({ ...lockRecord, file }, { nowIso: normalizedNow }))
605
- .filter(Boolean)
606
- .sort((left, right) => parseEpoch(left.lockedAt, normalizedNow) - parseEpoch(right.lockedAt, normalizedNow))
1062
+ const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
1063
+ const leases = await listRemoteLeases(sessionId, {
1064
+ targetPath,
1065
+ resolveAuthSession,
1066
+ request,
1067
+ });
1068
+ return (
1069
+ leases.find((lease) => fileLeasePathCovers(lease.file, normalizedFilePath)) ||
1070
+ leases.find((lease) => fileLeasePathsOverlap(lease.file, normalizedFilePath)) ||
1071
+ null
607
1072
  );
1073
+ }
608
1074
 
609
- return mutation.result;
1075
+ export async function listFileLocks(
1076
+ sessionId,
1077
+ {
1078
+ targetPath = process.cwd(),
1079
+ resolveAuthSession = resolveActiveAuthSession,
1080
+ request = requestJson,
1081
+ } = {},
1082
+ ) {
1083
+ return listRemoteLeases(sessionId, {
1084
+ targetPath,
1085
+ resolveAuthSession,
1086
+ request,
1087
+ });
610
1088
  }
611
1089
 
612
1090
  export async function releaseFileLocksForAgent(
@@ -615,64 +1093,89 @@ export async function releaseFileLocksForAgent(
615
1093
  {
616
1094
  reason = "agent_killed",
617
1095
  targetPath = process.cwd(),
618
- nowIso = new Date().toISOString(),
619
- actorAgentId = SENTI_AGENT_ID,
620
- } = {}
1096
+ resolveAuthSession = resolveActiveAuthSession,
1097
+ request = requestJson,
1098
+ requestMutation = requestJsonMutation,
1099
+ } = {},
621
1100
  ) {
1101
+ const normalizedSessionId = normalizeSessionId(sessionId);
622
1102
  const normalizedAgentId = normalizeAgentId(agentId);
623
- const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
624
- const normalizedReason = normalizeString(reason) || "agent_killed";
625
-
626
- const mutation = await mutateRegistry(
627
- sessionId,
628
- {
629
- targetPath,
630
- nowIso: normalizedNow,
631
- emitExpiredEvents: true,
632
- expiredEventAgentId: normalizeAgentId(actorAgentId || SENTI_AGENT_ID),
633
- },
634
- async (registry) => {
635
- const released = [];
636
- for (const [filePath, lockRecord] of Object.entries(registry.locks || {})) {
637
- if (normalizeString(lockRecord.agentId) !== normalizedAgentId) {
638
- continue;
639
- }
640
- released.push(presentLock({ ...lockRecord, file: filePath }, { nowIso: normalizedNow }));
641
- delete registry.locks[filePath];
1103
+ const normalizedReason =
1104
+ normalizeBoundedText(reason || "agent_killed", {
1105
+ field: "reason",
1106
+ maxLength: 128,
1107
+ }) || "agent_killed";
1108
+ const store = await readCapabilityStore(normalizedSessionId, { targetPath });
1109
+ const claims = store.claims.filter(
1110
+ (claim) => normalizeAgentId(claim.holderId) === normalizedAgentId,
1111
+ );
1112
+ const released = [];
1113
+ const failures = [];
1114
+ for (const claim of claims) {
1115
+ try {
1116
+ const response = await releaseCapabilityClaim(
1117
+ normalizedSessionId,
1118
+ normalizedAgentId,
1119
+ claim,
1120
+ {
1121
+ reason: normalizedReason,
1122
+ targetPath,
1123
+ resolveAuthSession,
1124
+ requestMutation,
1125
+ },
1126
+ );
1127
+ if (response.released) {
1128
+ released.push(presentLease(response.lease || { path: claim.path }));
642
1129
  }
643
- return released;
1130
+ } catch (error) {
1131
+ failures.push({
1132
+ leaseId: claim.leaseId,
1133
+ file: claim.path,
1134
+ code: normalizeString(error?.code) || "release_failed",
1135
+ });
644
1136
  }
645
- );
1137
+ }
646
1138
 
647
- const events = [];
648
- const actor = normalizeAgentId(actorAgentId || SENTI_AGENT_ID);
649
- for (const lockRecord of mutation.result || []) {
650
- const event = await appendLockEvent(
651
- sessionId,
652
- "file_unlock",
653
- actor,
654
- {
655
- file: normalizeString(lockRecord.file),
656
- heldBy: normalizedAgentId,
657
- intent: normalizeString(lockRecord.intent),
658
- reason: normalizedReason,
659
- },
660
- {
661
- targetPath,
662
- nowIso: normalizedNow,
663
- }
1139
+ let unresolved = [];
1140
+ let authority = {
1141
+ ok: true,
1142
+ authoritative: true,
1143
+ code: null,
1144
+ };
1145
+ try {
1146
+ const active = await listRemoteLeases(normalizedSessionId, {
1147
+ targetPath,
1148
+ resolveAuthSession,
1149
+ request,
1150
+ });
1151
+ unresolved = active.filter(
1152
+ (lease) => lease.agentId === normalizedAgentId,
664
1153
  );
665
- events.push(event);
1154
+ } catch (error) {
1155
+ authority = {
1156
+ ok: false,
1157
+ authoritative: false,
1158
+ code: normalizeString(error?.code) || "FILE_LEASE_LIST_FAILED",
1159
+ };
666
1160
  }
667
-
668
1161
  return {
669
- releasedCount: events.length,
670
- released: mutation.result || [],
671
- events,
672
- expiredEvents: mutation.expiredEvents,
1162
+ releasedCount: released.length,
1163
+ released,
1164
+ failures,
1165
+ unresolved,
1166
+ unresolvedKnown: authority.ok,
1167
+ authority,
1168
+ events: [],
1169
+ expiredEvents: [],
673
1170
  };
674
1171
  }
675
1172
 
676
1173
  export {
677
1174
  DEFAULT_FILE_LOCK_TTL_SECONDS,
1175
+ MAX_FILE_LOCK_TTL_SECONDS,
1176
+ MIN_FILE_LOCK_TTL_SECONDS,
1177
+ fileLeasePathCovers,
1178
+ fileLeasePathsOverlap,
1179
+ canonicalizeFilePath,
1180
+ normalizeFilePath,
678
1181
  };