pi-session-mcp 0.8.0 → 0.8.1

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.
@@ -6,7 +6,7 @@ import { PiExtensionError } from "./extension-spec.js";
6
6
  import { readPackageMetadata } from "./package-metadata.js";
7
7
  import { MAX_SKILL_FILE_BYTES } from "./skill-catalog.js";
8
8
  import { readSkillBases, SkillBasesError } from "./skill-customize.js";
9
- import { readSkillTree, shippedSkillsDirectory } from "./skill-library.js";
9
+ import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory } from "./skill-library.js";
10
10
  const defaultFileSystem = { readFile, stat, access };
11
11
  const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "extension_missing", "extension_version_mismatch", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated"];
12
12
  function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
@@ -67,9 +67,10 @@ async function productionBuildInputMtimes(root) {
67
67
  }
68
68
  /**
69
69
  * Compares the shipped skills with the fingerprints recorded when the operator copied them.
70
- * Reads only the bases file and the shipped files; nothing is executed or changed.
70
+ * Reads only the bases file and the shipped files, takes no lock and writes nothing: the bases
71
+ * file is replaced atomically, so a read sees either the old or the new content.
71
72
  */
72
- async function skillOverrideChecks(overrideDirectory, shippedDirectory) {
73
+ async function skillOverrideChecks(overrideDirectory, shippedDirectory, released) {
73
74
  if (overrideDirectory === undefined)
74
75
  return [];
75
76
  let bases;
@@ -90,9 +91,15 @@ async function skillOverrideChecks(overrideDirectory, shippedDirectory) {
90
91
  catch {
91
92
  continue;
92
93
  }
93
- checks.push(fingerprint === base
94
- ? ok("skill_override_outdated", `skill:${name}`)
95
- : warning("skill_override_outdated", `skill:${name}`, `review the changed shipped skill, then run pi-session-mcp-setup --apply --customize-skill ${name}`));
94
+ const command = `pi-session-mcp-setup --apply --customize-skill ${name}`;
95
+ // A legacy base is meaningful only as the version 2 value of the released original it names.
96
+ const recorded = LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(base) ? releasedSkillFingerprint(name, base, released) : base;
97
+ if (recorded === undefined)
98
+ checks.push(warning("skill_override_outdated", `skill:${name}`, `the recorded base predates version 2 fingerprints and cannot be confirmed; review the shipped skill, then run ${command}`));
99
+ else
100
+ checks.push(recorded === fingerprint
101
+ ? ok("skill_override_outdated", `skill:${name}`)
102
+ : warning("skill_override_outdated", `skill:${name}`, `review the changed shipped skill, then run ${command}`));
96
103
  }
97
104
  return checks;
98
105
  }
@@ -260,7 +267,7 @@ export async function runDoctor(dependencies = {}) {
260
267
  }
261
268
  }
262
269
  if (config)
263
- checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot)));
270
+ checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot), dependencies.releasedSkillTrees));
264
271
  const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
265
272
  checks.sort((a, b) => (rank.get(a.id) ?? 999) - (rank.get(b.id) ?? 999) || a.subject.localeCompare(b.subject));
266
273
  const hasError = checks.some((check) => check.severity === "error");
