blun-king-cli 9.1.562 → 9.1.564

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.
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
3
+ import { isFileLockContention, isTransientLockMetadataError } from "./filesystem-retry.js";
4
+
5
+ const LOCK_SCHEMA = "agentspine.owned-file-lock/v1";
6
+
7
+ function delay(milliseconds) {
8
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
9
+ }
10
+
11
+ function lockPayload(token, acquiredAt, leaseMs) {
12
+ return {
13
+ schema: LOCK_SCHEMA,
14
+ token,
15
+ acquiredAt,
16
+ leaseMs,
17
+ authority: "state-coordination-only"
18
+ };
19
+ }
20
+
21
+ async function readOwner(path) {
22
+ try {
23
+ const value = JSON.parse(await readFile(path, "utf8"));
24
+ if (!value || typeof value !== "object" || Array.isArray(value)
25
+ || value.schema !== LOCK_SCHEMA || typeof value.token !== "string") return null;
26
+ return value;
27
+ } catch (error) {
28
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return null;
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ function sameFile(left, right) {
34
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size
35
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
36
+ }
37
+
38
+ async function removeStaleLock(path, staleAfterMs) {
39
+ let before;
40
+ try {
41
+ before = await stat(path);
42
+ } catch (error) {
43
+ if (isTransientLockMetadataError(error)) return false;
44
+ throw error;
45
+ }
46
+ if (Date.now() - before.mtimeMs <= staleAfterMs) return false;
47
+ await readOwner(path);
48
+ let after;
49
+ try {
50
+ after = await stat(path);
51
+ } catch (error) {
52
+ if (isTransientLockMetadataError(error)) return false;
53
+ throw error;
54
+ }
55
+ if (!sameFile(before, after) || Date.now() - after.mtimeMs <= staleAfterMs) return false;
56
+ try {
57
+ await unlink(path);
58
+ return true;
59
+ } catch (error) {
60
+ if (isFileLockContention(error) || isTransientLockMetadataError(error)) return false;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ export async function withOwnedFileLock(path, task, {
66
+ staleAfterMs = 15000,
67
+ heartbeatIntervalMs = 1000,
68
+ retryDelayMs = 25,
69
+ maxAttempts = 80
70
+ } = {}) {
71
+ if (typeof task !== "function") throw new Error("owned file lock requires a task");
72
+ if (!Number.isInteger(staleAfterMs) || staleAfterMs < 50
73
+ || !Number.isInteger(heartbeatIntervalMs) || heartbeatIntervalMs < 10
74
+ || heartbeatIntervalMs * 3 >= staleAfterMs
75
+ || !Number.isInteger(retryDelayMs) || retryDelayMs < 1
76
+ || !Number.isInteger(maxAttempts) || maxAttempts < 1) {
77
+ throw new Error("owned file lock timing is invalid");
78
+ }
79
+ const token = randomUUID();
80
+ const acquiredAt = new Date().toISOString();
81
+ let acquired = false;
82
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
83
+ let handle;
84
+ try {
85
+ handle = await open(path, "wx", 0o600);
86
+ const payload = `${JSON.stringify(lockPayload(token, acquiredAt, staleAfterMs))}\n`;
87
+ await handle.writeFile(payload, "utf8");
88
+ acquired = true;
89
+ break;
90
+ } catch (error) {
91
+ if (!isFileLockContention(error)) {
92
+ if (handle) {
93
+ await handle.close();
94
+ handle = null;
95
+ await unlink(path).catch((cleanupError) => {
96
+ if (cleanupError.code !== "ENOENT") error.cleanupError = cleanupError;
97
+ });
98
+ }
99
+ throw error;
100
+ }
101
+ await removeStaleLock(path, staleAfterMs);
102
+ if (attempt + 1 < maxAttempts) await delay(retryDelayMs);
103
+ } finally {
104
+ await handle?.close();
105
+ }
106
+ }
107
+ if (!acquired) throw new Error("state is busy; retry shortly");
108
+
109
+ let ownershipError = null;
110
+ let heartbeat = Promise.resolve();
111
+ const assertOwned = async () => {
112
+ if (ownershipError) throw ownershipError;
113
+ const owner = await readOwner(path);
114
+ if (!owner || owner.token !== token) {
115
+ ownershipError = new Error("state lock ownership was lost; mutation aborted");
116
+ throw ownershipError;
117
+ }
118
+ };
119
+ const renew = async () => {
120
+ await assertOwned();
121
+ const now = new Date();
122
+ await utimes(path, now, now);
123
+ };
124
+ const timer = setInterval(() => {
125
+ heartbeat = heartbeat.then(renew).catch((error) => { ownershipError ||= error; });
126
+ }, heartbeatIntervalMs);
127
+ timer.unref?.();
128
+
129
+ try {
130
+ const result = await task({ token, acquiredAt, assertOwned });
131
+ await assertOwned();
132
+ return result;
133
+ } finally {
134
+ clearInterval(timer);
135
+ await heartbeat;
136
+ const owner = await readOwner(path).catch(() => null);
137
+ if (owner?.token === token) {
138
+ await unlink(path).catch((error) => {
139
+ if (error.code !== "ENOENT") throw error;
140
+ });
141
+ }
142
+ }
143
+ }
@@ -6,6 +6,7 @@ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.j
6
6
  import { loadCoordination } from "./coordination.js";
7
7
  import { loadGraph } from "./graph.js";
8
8
  import { projectStateDir } from "./paths.js";
9
+ import { isInaccessibleScanError } from "./source-roots.js";
9
10
 
10
11
  const POLICY_SCHEMA = "agentspine.execution-policy/v1";
11
12
  const JOB_SCHEMA = "agentspine.selfstarter/v1";
@@ -149,16 +150,39 @@ async function pathsFor(root, providedCatalog = null) {
149
150
 
150
151
  async function collectWorkspaceFiles(root) {
151
152
  const files = [];
153
+ const skippedInaccessibleEntries = [];
152
154
  let totalBytes = 0;
153
155
  async function walk(directory) {
154
- const stream = await opendir(directory);
156
+ let stream;
157
+ try {
158
+ stream = await opendir(directory);
159
+ } catch (error) {
160
+ if (!isInaccessibleScanError(error)) throw error;
161
+ skippedInaccessibleEntries.push({
162
+ relativePath: relative(root, directory).split(sep).join("/") || ".",
163
+ code: error.code,
164
+ kind: "directory"
165
+ });
166
+ return;
167
+ }
155
168
  const entries = [];
156
169
  for await (const entry of stream) entries.push(entry);
157
170
  entries.sort((a, b) => a.name.localeCompare(b.name));
158
171
  for (const entry of entries) {
159
172
  if (EXCLUDED_NAMES.has(entry.name)) continue;
160
173
  const path = join(directory, entry.name);
161
- const metadata = await lstat(path);
174
+ let metadata;
175
+ try {
176
+ metadata = await lstat(path);
177
+ } catch (error) {
178
+ if (!isInaccessibleScanError(error)) throw error;
179
+ skippedInaccessibleEntries.push({
180
+ relativePath: relative(root, path).split(sep).join("/"),
181
+ code: error.code,
182
+ kind: "entry"
183
+ });
184
+ continue;
185
+ }
162
186
  if (metadata.isSymbolicLink()) throw new Error(`workspace fingerprint rejects symbolic link: ${relative(root, path)}`);
163
187
  if (metadata.isDirectory()) await walk(path);
164
188
  else if (metadata.isFile()) {
@@ -171,18 +195,38 @@ async function collectWorkspaceFiles(root) {
171
195
  }
172
196
  }
173
197
  await walk(root);
174
- return files;
198
+ return { files, skippedInaccessibleEntries };
175
199
  }
176
200
 
177
201
  export async function workspaceFingerprint(inputRoot = process.cwd()) {
178
202
  const root = resolve(inputRoot);
179
- const files = await collectWorkspaceFiles(root);
203
+ const { files, skippedInaccessibleEntries } = await collectWorkspaceFiles(root);
180
204
  const hash = createHash("sha256");
205
+ for (const skipped of skippedInaccessibleEntries) {
206
+ hash.update("inaccessible\0").update(skipped.kind).update("\0")
207
+ .update(skipped.relativePath).update("\0").update(skipped.code).update("\0");
208
+ }
181
209
  for (const file of files) {
182
210
  hash.update(file.relativePath).update("\0").update(String(file.size)).update("\0");
183
211
  hash.update(await readFile(file.path)).update("\0");
184
212
  }
185
- return { digest: hash.digest("hex"), files: files.length, bytes: files.reduce((sum, file) => sum + file.size, 0) };
213
+ return {
214
+ digest: hash.digest("hex"), files: files.length,
215
+ bytes: files.reduce((sum, file) => sum + file.size, 0),
216
+ skippedInaccessibleEntries: skippedInaccessibleEntries.length,
217
+ skippedInaccessibleDetails: skippedInaccessibleEntries
218
+ };
219
+ }
220
+
221
+ function incompleteWorkspaceScan(skipped) {
222
+ const first = skipped[0];
223
+ const error = new Error(`workspace scan skipped ${skipped.length} inaccessible path${skipped.length === 1 ? "" : "s"}: ${first.relativePath}`);
224
+ error.code = "AGENTSPINE_SCAN_INCOMPLETE";
225
+ error.path = first.relativePath;
226
+ error.syscall = first.kind;
227
+ error.agentSpineScan = true;
228
+ error.skipped = skipped;
229
+ return error;
186
230
  }
187
231
 
188
232
  function knownActor(graph, id) {
@@ -649,6 +693,9 @@ export async function authorizeJobEffect({
649
693
  const deliveryId = stableId(toolUseId, "toolUseId");
650
694
  const at = timestamp(now, "now");
651
695
  const fingerprint = await workspaceFingerprint(root);
696
+ if (fingerprint.skippedInaccessibleDetails.length) {
697
+ throw incompleteWorkspaceScan(fingerprint.skippedInaccessibleDetails);
698
+ }
652
699
  return lockedStates(root, ({ policy, state, coordination, paths }) => {
653
700
  const job = state.jobs.find((item) => item.id === scope.jobId);
654
701
  if (!job) throw new Error("unknown self-starter job");
@@ -20,6 +20,10 @@ const SOURCE_RESOLUTION_MS = 2000;
20
20
  const SAFE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
21
21
  const SKIP_EXTRA_DIRS = new Set([".git", ".hg", ".svn", ".claude", ".codex", "node_modules", "vendor", "dist", "build", "coverage"]);
22
22
 
23
+ export function isInaccessibleScanError(error) {
24
+ return error?.code === "EACCES" || error?.code === "EPERM";
25
+ }
26
+
23
27
  function digest(value) { return createHash("sha256").update(value).digest("hex"); }
24
28
  function registryPath(env = process.env) { return join(stateRoot(env), "source-roots.json"); }
25
29
  function emptyRegistry() { return { schema: SOURCE_REGISTRY_SCHEMA, revision: 0, bindings: [], history: [] }; }
@@ -202,32 +206,41 @@ async function containsEmbeddedHostProfile(directory) {
202
206
  async function boundedMarkdownTree(directory, prefix, host, scope, precedenceStart, deadline, {
203
207
  projectBoundary = false,
204
208
  maxFiles = MAX_RULE_FILES,
205
- maxDirectoryEntries = MAX_DIRECTORY_ENTRIES
209
+ maxDirectoryEntries = MAX_DIRECTORY_ENTRIES,
210
+ skippedInaccessibleDirectories = []
206
211
  } = {}) {
207
212
  const root = await existingDirectory(directory);
208
213
  if (!root) return [];
209
214
  const output = [];
210
215
  let visitedEntries = 0;
211
216
  async function walk(current) {
212
- if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
213
- if (projectBoundary && current !== root
214
- && (await containsProjectMarker(current) || await containsEmbeddedHostProfile(current))) return;
215
- const entries = [];
216
- for await (const entry of await opendir(current)) {
217
- visitedEntries += 1;
218
- if (visitedEntries > maxDirectoryEntries) throw new Error(`host-native source tree exceeds ${maxDirectoryEntries} entries`);
219
- entries.push(entry);
220
- }
221
- entries.sort((a, b) => a.name.localeCompare(b.name));
222
- for (const entry of entries) {
223
- if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
224
- if (entry.isSymbolicLink()) continue;
225
- const path = join(current, entry.name);
226
- if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
227
- else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
228
- output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
229
- binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
217
+ try {
218
+ if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
219
+ if (projectBoundary && current !== root
220
+ && (await containsProjectMarker(current) || await containsEmbeddedHostProfile(current))) return;
221
+ const entries = [];
222
+ for await (const entry of await opendir(current)) {
223
+ visitedEntries += 1;
224
+ if (visitedEntries > maxDirectoryEntries) throw new Error(`host-native source tree exceeds ${maxDirectoryEntries} entries`);
225
+ entries.push(entry);
230
226
  }
227
+ entries.sort((a, b) => a.name.localeCompare(b.name));
228
+ for (const entry of entries) {
229
+ if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
230
+ if (entry.isSymbolicLink()) continue;
231
+ const path = join(current, entry.name);
232
+ if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
233
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
234
+ output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
235
+ binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
236
+ }
237
+ }
238
+ } catch (error) {
239
+ if (!isInaccessibleScanError(error)) throw error;
240
+ skippedInaccessibleDirectories.push({
241
+ relativePath: relative(root, current).replaceAll("\\", "/") || ".",
242
+ code: error.code
243
+ });
231
244
  }
232
245
  }
233
246
  await walk(root);
@@ -434,9 +447,11 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
434
447
  const knownHomeRoots = await homeRoots(env);
435
448
  const skippedHomeTree = knownHomeRoots.some((root) => samePath(root, projectRoot));
436
449
  const skippedFallbackHomeTree = skippedHomeTree && rootResolution === "cwd-fallback";
450
+ const skippedInaccessibleDirectories = [];
437
451
  if (!skippedHomeTree) {
438
452
  sources.push(...await boundedMarkdownTree(projectRoot, "agentspine:project", host, "project", 3000, deadline,
439
- { projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES }));
453
+ { projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES,
454
+ skippedInaccessibleDirectories }));
440
455
  }
441
456
  const nativeNames = new Set(host === "codex"
442
457
  ? ["AGENTS.override.md", "AGENTS.md", ...(hostDetails.fallbackNames || [])]
@@ -459,6 +474,7 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
459
474
  personalContinuityLoaded: documents.some((item) => item.sourceScope === "user") || Boolean(activeUserState),
460
475
  broadHomeScan: false, projectTreeScan: skippedFallbackHomeTree ? "skipped-unmarked-home"
461
476
  : skippedHomeTree ? "skipped-home-root" : "bounded",
477
+ skippedInaccessibleDirectories,
462
478
  rootResolution, registryRevision: registry.revision,
463
479
  ...(host === "claude" ? {
464
480
  memoryBound: Boolean(hostDetails.memoryRoot),
@@ -1 +1 @@
1
- export const VERSION = "0.11.4";
1
+ export const VERSION = "0.49.0";
package/blun.mjs CHANGED
@@ -342898,6 +342898,7 @@ function detectedUiLocale() {
342898
342898
  const SOURCE_LOCALE = "en";
342899
342899
  const PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9_]*)\}/g;
342900
342900
  const catalogs = {};
342901
+ const missingLocalizedUiTextWarnings = /* @__PURE__ */ new Set();
342901
342902
  let currentUiLocale = SOURCE_LOCALE;
342902
342903
  function registerUiCatalogFragment(fragment) {
342903
342904
  for (const [locale, copy] of Object.entries(fragment)) {
@@ -342928,7 +342929,13 @@ function uiTextFor(locale, key, params = {}) {
342928
342929
  const sourceTemplate = catalogs[SOURCE_LOCALE]?.[key];
342929
342930
  if (sourceTemplate === void 0) throw new Error(`Missing English UI source text for key "${key}".`);
342930
342931
  const localizedTemplate = catalogs[locale]?.[key];
342931
- if (locale !== SOURCE_LOCALE && isUiLocaleAvailable(locale) && localizedTemplate === void 0) throw new Error(`Missing UI text for key "${key}" in released locale "${locale}".`);
342932
+ if (locale !== SOURCE_LOCALE && isUiLocaleAvailable(locale) && localizedTemplate === void 0) {
342933
+ const warningKey = `${locale}:${key}`;
342934
+ if (!missingLocalizedUiTextWarnings.has(warningKey)) {
342935
+ missingLocalizedUiTextWarnings.add(warningKey);
342936
+ log?.warn("Missing localized UI text; using English fallback", { locale, key });
342937
+ }
342938
+ }
342932
342939
  const template = localizedTemplate ?? sourceTemplate;
342933
342940
  validateCatalogPlaceholders(key, sourceTemplate, template);
342934
342941
  return interpolate(key, template, params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.562",
3
+ "version": "9.1.564",
4
4
  "description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
5
5
  "license": "MIT",
6
6
  "bin": {