@@ -0,0 +1,125 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { assertSupportedFilesystem, currentSetupPlatform, isTrustedSystemBinary, openLockedDarwin } from "./setup-platform.js";
6
+ /**
7
+ * The kernel lock that serializes every read-modify-write of the skill bases file across
8
+ * processes. It lives on a stable lock file, never on the bases file: that file is replaced by a
9
+ * rename, which would move the lock onto an unlinked inode. The lock file is never deleted, and a
10
+ * holder that dies releases the lock through the kernel.
11
+ */
12
+ export const SKILL_BASES_LOCK_FILE = ".pi-session-mcp-bases.lock";
13
+ export const DEFAULT_OVERRIDE_LOCK_TIMEOUT_MS = 30_000;
14
+ const MIN_LOCK_TIMEOUT_MS = 50;
15
+ const MAX_LOCK_TIMEOUT_MS = 120_000;
16
+ /** Exit status of `flock -E` when the lock stayed busy for the whole wait. */
17
+ const FLOCK_BUSY_EXIT = 75;
18
+ const FLOCK_CANDIDATES = ["/usr/bin/flock", "/bin/flock"];
19
+ export class OverrideLockError extends Error {
20
+ code;
21
+ constructor(code) {
22
+ super("Skill override lock is not available");
23
+ this.code = code;
24
+ this.name = "OverrideLockError";
25
+ }
26
+ }
27
+ /**
28
+ * Takes the exclusive lock for one override directory, which must exist. Fails closed: an
29
+ * unsupported platform, a missing trusted `flock`, an unsupported filesystem, a link or non-file
30
+ * at the lock path and a descriptor that no longer names the path all end in
31
+ * `override_lock_unavailable`.
32
+ */
33
+ export async function acquireOverrideLock(overrideDirectory, options = {}) {
34
+ const platform = currentSetupPlatform(options.platform);
35
+ if (!platform)
36
+ throw new OverrideLockError("override_lock_unavailable");
37
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(MIN_LOCK_TIMEOUT_MS, Math.min(options.timeoutMs, MAX_LOCK_TIMEOUT_MS)) : DEFAULT_OVERRIDE_LOCK_TIMEOUT_MS;
38
+ const flockPath = platform === "linux" ? flockBinary(options) : undefined;
39
+ if (platform === "linux" && flockPath === undefined)
40
+ throw new OverrideLockError("override_lock_unavailable");
41
+ try {
42
+ await assertSupportedFilesystem(overrideDirectory, platform, options.readStatfs);
43
+ }
44
+ catch {
45
+ throw new OverrideLockError("override_lock_unavailable");
46
+ }
47
+ const lockPath = join(overrideDirectory, SKILL_BASES_LOCK_FILE);
48
+ let handle;
49
+ try {
50
+ // macOS takes the kernel lock at open time; on Linux the trusted flock locks this very
51
+ // open file description, so the lock outlives the flock process and ends with this descriptor.
52
+ handle = await openLockFile(lockPath, platform, timeoutMs, options.signal);
53
+ if (flockPath !== undefined)
54
+ await lockWithFlock(flockPath, handle.fd, timeoutMs, options.signal);
55
+ // The path may have been replaced while waiting: the lock only counts on the file it names.
56
+ const [descriptor, linked] = await Promise.all([handle.stat(), lstat(lockPath)]);
57
+ if (!descriptor.isFile() || !linked.isFile() || descriptor.dev !== linked.dev || descriptor.ino !== linked.ino)
58
+ throw new OverrideLockError("override_lock_unavailable");
59
+ }
60
+ catch (error) {
61
+ await handle?.close().catch(() => undefined);
62
+ throw error instanceof OverrideLockError ? error : new OverrideLockError("override_lock_unavailable");
63
+ }
64
+ const held = handle;
65
+ let released = false;
66
+ return {
67
+ async release() {
68
+ if (released)
69
+ return;
70
+ released = true;
71
+ await held.close().catch(() => undefined);
72
+ },
73
+ };
74
+ }
75
+ function flockBinary(options) {
76
+ const trusted = options.isTrustedBinary ?? ((path) => isTrustedSystemBinary(path));
77
+ return (options.flockCandidates ?? FLOCK_CANDIDATES).find((candidate) => trusted(candidate));
78
+ }
79
+ async function openLockFile(lockPath, platform, timeoutMs, signal) {
80
+ try {
81
+ if (platform === "darwin")
82
+ return await openLockedDarwin(lockPath, { timeoutMs, ...(signal ? { signal } : {}) });
83
+ return await open(lockPath, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
84
+ }
85
+ catch (error) {
86
+ throw new OverrideLockError(error.message === "ownership_lock_busy" ? "override_lock_busy" : "override_lock_unavailable");
87
+ }
88
+ }
89
+ function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
90
+ return new Promise((resolve, reject) => {
91
+ if (signal?.aborted) {
92
+ reject(new OverrideLockError("override_lock_unavailable"));
93
+ return;
94
+ }
95
+ let locker;
96
+ try {
97
+ // flock parses fractional seconds with the locale's decimal separator, so only whole
98
+ // seconds go on the command line. The exact bound is the timer below, which stops flock.
99
+ const wholeSeconds = String(Math.max(1, Math.ceil(timeoutMs / 1_000)));
100
+ locker = spawn(flockPath, ["-E", String(FLOCK_BUSY_EXIT), "-w", wholeSeconds, "3"], { stdio: ["ignore", "ignore", "ignore", lockFd], detached: true });
101
+ }
102
+ catch {
103
+ reject(new OverrideLockError("override_lock_unavailable"));
104
+ return;
105
+ }
106
+ let expired = false;
107
+ const stop = () => { if (locker.pid !== undefined) {
108
+ try {
109
+ process.kill(-locker.pid, "SIGKILL");
110
+ }
111
+ catch { /* already gone */ }
112
+ } };
113
+ const cleanup = () => { clearTimeout(bound); signal?.removeEventListener("abort", stop); };
114
+ const bound = setTimeout(() => { expired = true; stop(); }, timeoutMs);
115
+ signal?.addEventListener("abort", stop, { once: true });
116
+ locker.once("error", () => { cleanup(); reject(new OverrideLockError("override_lock_unavailable")); });
117
+ locker.once("close", (code) => {
118
+ cleanup();
119
+ if (code === 0)
120
+ resolve();
121
+ else
122
+ reject(new OverrideLockError(code === FLOCK_BUSY_EXIT || expired ? "override_lock_busy" : "override_lock_unavailable"));
123
+ });
124
+ });
125
+ }
@@ -0,0 +1,195 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { open, statfs } from "node:fs/promises";
4
+ export function currentSetupPlatform(platform = process.platform) {
5
+ return platform === "linux" || platform === "darwin" ? platform : undefined;
6
+ }
7
+ /** A regular, root-owned executable that no group or other user can replace. */
8
+ export function isTrustedSystemBinary(path, statFile = statSync) {
9
+ try {
10
+ const file = statFile(path);
11
+ return file.isFile() && file.uid === 0 && (file.mode & 0o022) === 0 && (file.mode & 0o111) !== 0;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ function readLinuxProcessGroup(pid) {
18
+ const line = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
19
+ const close = line.lastIndexOf(") ");
20
+ const state = line[close + 2];
21
+ if (close < 3 || !state || !/^[A-Za-z]$/.test(state) || line[close + 3] !== " ")
22
+ throw new Error("process_stat_invalid");
23
+ const fields = line.slice(close + 4).trim().split(/\s+/);
24
+ const group = Number(fields[1]);
25
+ if (!Number.isSafeInteger(group) || group < 0)
26
+ throw new Error("process_group_invalid");
27
+ return { group, state };
28
+ }
29
+ function linuxGroupState(group) {
30
+ let entries;
31
+ try {
32
+ entries = readdirSync("/proc", { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return "unknown";
36
+ }
37
+ for (const entry of entries) {
38
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
39
+ continue;
40
+ try {
41
+ const sample = readLinuxProcessGroup(Number(entry.name));
42
+ if (sample.group === group && sample.state !== "Z")
43
+ return "alive";
44
+ }
45
+ catch (error) {
46
+ if (error.code !== "ENOENT")
47
+ return "unknown";
48
+ }
49
+ }
50
+ return "gone";
51
+ }
52
+ function linuxTableReadable(selfPid) {
53
+ let sawSelf = false;
54
+ try {
55
+ for (const entry of readdirSync("/proc", { withFileTypes: true })) {
56
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
57
+ continue;
58
+ try {
59
+ const pid = Number(entry.name);
60
+ readLinuxProcessGroup(pid);
61
+ if (pid === selfPid)
62
+ sawSelf = true;
63
+ }
64
+ catch (error) {
65
+ if (error.code !== "ENOENT")
66
+ return false;
67
+ }
68
+ }
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ return sawSelf;
74
+ }
75
+ export const DARWIN_PS_PATH = "/bin/ps";
76
+ const PS_ARGUMENTS = ["-A", "-o", "pid=,pgid=,stat="];
77
+ const PS_TIMEOUT_MS = 2_000;
78
+ const PS_MAX_OUTPUT_BYTES = 1_048_576;
79
+ /** Parses `ps -A -o pid=,pgid=,stat=`; any unexpected line rejects the whole sample. */
80
+ export function parsePsTable(output) {
81
+ const body = output.endsWith("\n") ? output.slice(0, -1) : output;
82
+ if (body.length === 0)
83
+ return undefined;
84
+ const entries = [];
85
+ for (const line of body.split("\n")) {
86
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
87
+ if (!match)
88
+ return undefined;
89
+ const pid = Number(match[1]);
90
+ const group = Number(match[2]);
91
+ if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(group))
92
+ return undefined;
93
+ entries.push({ pid, group, state: match[3] });
94
+ }
95
+ return entries;
96
+ }
97
+ function runDarwinPs() {
98
+ if (!isTrustedSystemBinary(DARWIN_PS_PATH))
99
+ return Promise.resolve(undefined);
100
+ return new Promise((resolve) => {
101
+ try {
102
+ execFile(DARWIN_PS_PATH, [...PS_ARGUMENTS], { env: { LC_ALL: "C" }, timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_OUTPUT_BYTES, encoding: "utf8" }, (error, stdout) => {
103
+ // A non-zero exit, timeout, output overflow or spawn failure is indeterminate.
104
+ resolve(error ? undefined : { exitCode: 0, stdout });
105
+ });
106
+ }
107
+ catch {
108
+ resolve(undefined);
109
+ }
110
+ });
111
+ }
112
+ export function createProcessTable(platform, dependencies = {}) {
113
+ const selfPid = dependencies.selfPid ?? process.pid;
114
+ if (platform === "linux") {
115
+ return { pollMs: 10, groupState: async (group) => linuxGroupState(group), readable: async () => linuxTableReadable(selfPid) };
116
+ }
117
+ const runPs = dependencies.runPs ?? runDarwinPs;
118
+ const scan = async () => {
119
+ const output = await runPs();
120
+ if (!output || output.exitCode !== 0)
121
+ return undefined;
122
+ const entries = parsePsTable(output.stdout);
123
+ // A sample without the caller cannot be a complete process table.
124
+ return entries?.some((entry) => entry.pid === selfPid) ? entries : undefined;
125
+ };
126
+ return {
127
+ pollMs: 50,
128
+ async groupState(group) {
129
+ const entries = await scan();
130
+ if (!entries)
131
+ return "unknown";
132
+ return entries.some((entry) => entry.group === group && !entry.state.startsWith("Z")) ? "alive" : "gone";
133
+ },
134
+ async readable() { return (await scan()) !== undefined; },
135
+ };
136
+ }
137
+ // Kernel flock and directory fsync semantics are accepted only on the local
138
+ // Linux filesystems covered by the setup test matrix.
139
+ const SUPPORTED_LINUX_FILESYSTEMS = new Set([
140
+ 0xef53, // ext2/3/4
141
+ 0x58465342, // XFS
142
+ 0x9123683e, // Btrfs
143
+ 0x01021994, // tmpfs
144
+ 0x794c7630, // overlayfs
145
+ 0x2fc12fc1, // ZFS
146
+ 0xf2f52010, // F2FS
147
+ 0x24051905, // UBIFS
148
+ 0xca451a4e, // bcachefs
149
+ ]);
150
+ export async function assertSupportedFilesystem(path, platform, readStatfs = (target) => statfs(target)) {
151
+ const filesystem = await readStatfs(path);
152
+ if (platform === "linux") {
153
+ if (!SUPPORTED_LINUX_FILESYSTEMS.has(filesystem.type))
154
+ throw new Error("ownership_filesystem_unsupported");
155
+ return;
156
+ }
157
+ // macOS assigns f_type dynamically, so the directory must share the type of
158
+ // `/`, the sealed APFS system volume on every macOS that Node.js 22 supports.
159
+ if (filesystem.type !== (await readStatfs("/")).type)
160
+ throw new Error("ownership_filesystem_unsupported");
161
+ }
162
+ /** From XNU `bsd/sys/fcntl.h`; Node's `fs.constants` does not export these. */
163
+ export const DARWIN_O_NONBLOCK = 0x4;
164
+ export const DARWIN_O_EXLOCK = 0x20;
165
+ /**
166
+ * Opens the lock file with an exclusive lock on its open file description,
167
+ * which has flock(2) semantics on macOS. A busy lock is retried until the
168
+ * acquisition bound; a failed attempt holds no descriptor.
169
+ */
170
+ export async function openLockedDarwin(path, options) {
171
+ const openFile = options.openFile ?? ((target, flags, mode) => open(target, flags, mode));
172
+ const flags = constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | DARWIN_O_EXLOCK | DARWIN_O_NONBLOCK;
173
+ const boundMs = Number.isFinite(options.timeoutMs) ? Math.max(1_000, Math.min(options.timeoutMs, 120_000)) : 120_000;
174
+ const deadline = Date.now() + boundMs;
175
+ const retryMs = options.retryMs ?? 50;
176
+ while (true) {
177
+ if (options.signal?.aborted)
178
+ throw new Error("operation_aborted");
179
+ try {
180
+ return await openFile(path, flags, 0o600);
181
+ }
182
+ catch (error) {
183
+ const code = error.code;
184
+ if (code !== "EAGAIN" && code !== "EWOULDBLOCK")
185
+ throw new Error("ownership_lock_unavailable");
186
+ }
187
+ if (Date.now() >= deadline)
188
+ throw new Error("ownership_lock_busy");
189
+ await new Promise((resolve) => {
190
+ const done = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", done); resolve(); };
191
+ const timer = setTimeout(done, retryMs);
192
+ options.signal?.addEventListener("abort", done, { once: true });
193
+ });
194
+ }
195
+ }
@@ -1,18 +1,24 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, mkdir, open, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
+ import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
5
6
  import { result } from "./setup-result.js";
6
- import { readSkillTree, shippedSkillsDirectory, SKILL_NAME_PATTERN, SkillTreeError, skillOverrideDirectory } from "./skill-library.js";
7
+ import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN, SkillTreeError, skillOverrideDirectory, } from "./skill-library.js";
7
8
  /**
8
9
  * `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
9
10
  * starting point, and records the shipped fingerprint it was based on. The operator's files are
10
11
  * never overwritten or edited; a changed original is only reported and acknowledged by moving
11
12
  * the recorded base. Reports carry the skill name and a code, never a path or file content.
13
+ *
14
+ * Applying is a cross-process transaction: everything from reading the bases file to replacing it
15
+ * runs under the kernel lock of `override-lock.ts`, so two runs for different skills cannot lose
16
+ * each other's base. A dry run takes no lock and writes nothing.
12
17
  */
13
18
  export const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
14
19
  export const MAX_SKILL_BASES_BYTES = 64 * 1024;
15
- const FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
20
+ /** Written by this version; version 1 (0.8.0) holds only legacy fingerprints and is still read. */
21
+ const BASES_SCHEMA_VERSION = 2;
16
22
  export class SkillBasesError extends Error {
17
23
  constructor() {
18
24
  super("Skill bases file is not usable");
@@ -46,13 +52,17 @@ export async function readSkillBases(overrideDirectory) {
46
52
  catch {
47
53
  throw new SkillBasesError();
48
54
  }
49
- if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion" || parsed.schemaVersion !== 1)
55
+ if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion")
56
+ throw new SkillBasesError();
57
+ const version = parsed.schemaVersion;
58
+ if (version !== 1 && version !== BASES_SCHEMA_VERSION)
50
59
  throw new SkillBasesError();
51
60
  const bases = parsed.bases;
52
61
  if (!isPlainObject(bases))
53
62
  throw new SkillBasesError();
54
63
  const entries = Object.entries(bases);
55
- if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && typeof value === "string" && FINGERPRINT_PATTERN.test(value)))
64
+ const validValue = (value) => typeof value === "string" && (LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) || (version === BASES_SCHEMA_VERSION && SKILL_TREE_FINGERPRINT_PATTERN.test(value)));
65
+ if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && validValue(value)))
56
66
  throw new SkillBasesError();
57
67
  return Object.freeze(Object.fromEntries(entries));
58
68
  }
@@ -62,10 +72,25 @@ function isPlainObject(value) {
62
72
  const prototype = Object.getPrototypeOf(value);
63
73
  return prototype === Object.prototype || prototype === null;
64
74
  }
65
- /** Atomic replacement: a private temporary file in the same directory, then a rename. */
66
- async function writeSkillBases(overrideDirectory, bases) {
67
- const sorted = Object.fromEntries(Object.entries(bases).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
68
- const content = Buffer.from(`${JSON.stringify({ schemaVersion: 1, bases: sorted })}\n`, "utf8");
75
+ async function syncDirectory(path) {
76
+ const handle = await open(path, "r");
77
+ try {
78
+ await handle.sync();
79
+ }
80
+ finally {
81
+ await handle.close();
82
+ }
83
+ }
84
+ /**
85
+ * Atomic replacement: a private temporary file in the same directory, fsynced, then a rename and a
86
+ * directory fsync. Every provable legacy entry is migrated to its version 2 value on the way; an
87
+ * entry that no released tree names stays verbatim. A failure before the rename leaves the
88
+ * previous file untouched.
89
+ */
90
+ async function writeSkillBases(overrideDirectory, bases, released, beforeRename) {
91
+ const migrated = Object.entries(bases).map(([name, value]) => [name, LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) ? releasedSkillFingerprint(name, value, released) ?? value : value]);
92
+ const sorted = Object.fromEntries(migrated.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
93
+ const content = Buffer.from(`${JSON.stringify({ schemaVersion: BASES_SCHEMA_VERSION, bases: sorted })}\n`, "utf8");
69
94
  if (content.length > MAX_SKILL_BASES_BYTES)
70
95
  throw new SkillBasesError();
71
96
  const temporary = join(overrideDirectory, `.${SKILL_BASES_FILE.slice(1)}.${randomBytes(6).toString("hex")}.tmp`);
@@ -78,12 +103,14 @@ async function writeSkillBases(overrideDirectory, bases) {
78
103
  finally {
79
104
  await handle.close();
80
105
  }
106
+ await beforeRename?.();
81
107
  await rename(temporary, join(overrideDirectory, SKILL_BASES_FILE));
82
108
  }
83
109
  catch (error) {
84
110
  await unlink(temporary).catch(() => undefined);
85
111
  throw error;
86
112
  }
113
+ await syncDirectory(overrideDirectory);
87
114
  }
88
115
  function isMissing(error) {
89
116
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -96,7 +123,40 @@ function report(request, status, code) {
96
123
  // A planned change reports `planned` overall, like a registration dry-run.
97
124
  return result(request.apply ? "apply" : "dry-run", [finding], status === "planned" ? "planned" : undefined);
98
125
  }
99
- export async function customizeSkill(request) {
126
+ const OUTDATED = Object.freeze({ kind: "record", planned: "skill_override_outdated", applied: "skill_base_updated" });
127
+ /** Pure with respect to the library: reads the override tree, never changes anything. */
128
+ async function decide(name, overridePath, shipped, bases, released) {
129
+ try {
130
+ if (!(await lstat(overridePath)).isDirectory())
131
+ return { kind: "failed", code: "skill_override_invalid" };
132
+ }
133
+ catch (error) {
134
+ if (!isMissing(error))
135
+ throw error;
136
+ return { kind: "copy", planned: "skill_customize_planned", applied: "skill_customized" };
137
+ }
138
+ let override;
139
+ try {
140
+ override = await readSkillTree(overridePath);
141
+ }
142
+ catch (error) {
143
+ if (error instanceof SkillTreeError)
144
+ return { kind: "failed", code: "skill_override_invalid" };
145
+ throw error;
146
+ }
147
+ const base = Object.hasOwn(bases, name) ? bases[name] : undefined;
148
+ // Without a base an identical override is a customization whose bases commit failed earlier.
149
+ if (base === undefined)
150
+ return override.fingerprint === shipped.fingerprint ? { kind: "record", planned: "skill_customize_planned", applied: "skill_customized" } : OUTDATED;
151
+ if (SKILL_TREE_FINGERPRINT_PATTERN.test(base))
152
+ return base === shipped.fingerprint ? { kind: "current" } : OUTDATED;
153
+ // A legacy base only names a released original when a trusted reference tree has that value.
154
+ const original = releasedSkillFingerprint(name, base, released);
155
+ if (original === undefined)
156
+ return { kind: "record", planned: "skill_base_unverified", applied: "skill_base_updated" };
157
+ return original === shipped.fingerprint ? { kind: "record", planned: "skill_base_migration_planned", applied: "skill_base_migrated" } : OUTDATED;
158
+ }
159
+ export async function customizeSkill(request, dependencies = {}) {
100
160
  const done = (status, code) => report(request, status, code);
101
161
  if (!SKILL_NAME_PATTERN.test(request.name))
102
162
  return done("failed", "skill_not_shipped");
@@ -112,69 +172,94 @@ export async function customizeSkill(request) {
112
172
  return done("failed", "skill_customize_failed");
113
173
  }
114
174
  const overrideDirectory = skillOverrideDirectory(request.configPath);
115
- let bases;
116
- try {
117
- bases = await readSkillBases(overrideDirectory);
118
- }
119
- catch (error) {
120
- if (error instanceof SkillBasesError)
175
+ const overridePath = join(overrideDirectory, request.name);
176
+ const read = async () => {
177
+ try {
178
+ return await readSkillBases(overrideDirectory);
179
+ }
180
+ catch (error) {
181
+ if (error instanceof SkillBasesError)
182
+ return undefined;
183
+ throw error;
184
+ }
185
+ };
186
+ if (!request.apply) {
187
+ // No lock and no write: the report is a snapshot of what an apply would decide.
188
+ const bases = await read();
189
+ if (bases === undefined)
121
190
  return done("failed", "skill_bases_corrupt");
122
- throw error;
191
+ const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
192
+ if (decision.kind === "failed")
193
+ return done("failed", decision.code);
194
+ return decision.kind === "current" ? done("unchanged", "skill_override_current") : done("planned", decision.planned);
123
195
  }
124
- const overridePath = join(overrideDirectory, request.name);
125
- let exists = true;
196
+ await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
197
+ let lock;
126
198
  try {
127
- const info = await lstat(overridePath);
128
- if (!info.isDirectory())
129
- return done("failed", "skill_override_invalid");
199
+ lock = await acquireOverrideLock(overrideDirectory, dependencies.lock);
130
200
  }
131
201
  catch (error) {
132
- if (!isMissing(error))
133
- throw error;
134
- exists = false;
135
- }
136
- if (!exists) {
137
- if (!request.apply)
138
- return done("planned", "skill_customize_planned");
139
- await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
140
- await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
141
- return done("ok", "skill_customized");
202
+ if (error instanceof OverrideLockError)
203
+ return done("failed", error.code === "override_lock_busy" ? "skill_customize_lock_busy" : "skill_customize_lock_unavailable");
204
+ throw error;
142
205
  }
143
206
  try {
144
- await readSkillTree(overridePath);
207
+ await dependencies.afterLock?.();
208
+ // Read only now: a snapshot from before the lock may miss a base another run just wrote.
209
+ const bases = await read();
210
+ if (bases === undefined)
211
+ return done("failed", "skill_bases_corrupt");
212
+ const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
213
+ if (decision.kind === "failed")
214
+ return done("failed", decision.code);
215
+ if (decision.kind === "current")
216
+ return done("unchanged", "skill_override_current");
217
+ if (decision.kind === "copy")
218
+ await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
219
+ await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint }, dependencies.released, dependencies.beforeBasesRename);
220
+ return done("ok", decision.applied);
145
221
  }
146
- catch (error) {
147
- if (error instanceof SkillTreeError)
148
- return done("failed", "skill_override_invalid");
149
- throw error;
222
+ finally {
223
+ await lock.release();
150
224
  }
151
- if (bases[request.name] === shipped.fingerprint)
152
- return done("unchanged", "skill_override_current");
153
- if (!request.apply)
154
- return done("planned", "skill_override_outdated");
155
- // Only the recorded base moves: it acknowledges that the operator reviewed the changed original.
156
- await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
157
- return done("ok", "skill_base_updated");
158
225
  }
159
226
  catch {
160
227
  return done("failed", "skill_customize_failed");
161
228
  }
162
229
  }
163
230
  async function copyShippedSkill(files, overrideDirectory, overridePath, name) {
164
- await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
165
231
  const temporary = join(overrideDirectory, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
166
232
  try {
167
233
  await mkdir(temporary, { mode: 0o755 });
168
234
  for (const file of files) {
169
235
  const destination = join(temporary, ...file.path.split("/"));
170
236
  await mkdir(dirname(destination), { recursive: true });
171
- await writeFile(destination, file.bytes, { flag: "wx", mode: 0o644 });
237
+ const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
238
+ try {
239
+ await handle.writeFile(file.bytes);
240
+ await handle.sync();
241
+ }
242
+ finally {
243
+ await handle.close();
244
+ }
245
+ }
246
+ // Every directory entry of the temporary tree is durable before the rename and the bases commit.
247
+ const directories = new Set([temporary]);
248
+ for (const file of files) {
249
+ let cursor = dirname(join(temporary, ...file.path.split("/")));
250
+ while (cursor !== temporary) {
251
+ directories.add(cursor);
252
+ cursor = dirname(cursor);
253
+ }
172
254
  }
255
+ for (const directory of [...directories].sort((left, right) => right.length - left.length))
256
+ await syncDirectory(directory);
173
257
  // The override must not appear while the copy runs, and an operator's directory is never replaced.
174
258
  if (await lstat(overridePath).then(() => true, (error) => { if (isMissing(error))
175
259
  return false; throw error; }))
176
260
  throw new Error("override appeared");
177
261
  await rename(temporary, overridePath);
262
+ await syncDirectory(overrideDirectory);
178
263
  }
179
264
  catch (error) {
180
265
  await rm(temporary, { recursive: true, force: true }).catch(() => undefined);