@hasna/skills 0.10.30 → 0.10.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -623,6 +623,18 @@ on this same host. The exact lock bytes and a metadata record remain under
623
623
  by recovery. A live, recent, malformed or different-host lock still blocks the
624
624
  write. A recovery guard serializes competing recoverers.
625
625
 
626
+ Writers gather identity before publishing the lock. A private, fully written and
627
+ synced staging inode is linked into the live path atomically without replacing
628
+ an existing lock; the staging name is then removed before the protected write.
629
+ The writer keeps its descriptor open through lock release. An interruption at
630
+ the brief two-link publication boundary leaves a complete marker and its exact
631
+ operation-bound staging witness. Recovery accepts that pair only when both names
632
+ identify the same private inode and the existing stale-owner checks pass.
633
+ Readers from 0.10.31 and earlier refuse this interrupted two-link state; inspect
634
+ and recover it with an updated installation. Ordinary held locks have one link.
635
+ This prevents new empty locks; it does not recover historical empty or malformed
636
+ locks, whose ownership remains unknown and which must stay protected.
637
+
626
638
  Older PID-only locks require a local review because they do not identify their
627
639
  host. On the host that created the lock, inspect the receipt and lock, verify
628
640
  the recorded PID belongs to no live writer, then apply the exact reviewed lock:
package/bin/index.js CHANGED
@@ -36868,7 +36868,7 @@ var package_default;
36868
36868
  var init_package = __esm(() => {
36869
36869
  package_default = {
36870
36870
  name: "@hasna/skills",
36871
- version: "0.10.30",
36871
+ version: "0.10.33",
36872
36872
  description: "Skills library for AI coding agents",
36873
36873
  type: "module",
36874
36874
  bin: {
@@ -48139,7 +48139,7 @@ var init_profile_limits = __esm(() => {
48139
48139
  // src/lib/selection-cache.ts
48140
48140
  import { createHash as createHash6, randomUUID as randomUUID3 } from "crypto";
48141
48141
  import { spawnSync } from "child_process";
48142
- import { closeSync as closeSync2, constants as constants3, fstatSync as fstatSync2, fsyncSync, lstatSync as lstatSync7, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync9, readlinkSync as readlinkSync2, readSync as readSync2, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
48142
+ import { closeSync as closeSync2, constants as constants3, fstatSync as fstatSync2, fsyncSync, linkSync, lstatSync as lstatSync7, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync9, readlinkSync as readlinkSync2, readSync as readSync2, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
48143
48143
  import { hostname } from "os";
48144
48144
  import { dirname as dirname7, join as join15, parse as parse2, resolve as resolve8 } from "path";
48145
48145
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
@@ -48487,7 +48487,7 @@ function readSessionLock(sessionId, options) {
48487
48487
  const before2 = lstatSync7(path, { throwIfNoEntry: false });
48488
48488
  if (!before2)
48489
48489
  return null;
48490
- if (!before2.isFile() || before2.nlink !== 1 || (before2.mode & 63) !== 0 || process.getuid && before2.uid !== process.getuid()) {
48490
+ if (!before2.isFile() || ![1, 2].includes(before2.nlink) || (before2.mode & 63) !== 0 || process.getuid && before2.uid !== process.getuid()) {
48491
48491
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is not a private regular file; recovery requires review.");
48492
48492
  }
48493
48493
  const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
@@ -48501,10 +48501,54 @@ function readSessionLock(sessionId, options) {
48501
48501
  } catch {
48502
48502
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is unreadable; recovery requires review.");
48503
48503
  }
48504
- if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
48504
+ if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.publication !== undefined && marker.publication !== "hard-link-v1" || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
48505
48505
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has invalid ownership metadata; recovery requires review.");
48506
48506
  }
48507
- return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after2.dev, ino: after2.ino, mtimeMs: after2.mtimeMs };
48507
+ let stagingPath;
48508
+ if (after2.nlink === 2) {
48509
+ stagingPath = `${path}.${marker.operationId}.prepared`;
48510
+ assertRegularPath(stagingPath);
48511
+ const staged = lstatSync7(stagingPath, { throwIfNoEntry: false });
48512
+ if (marker.publication !== "hard-link-v1" || !staged?.isFile() || staged.dev !== after2.dev || staged.ino !== after2.ino || staged.nlink !== 2 || staged.mode !== after2.mode || staged.uid !== after2.uid || staged.gid !== after2.gid) {
48513
+ throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has an unbound publication link; recovery requires review.");
48514
+ }
48515
+ }
48516
+ return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after2.dev, ino: after2.ino, mtimeMs: after2.mtimeMs, stagingPath };
48517
+ }
48518
+ function publishSessionLock(path, marker) {
48519
+ const stagingPath = `${path}.${marker.operationId}.prepared`;
48520
+ const bytes = Buffer.from(`${JSON.stringify({ ...marker, publication: "hard-link-v1" })}
48521
+ `);
48522
+ const fd = openSync2(stagingPath, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
48523
+ const stat = fstatSync2(fd);
48524
+ let published = false, handedOff = false;
48525
+ try {
48526
+ writeFileSync3(fd, bytes);
48527
+ fsyncSync(fd);
48528
+ linkSync(stagingPath, path);
48529
+ published = true;
48530
+ syncDirectory(dirname7(path));
48531
+ const staged = lstatSync7(stagingPath);
48532
+ if (!staged.isFile() || staged.dev !== stat.dev || staged.ino !== stat.ino) {
48533
+ throw new SkillSelectionError("SESSION_WRITE_LOCK_CHANGED", "The initialized lock staging inode changed before publication completed.");
48534
+ }
48535
+ unlinkSync(stagingPath);
48536
+ syncDirectory(dirname7(path));
48537
+ handedOff = true;
48538
+ return { path, stagingPath, fd, dev: stat.dev, ino: stat.ino };
48539
+ } finally {
48540
+ if (!handedOff) {
48541
+ try {
48542
+ for (const target of [...published ? [path] : [], stagingPath]) {
48543
+ const current = lstatSync7(target, { throwIfNoEntry: false });
48544
+ if (current?.isFile() && current.dev === stat.dev && current.ino === stat.ino)
48545
+ unlinkSync(target);
48546
+ }
48547
+ } finally {
48548
+ closeSync2(fd);
48549
+ }
48550
+ }
48551
+ }
48508
48552
  }
48509
48553
  function writerIsGone(lock, explicitLocalHost) {
48510
48554
  if (Date.now() - lock.mtimeMs < SESSION_LOCK_MIN_AGE_MS)
@@ -48593,10 +48637,21 @@ function archiveSessionLock(lock, receipt, explicitLocalHost) {
48593
48637
  try {
48594
48638
  renameSync(lock.path, archivePath);
48595
48639
  moved = true;
48640
+ const archivedPaths = [archivePath];
48641
+ if (finalLock.stagingPath) {
48642
+ const publicationPath = join15(directory, "publication.write-lock");
48643
+ renameSync(finalLock.stagingPath, publicationPath);
48644
+ archivedPaths.push(publicationPath);
48645
+ }
48596
48646
  syncDirectory(directory);
48597
48647
  syncDirectory(dirname7(lock.path));
48598
- if (sha256Hex(readRegularFile(archivePath, SESSION_LOCK_MAX_BYTES)) !== lock.sha256) {
48599
- throw new Error("Archived lock bytes changed");
48648
+ for (const path of archivedPaths) {
48649
+ const before2 = lstatSync7(path, { throwIfNoEntry: false });
48650
+ const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
48651
+ const after2 = lstatSync7(path, { throwIfNoEntry: false });
48652
+ if (!before2?.isFile() || before2.dev !== lock.dev || before2.ino !== lock.ino || before2.nlink !== archivedPaths.length || before2.mtimeMs !== lock.mtimeMs || (before2.mode & 63) !== 0 || process.getuid && before2.uid !== process.getuid() || !bytes || sha256Hex(bytes) !== lock.sha256 || !after2?.isFile() || before2.dev !== after2.dev || before2.ino !== after2.ino || before2.mtimeMs !== after2.mtimeMs || before2.mode !== after2.mode || before2.uid !== after2.uid || before2.gid !== after2.gid || before2.nlink !== after2.nlink) {
48653
+ throw new Error("Archived lock ownership or bytes changed");
48654
+ }
48600
48655
  }
48601
48656
  } catch (error) {
48602
48657
  if (moved)
@@ -48623,6 +48678,7 @@ function recoverSessionWriteLock(sessionId, reviewDigest, options = {}) {
48623
48678
  }
48624
48679
  function withSessionWriteLocks(sessionIds, options, action) {
48625
48680
  const operationId = randomUUID3();
48681
+ const identity4 = localLockIdentity();
48626
48682
  const locks = [...new Set(sessionIds)].map((sessionId) => ({ sessionId, path: `${sessionReceiptPath(sessionId, options)}.write-lock` })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
48627
48683
  const owned = [];
48628
48684
  const assertOwned = () => {
@@ -48636,9 +48692,10 @@ function withSessionWriteLocks(sessionIds, options, action) {
48636
48692
  try {
48637
48693
  for (const lock of locks) {
48638
48694
  assertRegularPath(lock.path, true);
48639
- let fd;
48695
+ const marker = { schemaVersion: identity4 ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity4 };
48696
+ let acquired;
48640
48697
  try {
48641
- fd = openSync2(lock.path, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
48698
+ acquired = publishSessionLock(lock.path, marker);
48642
48699
  } catch (error) {
48643
48700
  if (error.code !== "EEXIST")
48644
48701
  throw error;
@@ -48653,7 +48710,7 @@ function withSessionWriteLocks(sessionIds, options, action) {
48653
48710
  throw recoveryError;
48654
48711
  }
48655
48712
  try {
48656
- fd = openSync2(lock.path, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
48713
+ acquired = publishSessionLock(lock.path, marker);
48657
48714
  } catch (retryError) {
48658
48715
  if (retryError.code === "EEXIST") {
48659
48716
  if (!readSessionLock(lock.sessionId, options))
@@ -48663,19 +48720,20 @@ function withSessionWriteLocks(sessionIds, options, action) {
48663
48720
  throw retryError;
48664
48721
  }
48665
48722
  }
48666
- const stat = fstatSync2(fd);
48667
- owned.push({ path: lock.path, fd, dev: stat.dev, ino: stat.ino });
48668
- const identity4 = localLockIdentity();
48669
- writeFileSync3(fd, `${JSON.stringify({ schemaVersion: identity4 ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity4 })}
48670
- `);
48723
+ owned.push(acquired);
48671
48724
  }
48672
48725
  return action(assertOwned);
48673
48726
  } finally {
48674
48727
  for (const lock of owned.reverse()) {
48675
- closeSync2(lock.fd);
48676
- const current = lstatSync7(lock.path, { throwIfNoEntry: false });
48677
- if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
48678
- unlinkSync(lock.path);
48728
+ try {
48729
+ for (const path of [lock.path, lock.stagingPath]) {
48730
+ const current = lstatSync7(path, { throwIfNoEntry: false });
48731
+ if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
48732
+ unlinkSync(path);
48733
+ }
48734
+ } finally {
48735
+ closeSync2(lock.fd);
48736
+ }
48679
48737
  }
48680
48738
  }
48681
48739
  }
@@ -51369,6 +51427,29 @@ function mcpControls(path, read2) {
51369
51427
  refuse2();
51370
51428
  return sha2562;
51371
51429
  }
51430
+ function isBundledCleanupHooks(root, namespace, value) {
51431
+ const object5 = (v) => Boolean(v && typeof v === "object" && !Array.isArray(v));
51432
+ const exactKeys = (v, keys2) => Object.keys(v).length === keys2.length && keys2.every((key) => Object.hasOwn(v, key));
51433
+ if (basename5(dirname10(root)) !== namespace || basename5(dirname10(dirname10(root))) !== "openai-bundled")
51434
+ return false;
51435
+ const server = namespace === "unified-computer-use" ? "cua_repl" : ["browser", "chrome", "chrome-dev", "chrome-internal", "computer-use"].includes(namespace) ? "node_repl" : undefined;
51436
+ if (!server || !object5(value) || !exactKeys(value, ["hooks"]) || !object5(value.hooks))
51437
+ return false;
51438
+ const events = Object.entries(value.hooks);
51439
+ if (!events.length || events.length > 3)
51440
+ return false;
51441
+ for (const [event, groups] of events) {
51442
+ if (!["Interrupt", "SubagentStop", "Stop"].includes(event) || !Array.isArray(groups) || groups.length !== 1)
51443
+ return false;
51444
+ const group = groups[0];
51445
+ if (!object5(group) || !exactKeys(group, ["hooks"]) || !Array.isArray(group.hooks) || group.hooks.length !== 1)
51446
+ return false;
51447
+ const handler = group.hooks[0];
51448
+ if (!object5(handler) || !exactKeys(handler, ["type", "server", "tool", "input"]) || handler.type !== "mcp_tool" || handler.server !== server || handler.tool !== "turn_ended" || !object5(handler.input) || !exactKeys(handler.input, ["hook_event_name", "session_id", "turn_id"]) || handler.input.hook_event_name !== "${hook_event_name}" || handler.input.turn_id !== "${turn_id}" || handler.input.session_id !== (event === "SubagentStop" ? "${agent_id}" : "${session_id}"))
51449
+ return false;
51450
+ }
51451
+ return true;
51452
+ }
51372
51453
  function rootControls(root, read2) {
51373
51454
  const manifestPath = join18(root, ".codex-plugin/plugin.json"), manifestFile = lstatSync12(manifestPath, { throwIfNoEntry: false });
51374
51455
  if (!lstatSync12(join18(root, ".codex-plugin"), { throwIfNoEntry: false })?.isDirectory() || !manifestFile?.isFile() || manifestFile.size > 1024 * 1024)
@@ -51386,9 +51467,11 @@ function rootControls(root, read2) {
51386
51467
  const cacheVersion = basename5(root), declaredVersion = manifest.version === undefined ? "" : typeof manifest.version === "string" ? manifest.version.trim() : null;
51387
51468
  if (declaredVersion === null || (declaredVersion ? declaredVersion !== cacheVersion : cacheVersion !== CODEX_DEFAULT_AGENT_PLUGIN_VERSION))
51388
51469
  refuse2();
51389
- if (manifest.hooks !== undefined || manifest.commands !== undefined || manifest.mcpServers !== undefined && manifest.mcpServers !== "./.mcp.json" || manifest.apps !== undefined && manifest.apps !== "./.app.json" || lstatSync12(join18(root, "hooks"), { throwIfNoEntry: false }))
51470
+ if (manifest.hooks !== undefined && !isBundledCleanupHooks(root, manifest.name, manifest.hooks) || manifest.commands !== undefined || manifest.mcpServers !== undefined && manifest.mcpServers !== "./.mcp.json" || manifest.apps !== undefined && manifest.apps !== "./.app.json" || lstatSync12(join18(root, "hooks"), { throwIfNoEntry: false }))
51390
51471
  refuse2();
51391
51472
  const mcp = join18(root, ".mcp.json"), mcpPresent = lstatSync12(mcp, { throwIfNoEntry: false });
51473
+ if (manifest.hooks !== undefined && (manifest.mcpServers !== undefined || mcpPresent))
51474
+ refuse2();
51392
51475
  if (mcpPresent && (!mcpPresent.isFile() || mcpPresent.isSymbolicLink() || mcpPresent.size > 1024 * 1024 || realpathSync5(mcp) !== mcp))
51393
51476
  refuse2();
51394
51477
  if (Boolean(mcpPresent) !== (manifest.mcpServers !== undefined))
@@ -52513,7 +52596,7 @@ var init_agent_sumi = __esm(() => {
52513
52596
  // src/lib/agent-integration.ts
52514
52597
  import { createHash as createHash12, randomUUID as randomUUID5 } from "crypto";
52515
52598
  import { isDeepStrictEqual as isDeepStrictEqual5 } from "util";
52516
- import { existsSync as existsSync13, lstatSync as lstatSync16, statSync as statSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync13, readdirSync as readdirSync8, opendirSync as opendirSync3, readlinkSync as readlinkSync4, realpathSync as realpathSync7, renameSync as renameSync2, rmdirSync as rmdirSync2, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, chmodSync, openSync as openSync8, closeSync as closeSync8, fsyncSync as fsyncSync2, fstatSync as fstatSync8, readSync as readSync6, constants as constants8, linkSync } from "fs";
52599
+ import { existsSync as existsSync13, lstatSync as lstatSync16, statSync as statSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync13, readdirSync as readdirSync8, opendirSync as opendirSync3, readlinkSync as readlinkSync4, realpathSync as realpathSync7, renameSync as renameSync2, rmdirSync as rmdirSync2, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, chmodSync, openSync as openSync8, closeSync as closeSync8, fsyncSync as fsyncSync2, fstatSync as fstatSync8, readSync as readSync6, constants as constants8, linkSync as linkSync2 } from "fs";
52517
52600
  import { dirname as dirname11, join as join23, resolve as resolve12, relative as relative5, isAbsolute as isAbsolute11, sep as sep5 } from "path";
52518
52601
  import { homedir as homedir3 } from "os";
52519
52602
  function hasNoDarwinAcl(fd) {
@@ -53567,7 +53650,7 @@ function archiveNativeSkills(inventory, options) {
53567
53650
  continue;
53568
53651
  }
53569
53652
  if (entry.discoveryOnly) {
53570
- linkSync(entry.archive, entry.source);
53653
+ linkSync2(entry.archive, entry.source);
53571
53654
  syncArchiveDirectory(dirname11(entry.source));
53572
53655
  unlinkSync3(entry.archive);
53573
53656
  } else {
@@ -55550,7 +55633,7 @@ var init_plugin_projection = __esm(() => {
55550
55633
 
55551
55634
  // src/lib/plugin-projection-store.ts
55552
55635
  import { createHash as createHash14, randomUUID as randomUUID6 } from "crypto";
55553
- import { constants as constants9, closeSync as closeSync9, fchmodSync, fstatSync as fstatSync9, fsyncSync as fsyncSync3, linkSync as linkSync2, lstatSync as lstatSync18, mkdirSync as mkdirSync9, openSync as openSync9, opendirSync as opendirSync4, readSync as readSync7, renameSync as renameSync5, rmdirSync as rmdirSync3, rmSync as rmSync4, unlinkSync as unlinkSync4, writeSync as writeSync2 } from "fs";
55636
+ import { constants as constants9, closeSync as closeSync9, fchmodSync, fstatSync as fstatSync9, fsyncSync as fsyncSync3, linkSync as linkSync3, lstatSync as lstatSync18, mkdirSync as mkdirSync9, openSync as openSync9, opendirSync as opendirSync4, readSync as readSync7, renameSync as renameSync5, rmdirSync as rmdirSync3, rmSync as rmSync4, unlinkSync as unlinkSync4, writeSync as writeSync2 } from "fs";
55554
55637
  import { dirname as dirname13, isAbsolute as isAbsolute12, join as join30, parse as parse5, resolve as resolve13 } from "path";
55555
55638
  function stat(path) {
55556
55639
  try {
@@ -55862,7 +55945,7 @@ function writePluginJsonImmutable(path, value) {
55862
55945
  written = stat(temporary);
55863
55946
  verifyAncestors(chain, true);
55864
55947
  try {
55865
- linkSync2(temporary, path);
55948
+ linkSync3(temporary, path);
55866
55949
  } catch (error) {
55867
55950
  if (!(error && typeof error === "object" && ("code" in error) && error.code === "EEXIST"))
55868
55951
  throw error;
@@ -57240,13 +57323,13 @@ function resolveAgentDiscovery(options) {
57240
57323
  unresolved("JSONC configuration requires a supported format adapter");
57241
57324
  for (const root2 of [directory, join33(home, ".claude"), join33(home, ".agents")])
57242
57325
  for (const name of ["skill", "skills"])
57243
- roots.add(join33(root2, name));
57326
+ roots.add(canonical6(join33(root2, name)));
57244
57327
  if (config.skills !== undefined && (!Array.isArray(config.skills) || config.skills.some((path) => typeof path !== "string")))
57245
57328
  unresolved("skills must be a string array");
57246
57329
  for (const value of config.skills ?? []) {
57247
57330
  if (typeof value !== "string" || /[\0$]/.test(value) || !value || !(isAbsolute17(value) || value.startsWith("~/")))
57248
57331
  unresolved("remote or relative skill sources need a dedicated discovery adapter");
57249
- roots.add(value.startsWith("~/") ? join33(home, value.slice(2)) : value);
57332
+ roots.add(canonical6(value.startsWith("~/") ? join33(home, value.slice(2)) : value));
57250
57333
  }
57251
57334
  for (const key of ["plugin", "plugins"])
57252
57335
  if (config[key] !== undefined && !Array.isArray(config[key]))
package/bin/mcp.js CHANGED
@@ -20019,7 +20019,7 @@ var package_default;
20019
20019
  var init_package = __esm(() => {
20020
20020
  package_default = {
20021
20021
  name: "@hasna/skills",
20022
- version: "0.10.30",
20022
+ version: "0.10.33",
20023
20023
  description: "Skills library for AI coding agents",
20024
20024
  type: "module",
20025
20025
  bin: {
@@ -46227,7 +46227,7 @@ var init_profile_limits = __esm(() => {
46227
46227
  // src/lib/selection-cache.ts
46228
46228
  import { createHash as createHash6, randomUUID } from "crypto";
46229
46229
  import { spawnSync as spawnSync2 } from "child_process";
46230
- import { closeSync as closeSync6, constants as constants3, fstatSync as fstatSync6, fsyncSync, lstatSync as lstatSync8, mkdirSync as mkdirSync3, openSync as openSync6, readFileSync as readFileSync10, readlinkSync as readlinkSync2, readSync as readSync3, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
46230
+ import { closeSync as closeSync6, constants as constants3, fstatSync as fstatSync6, fsyncSync, linkSync, lstatSync as lstatSync8, mkdirSync as mkdirSync3, openSync as openSync6, readFileSync as readFileSync10, readlinkSync as readlinkSync2, readSync as readSync3, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
46231
46231
  import { hostname as hostname3 } from "os";
46232
46232
  import { dirname as dirname8, join as join14, parse as parse7, resolve as resolve7 } from "path";
46233
46233
  import { isDeepStrictEqual } from "util";
@@ -46563,7 +46563,7 @@ function readSessionLock(sessionId, options) {
46563
46563
  const before = lstatSync8(path, { throwIfNoEntry: false });
46564
46564
  if (!before)
46565
46565
  return null;
46566
- if (!before.isFile() || before.nlink !== 1 || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid()) {
46566
+ if (!before.isFile() || ![1, 2].includes(before.nlink) || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid()) {
46567
46567
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is not a private regular file; recovery requires review.");
46568
46568
  }
46569
46569
  const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
@@ -46577,10 +46577,54 @@ function readSessionLock(sessionId, options) {
46577
46577
  } catch {
46578
46578
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is unreadable; recovery requires review.");
46579
46579
  }
46580
- if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
46580
+ if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.publication !== undefined && marker.publication !== "hard-link-v1" || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
46581
46581
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has invalid ownership metadata; recovery requires review.");
46582
46582
  }
46583
- return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs };
46583
+ let stagingPath;
46584
+ if (after.nlink === 2) {
46585
+ stagingPath = `${path}.${marker.operationId}.prepared`;
46586
+ assertRegularPath(stagingPath);
46587
+ const staged = lstatSync8(stagingPath, { throwIfNoEntry: false });
46588
+ if (marker.publication !== "hard-link-v1" || !staged?.isFile() || staged.dev !== after.dev || staged.ino !== after.ino || staged.nlink !== 2 || staged.mode !== after.mode || staged.uid !== after.uid || staged.gid !== after.gid) {
46589
+ throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has an unbound publication link; recovery requires review.");
46590
+ }
46591
+ }
46592
+ return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs, stagingPath };
46593
+ }
46594
+ function publishSessionLock(path, marker) {
46595
+ const stagingPath = `${path}.${marker.operationId}.prepared`;
46596
+ const bytes = Buffer.from(`${JSON.stringify({ ...marker, publication: "hard-link-v1" })}
46597
+ `);
46598
+ const fd = openSync6(stagingPath, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
46599
+ const stat = fstatSync6(fd);
46600
+ let published = false, handedOff = false;
46601
+ try {
46602
+ writeFileSync3(fd, bytes);
46603
+ fsyncSync(fd);
46604
+ linkSync(stagingPath, path);
46605
+ published = true;
46606
+ syncDirectory(dirname8(path));
46607
+ const staged = lstatSync8(stagingPath);
46608
+ if (!staged.isFile() || staged.dev !== stat.dev || staged.ino !== stat.ino) {
46609
+ throw new SkillSelectionError("SESSION_WRITE_LOCK_CHANGED", "The initialized lock staging inode changed before publication completed.");
46610
+ }
46611
+ unlinkSync(stagingPath);
46612
+ syncDirectory(dirname8(path));
46613
+ handedOff = true;
46614
+ return { path, stagingPath, fd, dev: stat.dev, ino: stat.ino };
46615
+ } finally {
46616
+ if (!handedOff) {
46617
+ try {
46618
+ for (const target of [...published ? [path] : [], stagingPath]) {
46619
+ const current = lstatSync8(target, { throwIfNoEntry: false });
46620
+ if (current?.isFile() && current.dev === stat.dev && current.ino === stat.ino)
46621
+ unlinkSync(target);
46622
+ }
46623
+ } finally {
46624
+ closeSync6(fd);
46625
+ }
46626
+ }
46627
+ }
46584
46628
  }
46585
46629
  function writerIsGone(lock, explicitLocalHost) {
46586
46630
  if (Date.now() - lock.mtimeMs < SESSION_LOCK_MIN_AGE_MS)
@@ -46642,10 +46686,21 @@ function archiveSessionLock(lock, receipt, explicitLocalHost) {
46642
46686
  try {
46643
46687
  renameSync(lock.path, archivePath);
46644
46688
  moved = true;
46689
+ const archivedPaths = [archivePath];
46690
+ if (finalLock.stagingPath) {
46691
+ const publicationPath = join14(directory, "publication.write-lock");
46692
+ renameSync(finalLock.stagingPath, publicationPath);
46693
+ archivedPaths.push(publicationPath);
46694
+ }
46645
46695
  syncDirectory(directory);
46646
46696
  syncDirectory(dirname8(lock.path));
46647
- if (sha256Hex(readRegularFile(archivePath, SESSION_LOCK_MAX_BYTES)) !== lock.sha256) {
46648
- throw new Error("Archived lock bytes changed");
46697
+ for (const path of archivedPaths) {
46698
+ const before = lstatSync8(path, { throwIfNoEntry: false });
46699
+ const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
46700
+ const after = lstatSync8(path, { throwIfNoEntry: false });
46701
+ if (!before?.isFile() || before.dev !== lock.dev || before.ino !== lock.ino || before.nlink !== archivedPaths.length || before.mtimeMs !== lock.mtimeMs || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid() || !bytes || sha256Hex(bytes) !== lock.sha256 || !after?.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.mode !== after.mode || before.uid !== after.uid || before.gid !== after.gid || before.nlink !== after.nlink) {
46702
+ throw new Error("Archived lock ownership or bytes changed");
46703
+ }
46649
46704
  }
46650
46705
  } catch (error61) {
46651
46706
  if (moved)
@@ -46662,6 +46717,7 @@ function archiveSessionLock(lock, receipt, explicitLocalHost) {
46662
46717
  }
46663
46718
  function withSessionWriteLocks(sessionIds, options, action) {
46664
46719
  const operationId = randomUUID();
46720
+ const identity3 = localLockIdentity();
46665
46721
  const locks = [...new Set(sessionIds)].map((sessionId) => ({ sessionId, path: `${sessionReceiptPath(sessionId, options)}.write-lock` })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
46666
46722
  const owned = [];
46667
46723
  const assertOwned = () => {
@@ -46675,9 +46731,10 @@ function withSessionWriteLocks(sessionIds, options, action) {
46675
46731
  try {
46676
46732
  for (const lock of locks) {
46677
46733
  assertRegularPath(lock.path, true);
46678
- let fd;
46734
+ const marker = { schemaVersion: identity3 ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity3 };
46735
+ let acquired;
46679
46736
  try {
46680
- fd = openSync6(lock.path, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
46737
+ acquired = publishSessionLock(lock.path, marker);
46681
46738
  } catch (error61) {
46682
46739
  if (error61.code !== "EEXIST")
46683
46740
  throw error61;
@@ -46692,7 +46749,7 @@ function withSessionWriteLocks(sessionIds, options, action) {
46692
46749
  throw recoveryError;
46693
46750
  }
46694
46751
  try {
46695
- fd = openSync6(lock.path, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
46752
+ acquired = publishSessionLock(lock.path, marker);
46696
46753
  } catch (retryError) {
46697
46754
  if (retryError.code === "EEXIST") {
46698
46755
  if (!readSessionLock(lock.sessionId, options))
@@ -46702,19 +46759,20 @@ function withSessionWriteLocks(sessionIds, options, action) {
46702
46759
  throw retryError;
46703
46760
  }
46704
46761
  }
46705
- const stat = fstatSync6(fd);
46706
- owned.push({ path: lock.path, fd, dev: stat.dev, ino: stat.ino });
46707
- const identity3 = localLockIdentity();
46708
- writeFileSync3(fd, `${JSON.stringify({ schemaVersion: identity3 ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity3 })}
46709
- `);
46762
+ owned.push(acquired);
46710
46763
  }
46711
46764
  return action(assertOwned);
46712
46765
  } finally {
46713
46766
  for (const lock of owned.reverse()) {
46714
- closeSync6(lock.fd);
46715
- const current = lstatSync8(lock.path, { throwIfNoEntry: false });
46716
- if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
46717
- unlinkSync(lock.path);
46767
+ try {
46768
+ for (const path of [lock.path, lock.stagingPath]) {
46769
+ const current = lstatSync8(path, { throwIfNoEntry: false });
46770
+ if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
46771
+ unlinkSync(path);
46772
+ }
46773
+ } finally {
46774
+ closeSync6(lock.fd);
46775
+ }
46718
46776
  }
46719
46777
  }
46720
46778
  }
@@ -48897,7 +48955,7 @@ var init_plugin_projection = __esm(() => {
48897
48955
  });
48898
48956
 
48899
48957
  // src/lib/plugin-projection-store.ts
48900
- import { constants as constants7, closeSync as closeSync10, fchmodSync, fstatSync as fstatSync10, fsyncSync as fsyncSync2, linkSync, lstatSync as lstatSync14, mkdirSync as mkdirSync6, openSync as openSync10, opendirSync as opendirSync2, readSync as readSync6, renameSync as renameSync2, rmdirSync as rmdirSync2, rmSync as rmSync2, unlinkSync as unlinkSync3, writeSync as writeSync2 } from "fs";
48958
+ import { constants as constants7, closeSync as closeSync10, fchmodSync, fstatSync as fstatSync10, fsyncSync as fsyncSync2, linkSync as linkSync2, lstatSync as lstatSync14, mkdirSync as mkdirSync6, openSync as openSync10, opendirSync as opendirSync2, readSync as readSync6, renameSync as renameSync2, rmdirSync as rmdirSync2, rmSync as rmSync2, unlinkSync as unlinkSync3, writeSync as writeSync2 } from "fs";
48901
48959
  import { dirname as dirname11, isAbsolute as isAbsolute9, join as join22, parse as parse9, resolve as resolve11 } from "path";
48902
48960
  function stat(path) {
48903
48961
  try {
@@ -49956,13 +50014,13 @@ function resolveAgentDiscovery(options) {
49956
50014
  unresolved("JSONC configuration requires a supported format adapter");
49957
50015
  for (const root of [directory, join25(home, ".claude"), join25(home, ".agents")])
49958
50016
  for (const name of ["skill", "skills"])
49959
- roots.add(join25(root, name));
50017
+ roots.add(canonical6(join25(root, name)));
49960
50018
  if (config2.skills !== undefined && (!Array.isArray(config2.skills) || config2.skills.some((path) => typeof path !== "string")))
49961
50019
  unresolved("skills must be a string array");
49962
50020
  for (const value of config2.skills ?? []) {
49963
50021
  if (typeof value !== "string" || /[\0$]/.test(value) || !value || !(isAbsolute13(value) || value.startsWith("~/")))
49964
50022
  unresolved("remote or relative skill sources need a dedicated discovery adapter");
49965
- roots.add(value.startsWith("~/") ? join25(home, value.slice(2)) : value);
50023
+ roots.add(canonical6(value.startsWith("~/") ? join25(home, value.slice(2)) : value));
49966
50024
  }
49967
50025
  for (const key of ["plugin", "plugins"])
49968
50026
  if (config2[key] !== undefined && !Array.isArray(config2[key]))
package/bin/migrate.js CHANGED
@@ -7,7 +7,7 @@ import { join as join5 } from "path";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "@hasna/skills",
10
- version: "0.10.30",
10
+ version: "0.10.33",
11
11
  description: "Skills library for AI coding agents",
12
12
  type: "module",
13
13
  bin: {
package/bin/server.js CHANGED
@@ -24122,7 +24122,7 @@ var init_dist_es9 = __esm(() => {
24122
24122
  // package.json
24123
24123
  var package_default = {
24124
24124
  name: "@hasna/skills",
24125
- version: "0.10.30",
24125
+ version: "0.10.33",
24126
24126
  description: "Skills library for AI coding agents",
24127
24127
  type: "module",
24128
24128
  bin: {
package/bin/worker.js CHANGED
@@ -24125,7 +24125,7 @@ import { randomUUID as randomUUID6 } from "crypto";
24125
24125
  // package.json
24126
24126
  var package_default = {
24127
24127
  name: "@hasna/skills",
24128
- version: "0.10.30",
24128
+ version: "0.10.33",
24129
24129
  description: "Skills library for AI coding agents",
24130
24130
  type: "module",
24131
24131
  bin: {
package/dist/index.js CHANGED
@@ -9665,7 +9665,7 @@ import { isAbsolute as isAbsolute13, join as join26, resolve as resolve13 } from
9665
9665
  // src/lib/selection-cache.ts
9666
9666
  import { createHash as createHash6, randomUUID } from "crypto";
9667
9667
  import { spawnSync } from "child_process";
9668
- import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, fsyncSync, lstatSync as lstatSync7, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync8, readlinkSync as readlinkSync2, readSync as readSync2, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
9668
+ import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, fsyncSync, linkSync, lstatSync as lstatSync7, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync8, readlinkSync as readlinkSync2, readSync as readSync2, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
9669
9669
  import { hostname } from "os";
9670
9670
  import { dirname as dirname7, join as join11, parse as parse2, resolve as resolve7 } from "path";
9671
9671
  import { isDeepStrictEqual } from "util";
@@ -10655,7 +10655,7 @@ function readSessionLock(sessionId, options) {
10655
10655
  const before = lstatSync7(path, { throwIfNoEntry: false });
10656
10656
  if (!before)
10657
10657
  return null;
10658
- if (!before.isFile() || before.nlink !== 1 || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid()) {
10658
+ if (!before.isFile() || ![1, 2].includes(before.nlink) || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid()) {
10659
10659
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is not a private regular file; recovery requires review.");
10660
10660
  }
10661
10661
  const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
@@ -10669,10 +10669,54 @@ function readSessionLock(sessionId, options) {
10669
10669
  } catch {
10670
10670
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is unreadable; recovery requires review.");
10671
10671
  }
10672
- if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
10672
+ if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.publication !== undefined && marker.publication !== "hard-link-v1" || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
10673
10673
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has invalid ownership metadata; recovery requires review.");
10674
10674
  }
10675
- return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs };
10675
+ let stagingPath;
10676
+ if (after.nlink === 2) {
10677
+ stagingPath = `${path}.${marker.operationId}.prepared`;
10678
+ assertRegularPath(stagingPath);
10679
+ const staged = lstatSync7(stagingPath, { throwIfNoEntry: false });
10680
+ if (marker.publication !== "hard-link-v1" || !staged?.isFile() || staged.dev !== after.dev || staged.ino !== after.ino || staged.nlink !== 2 || staged.mode !== after.mode || staged.uid !== after.uid || staged.gid !== after.gid) {
10681
+ throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has an unbound publication link; recovery requires review.");
10682
+ }
10683
+ }
10684
+ return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs, stagingPath };
10685
+ }
10686
+ function publishSessionLock(path, marker) {
10687
+ const stagingPath = `${path}.${marker.operationId}.prepared`;
10688
+ const bytes = Buffer.from(`${JSON.stringify({ ...marker, publication: "hard-link-v1" })}
10689
+ `);
10690
+ const fd = openSync2(stagingPath, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
10691
+ const stat = fstatSync2(fd);
10692
+ let published = false, handedOff = false;
10693
+ try {
10694
+ writeFileSync3(fd, bytes);
10695
+ fsyncSync(fd);
10696
+ linkSync(stagingPath, path);
10697
+ published = true;
10698
+ syncDirectory(dirname7(path));
10699
+ const staged = lstatSync7(stagingPath);
10700
+ if (!staged.isFile() || staged.dev !== stat.dev || staged.ino !== stat.ino) {
10701
+ throw new SkillSelectionError("SESSION_WRITE_LOCK_CHANGED", "The initialized lock staging inode changed before publication completed.");
10702
+ }
10703
+ unlinkSync(stagingPath);
10704
+ syncDirectory(dirname7(path));
10705
+ handedOff = true;
10706
+ return { path, stagingPath, fd, dev: stat.dev, ino: stat.ino };
10707
+ } finally {
10708
+ if (!handedOff) {
10709
+ try {
10710
+ for (const target of [...published ? [path] : [], stagingPath]) {
10711
+ const current = lstatSync7(target, { throwIfNoEntry: false });
10712
+ if (current?.isFile() && current.dev === stat.dev && current.ino === stat.ino)
10713
+ unlinkSync(target);
10714
+ }
10715
+ } finally {
10716
+ closeSync2(fd);
10717
+ }
10718
+ }
10719
+ }
10676
10720
  }
10677
10721
  function writerIsGone(lock, explicitLocalHost) {
10678
10722
  if (Date.now() - lock.mtimeMs < SESSION_LOCK_MIN_AGE_MS)
@@ -10734,10 +10778,21 @@ function archiveSessionLock(lock, receipt, explicitLocalHost) {
10734
10778
  try {
10735
10779
  renameSync(lock.path, archivePath);
10736
10780
  moved = true;
10781
+ const archivedPaths = [archivePath];
10782
+ if (finalLock.stagingPath) {
10783
+ const publicationPath = join11(directory, "publication.write-lock");
10784
+ renameSync(finalLock.stagingPath, publicationPath);
10785
+ archivedPaths.push(publicationPath);
10786
+ }
10737
10787
  syncDirectory(directory);
10738
10788
  syncDirectory(dirname7(lock.path));
10739
- if (sha256Hex(readRegularFile(archivePath, SESSION_LOCK_MAX_BYTES)) !== lock.sha256) {
10740
- throw new Error("Archived lock bytes changed");
10789
+ for (const path of archivedPaths) {
10790
+ const before = lstatSync7(path, { throwIfNoEntry: false });
10791
+ const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
10792
+ const after = lstatSync7(path, { throwIfNoEntry: false });
10793
+ if (!before?.isFile() || before.dev !== lock.dev || before.ino !== lock.ino || before.nlink !== archivedPaths.length || before.mtimeMs !== lock.mtimeMs || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid() || !bytes || sha256Hex(bytes) !== lock.sha256 || !after?.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.mode !== after.mode || before.uid !== after.uid || before.gid !== after.gid || before.nlink !== after.nlink) {
10794
+ throw new Error("Archived lock ownership or bytes changed");
10795
+ }
10741
10796
  }
10742
10797
  } catch (error) {
10743
10798
  if (moved)
@@ -10754,6 +10809,7 @@ function archiveSessionLock(lock, receipt, explicitLocalHost) {
10754
10809
  }
10755
10810
  function withSessionWriteLocks(sessionIds, options, action) {
10756
10811
  const operationId = randomUUID();
10812
+ const identity3 = localLockIdentity();
10757
10813
  const locks = [...new Set(sessionIds)].map((sessionId) => ({ sessionId, path: `${sessionReceiptPath(sessionId, options)}.write-lock` })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
10758
10814
  const owned = [];
10759
10815
  const assertOwned = () => {
@@ -10767,9 +10823,10 @@ function withSessionWriteLocks(sessionIds, options, action) {
10767
10823
  try {
10768
10824
  for (const lock of locks) {
10769
10825
  assertRegularPath(lock.path, true);
10770
- let fd;
10826
+ const marker = { schemaVersion: identity3 ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity3 };
10827
+ let acquired;
10771
10828
  try {
10772
- fd = openSync2(lock.path, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
10829
+ acquired = publishSessionLock(lock.path, marker);
10773
10830
  } catch (error) {
10774
10831
  if (error.code !== "EEXIST")
10775
10832
  throw error;
@@ -10784,7 +10841,7 @@ function withSessionWriteLocks(sessionIds, options, action) {
10784
10841
  throw recoveryError;
10785
10842
  }
10786
10843
  try {
10787
- fd = openSync2(lock.path, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
10844
+ acquired = publishSessionLock(lock.path, marker);
10788
10845
  } catch (retryError) {
10789
10846
  if (retryError.code === "EEXIST") {
10790
10847
  if (!readSessionLock(lock.sessionId, options))
@@ -10794,19 +10851,20 @@ function withSessionWriteLocks(sessionIds, options, action) {
10794
10851
  throw retryError;
10795
10852
  }
10796
10853
  }
10797
- const stat = fstatSync2(fd);
10798
- owned.push({ path: lock.path, fd, dev: stat.dev, ino: stat.ino });
10799
- const identity3 = localLockIdentity();
10800
- writeFileSync3(fd, `${JSON.stringify({ schemaVersion: identity3 ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity3 })}
10801
- `);
10854
+ owned.push(acquired);
10802
10855
  }
10803
10856
  return action(assertOwned);
10804
10857
  } finally {
10805
10858
  for (const lock of owned.reverse()) {
10806
- closeSync2(lock.fd);
10807
- const current = lstatSync7(lock.path, { throwIfNoEntry: false });
10808
- if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
10809
- unlinkSync(lock.path);
10859
+ try {
10860
+ for (const path of [lock.path, lock.stagingPath]) {
10861
+ const current = lstatSync7(path, { throwIfNoEntry: false });
10862
+ if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
10863
+ unlinkSync(path);
10864
+ }
10865
+ } finally {
10866
+ closeSync2(lock.fd);
10867
+ }
10810
10868
  }
10811
10869
  }
10812
10870
  }
@@ -10917,7 +10975,7 @@ function replaceSkillSession(expected, replacement, plan, options = {}, beforeCo
10917
10975
  // package.json
10918
10976
  var package_default = {
10919
10977
  name: "@hasna/skills",
10920
- version: "0.10.30",
10978
+ version: "0.10.33",
10921
10979
  description: "Skills library for AI coding agents",
10922
10980
  type: "module",
10923
10981
  bin: {
@@ -13509,6 +13567,29 @@ function mcpControls(path, read2) {
13509
13567
  refuse2();
13510
13568
  return sha2562;
13511
13569
  }
13570
+ function isBundledCleanupHooks(root, namespace, value) {
13571
+ const object5 = (v) => Boolean(v && typeof v === "object" && !Array.isArray(v));
13572
+ const exactKeys = (v, keys) => Object.keys(v).length === keys.length && keys.every((key) => Object.hasOwn(v, key));
13573
+ if (basename5(dirname10(root)) !== namespace || basename5(dirname10(dirname10(root))) !== "openai-bundled")
13574
+ return false;
13575
+ const server = namespace === "unified-computer-use" ? "cua_repl" : ["browser", "chrome", "chrome-dev", "chrome-internal", "computer-use"].includes(namespace) ? "node_repl" : undefined;
13576
+ if (!server || !object5(value) || !exactKeys(value, ["hooks"]) || !object5(value.hooks))
13577
+ return false;
13578
+ const events = Object.entries(value.hooks);
13579
+ if (!events.length || events.length > 3)
13580
+ return false;
13581
+ for (const [event, groups] of events) {
13582
+ if (!["Interrupt", "SubagentStop", "Stop"].includes(event) || !Array.isArray(groups) || groups.length !== 1)
13583
+ return false;
13584
+ const group = groups[0];
13585
+ if (!object5(group) || !exactKeys(group, ["hooks"]) || !Array.isArray(group.hooks) || group.hooks.length !== 1)
13586
+ return false;
13587
+ const handler = group.hooks[0];
13588
+ if (!object5(handler) || !exactKeys(handler, ["type", "server", "tool", "input"]) || handler.type !== "mcp_tool" || handler.server !== server || handler.tool !== "turn_ended" || !object5(handler.input) || !exactKeys(handler.input, ["hook_event_name", "session_id", "turn_id"]) || handler.input.hook_event_name !== "${hook_event_name}" || handler.input.turn_id !== "${turn_id}" || handler.input.session_id !== (event === "SubagentStop" ? "${agent_id}" : "${session_id}"))
13589
+ return false;
13590
+ }
13591
+ return true;
13592
+ }
13512
13593
  function rootControls(root, read2) {
13513
13594
  const manifestPath = join14(root, ".codex-plugin/plugin.json"), manifestFile = lstatSync12(manifestPath, { throwIfNoEntry: false });
13514
13595
  if (!lstatSync12(join14(root, ".codex-plugin"), { throwIfNoEntry: false })?.isDirectory() || !manifestFile?.isFile() || manifestFile.size > 1024 * 1024)
@@ -13526,9 +13607,11 @@ function rootControls(root, read2) {
13526
13607
  const cacheVersion = basename5(root), declaredVersion = manifest.version === undefined ? "" : typeof manifest.version === "string" ? manifest.version.trim() : null;
13527
13608
  if (declaredVersion === null || (declaredVersion ? declaredVersion !== cacheVersion : cacheVersion !== CODEX_DEFAULT_AGENT_PLUGIN_VERSION))
13528
13609
  refuse2();
13529
- if (manifest.hooks !== undefined || manifest.commands !== undefined || manifest.mcpServers !== undefined && manifest.mcpServers !== "./.mcp.json" || manifest.apps !== undefined && manifest.apps !== "./.app.json" || lstatSync12(join14(root, "hooks"), { throwIfNoEntry: false }))
13610
+ if (manifest.hooks !== undefined && !isBundledCleanupHooks(root, manifest.name, manifest.hooks) || manifest.commands !== undefined || manifest.mcpServers !== undefined && manifest.mcpServers !== "./.mcp.json" || manifest.apps !== undefined && manifest.apps !== "./.app.json" || lstatSync12(join14(root, "hooks"), { throwIfNoEntry: false }))
13530
13611
  refuse2();
13531
13612
  const mcp = join14(root, ".mcp.json"), mcpPresent = lstatSync12(mcp, { throwIfNoEntry: false });
13613
+ if (manifest.hooks !== undefined && (manifest.mcpServers !== undefined || mcpPresent))
13614
+ refuse2();
13532
13615
  if (mcpPresent && (!mcpPresent.isFile() || mcpPresent.isSymbolicLink() || mcpPresent.size > 1024 * 1024 || realpathSync5(mcp) !== mcp))
13533
13616
  refuse2();
13534
13617
  if (Boolean(mcpPresent) !== (manifest.mcpServers !== undefined))
@@ -14127,7 +14210,7 @@ function upgradeCodexSettingsWitnessV3(previous, reviewedSettingsPath) {
14127
14210
  // src/lib/agent-integration.ts
14128
14211
  import { createHash as createHash12, randomUUID as randomUUID2 } from "crypto";
14129
14212
  import { isDeepStrictEqual as isDeepStrictEqual4 } from "util";
14130
- import { existsSync as existsSync9, lstatSync as lstatSync16, statSync as statSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, readdirSync as readdirSync8, opendirSync as opendirSync3, readlinkSync as readlinkSync4, realpathSync as realpathSync7, renameSync as renameSync2, rmdirSync as rmdirSync2, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, chmodSync, openSync as openSync8, closeSync as closeSync8, fsyncSync as fsyncSync2, fstatSync as fstatSync8, readSync as readSync6, constants as constants7, linkSync } from "fs";
14213
+ import { existsSync as existsSync9, lstatSync as lstatSync16, statSync as statSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, readdirSync as readdirSync8, opendirSync as opendirSync3, readlinkSync as readlinkSync4, realpathSync as realpathSync7, renameSync as renameSync2, rmdirSync as rmdirSync2, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, chmodSync, openSync as openSync8, closeSync as closeSync8, fsyncSync as fsyncSync2, fstatSync as fstatSync8, readSync as readSync6, constants as constants7, linkSync as linkSync2 } from "fs";
14131
14214
  import { dirname as dirname11, join as join18, resolve as resolve11, relative as relative5, isAbsolute as isAbsolute11, sep as sep5 } from "path";
14132
14215
  import { homedir as homedir2 } from "os";
14133
14216
 
@@ -15716,7 +15799,7 @@ function archiveNativeSkills(inventory, options) {
15716
15799
  continue;
15717
15800
  }
15718
15801
  if (entry.discoveryOnly) {
15719
- linkSync(entry.archive, entry.source);
15802
+ linkSync2(entry.archive, entry.source);
15720
15803
  syncArchiveDirectory(dirname11(entry.source));
15721
15804
  unlinkSync3(entry.archive);
15722
15805
  } else {
@@ -17296,7 +17379,7 @@ function buildPluginProjection(entries) {
17296
17379
 
17297
17380
  // src/lib/plugin-projection-store.ts
17298
17381
  import { createHash as createHash14, randomUUID as randomUUID3 } from "crypto";
17299
- import { constants as constants8, closeSync as closeSync9, fchmodSync, fstatSync as fstatSync9, fsyncSync as fsyncSync3, linkSync as linkSync2, lstatSync as lstatSync18, mkdirSync as mkdirSync8, openSync as openSync9, opendirSync as opendirSync4, readSync as readSync7, renameSync as renameSync4, rmdirSync as rmdirSync3, rmSync as rmSync3, unlinkSync as unlinkSync4, writeSync as writeSync2 } from "fs";
17382
+ import { constants as constants8, closeSync as closeSync9, fchmodSync, fstatSync as fstatSync9, fsyncSync as fsyncSync3, linkSync as linkSync3, lstatSync as lstatSync18, mkdirSync as mkdirSync8, openSync as openSync9, opendirSync as opendirSync4, readSync as readSync7, renameSync as renameSync4, rmdirSync as rmdirSync3, rmSync as rmSync3, unlinkSync as unlinkSync4, writeSync as writeSync2 } from "fs";
17300
17383
  import { dirname as dirname13, isAbsolute as isAbsolute12, join as join25, parse as parse5, resolve as resolve12 } from "path";
17301
17384
  var READ_FLAGS = constants8.O_RDONLY | constants8.O_NOFOLLOW | constants8.O_NONBLOCK;
17302
17385
  var WRITE_FLAGS = constants8.O_WRONLY | constants8.O_CREAT | constants8.O_EXCL | constants8.O_NOFOLLOW | constants8.O_NONBLOCK;
@@ -17612,7 +17695,7 @@ function writePluginJsonImmutable(path, value) {
17612
17695
  written = stat(temporary);
17613
17696
  verifyAncestors(chain, true);
17614
17697
  try {
17615
- linkSync2(temporary, path);
17698
+ linkSync3(temporary, path);
17616
17699
  } catch (error) {
17617
17700
  if (!(error && typeof error === "object" && ("code" in error) && error.code === "EEXIST"))
17618
17701
  throw error;
@@ -18712,13 +18795,13 @@ function resolveAgentDiscovery(options) {
18712
18795
  unresolved("JSONC configuration requires a supported format adapter");
18713
18796
  for (const root2 of [directory, join28(home, ".claude"), join28(home, ".agents")])
18714
18797
  for (const name of ["skill", "skills"])
18715
- roots.add(join28(root2, name));
18798
+ roots.add(canonical6(join28(root2, name)));
18716
18799
  if (config.skills !== undefined && (!Array.isArray(config.skills) || config.skills.some((path) => typeof path !== "string")))
18717
18800
  unresolved("skills must be a string array");
18718
18801
  for (const value of config.skills ?? []) {
18719
18802
  if (typeof value !== "string" || /[\0$]/.test(value) || !value || !(isAbsolute16(value) || value.startsWith("~/")))
18720
18803
  unresolved("remote or relative skill sources need a dedicated discovery adapter");
18721
- roots.add(value.startsWith("~/") ? join28(home, value.slice(2)) : value);
18804
+ roots.add(canonical6(value.startsWith("~/") ? join28(home, value.slice(2)) : value));
18722
18805
  }
18723
18806
  for (const key of ["plugin", "plugins"])
18724
18807
  if (config[key] !== undefined && !Array.isArray(config[key]))
package/dist/sdk/index.js CHANGED
@@ -32373,7 +32373,7 @@ var init_event_sink = __esm(() => {
32373
32373
  // package.json
32374
32374
  var package_default = {
32375
32375
  name: "@hasna/skills",
32376
- version: "0.10.30",
32376
+ version: "0.10.33",
32377
32377
  description: "Skills library for AI coding agents",
32378
32378
  type: "module",
32379
32379
  bin: {
@@ -33976,7 +33976,7 @@ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
33976
33976
  // src/lib/selection-cache.ts
33977
33977
  import { createHash as createHash3, randomUUID } from "crypto";
33978
33978
  import { spawnSync as spawnSync2 } from "child_process";
33979
- import { closeSync as closeSync5, constants as constants2, fstatSync as fstatSync5, fsyncSync, lstatSync as lstatSync3, mkdirSync as mkdirSync2, openSync as openSync5, readFileSync as readFileSync6, readlinkSync, readSync as readSync2, renameSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
33979
+ import { closeSync as closeSync5, constants as constants2, fstatSync as fstatSync5, fsyncSync, linkSync, lstatSync as lstatSync3, mkdirSync as mkdirSync2, openSync as openSync5, readFileSync as readFileSync6, readlinkSync, readSync as readSync2, renameSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
33980
33980
  import { hostname } from "os";
33981
33981
  import { dirname as dirname3, join as join7, parse, resolve as resolve2 } from "path";
33982
33982
  import { isDeepStrictEqual } from "util";
@@ -35259,7 +35259,7 @@ function readSessionLock(sessionId, options) {
35259
35259
  const before = lstatSync3(path, { throwIfNoEntry: false });
35260
35260
  if (!before)
35261
35261
  return null;
35262
- if (!before.isFile() || before.nlink !== 1 || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid()) {
35262
+ if (!before.isFile() || ![1, 2].includes(before.nlink) || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid()) {
35263
35263
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is not a private regular file; recovery requires review.");
35264
35264
  }
35265
35265
  const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
@@ -35273,10 +35273,54 @@ function readSessionLock(sessionId, options) {
35273
35273
  } catch {
35274
35274
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is unreadable; recovery requires review.");
35275
35275
  }
35276
- if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
35276
+ if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.publication !== undefined && marker.publication !== "hard-link-v1" || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
35277
35277
  throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has invalid ownership metadata; recovery requires review.");
35278
35278
  }
35279
- return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs };
35279
+ let stagingPath;
35280
+ if (after.nlink === 2) {
35281
+ stagingPath = `${path}.${marker.operationId}.prepared`;
35282
+ assertRegularPath(stagingPath);
35283
+ const staged = lstatSync3(stagingPath, { throwIfNoEntry: false });
35284
+ if (marker.publication !== "hard-link-v1" || !staged?.isFile() || staged.dev !== after.dev || staged.ino !== after.ino || staged.nlink !== 2 || staged.mode !== after.mode || staged.uid !== after.uid || staged.gid !== after.gid) {
35285
+ throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has an unbound publication link; recovery requires review.");
35286
+ }
35287
+ }
35288
+ return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs, stagingPath };
35289
+ }
35290
+ function publishSessionLock(path, marker) {
35291
+ const stagingPath = `${path}.${marker.operationId}.prepared`;
35292
+ const bytes = Buffer.from(`${JSON.stringify({ ...marker, publication: "hard-link-v1" })}
35293
+ `);
35294
+ const fd = openSync5(stagingPath, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
35295
+ const stat = fstatSync5(fd);
35296
+ let published = false, handedOff = false;
35297
+ try {
35298
+ writeFileSync2(fd, bytes);
35299
+ fsyncSync(fd);
35300
+ linkSync(stagingPath, path);
35301
+ published = true;
35302
+ syncDirectory(dirname3(path));
35303
+ const staged = lstatSync3(stagingPath);
35304
+ if (!staged.isFile() || staged.dev !== stat.dev || staged.ino !== stat.ino) {
35305
+ throw new SkillSelectionError("SESSION_WRITE_LOCK_CHANGED", "The initialized lock staging inode changed before publication completed.");
35306
+ }
35307
+ unlinkSync(stagingPath);
35308
+ syncDirectory(dirname3(path));
35309
+ handedOff = true;
35310
+ return { path, stagingPath, fd, dev: stat.dev, ino: stat.ino };
35311
+ } finally {
35312
+ if (!handedOff) {
35313
+ try {
35314
+ for (const target of [...published ? [path] : [], stagingPath]) {
35315
+ const current = lstatSync3(target, { throwIfNoEntry: false });
35316
+ if (current?.isFile() && current.dev === stat.dev && current.ino === stat.ino)
35317
+ unlinkSync(target);
35318
+ }
35319
+ } finally {
35320
+ closeSync5(fd);
35321
+ }
35322
+ }
35323
+ }
35280
35324
  }
35281
35325
  function writerIsGone(lock, explicitLocalHost) {
35282
35326
  if (Date.now() - lock.mtimeMs < SESSION_LOCK_MIN_AGE_MS)
@@ -35338,10 +35382,21 @@ function archiveSessionLock(lock, receipt, explicitLocalHost) {
35338
35382
  try {
35339
35383
  renameSync(lock.path, archivePath);
35340
35384
  moved = true;
35385
+ const archivedPaths = [archivePath];
35386
+ if (finalLock.stagingPath) {
35387
+ const publicationPath = join7(directory, "publication.write-lock");
35388
+ renameSync(finalLock.stagingPath, publicationPath);
35389
+ archivedPaths.push(publicationPath);
35390
+ }
35341
35391
  syncDirectory(directory);
35342
35392
  syncDirectory(dirname3(lock.path));
35343
- if (sha256Hex(readRegularFile(archivePath, SESSION_LOCK_MAX_BYTES)) !== lock.sha256) {
35344
- throw new Error("Archived lock bytes changed");
35393
+ for (const path of archivedPaths) {
35394
+ const before = lstatSync3(path, { throwIfNoEntry: false });
35395
+ const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
35396
+ const after = lstatSync3(path, { throwIfNoEntry: false });
35397
+ if (!before?.isFile() || before.dev !== lock.dev || before.ino !== lock.ino || before.nlink !== archivedPaths.length || before.mtimeMs !== lock.mtimeMs || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid() || !bytes || sha256Hex(bytes) !== lock.sha256 || !after?.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.mode !== after.mode || before.uid !== after.uid || before.gid !== after.gid || before.nlink !== after.nlink) {
35398
+ throw new Error("Archived lock ownership or bytes changed");
35399
+ }
35345
35400
  }
35346
35401
  } catch (error) {
35347
35402
  if (moved)
@@ -35358,6 +35413,7 @@ function archiveSessionLock(lock, receipt, explicitLocalHost) {
35358
35413
  }
35359
35414
  function withSessionWriteLocks(sessionIds, options, action) {
35360
35415
  const operationId = randomUUID();
35416
+ const identity = localLockIdentity();
35361
35417
  const locks = [...new Set(sessionIds)].map((sessionId) => ({ sessionId, path: `${sessionReceiptPath(sessionId, options)}.write-lock` })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
35362
35418
  const owned = [];
35363
35419
  const assertOwned = () => {
@@ -35371,9 +35427,10 @@ function withSessionWriteLocks(sessionIds, options, action) {
35371
35427
  try {
35372
35428
  for (const lock of locks) {
35373
35429
  assertRegularPath(lock.path, true);
35374
- let fd;
35430
+ const marker = { schemaVersion: identity ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity };
35431
+ let acquired;
35375
35432
  try {
35376
- fd = openSync5(lock.path, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
35433
+ acquired = publishSessionLock(lock.path, marker);
35377
35434
  } catch (error) {
35378
35435
  if (error.code !== "EEXIST")
35379
35436
  throw error;
@@ -35388,7 +35445,7 @@ function withSessionWriteLocks(sessionIds, options, action) {
35388
35445
  throw recoveryError;
35389
35446
  }
35390
35447
  try {
35391
- fd = openSync5(lock.path, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
35448
+ acquired = publishSessionLock(lock.path, marker);
35392
35449
  } catch (retryError) {
35393
35450
  if (retryError.code === "EEXIST") {
35394
35451
  if (!readSessionLock(lock.sessionId, options))
@@ -35398,19 +35455,20 @@ function withSessionWriteLocks(sessionIds, options, action) {
35398
35455
  throw retryError;
35399
35456
  }
35400
35457
  }
35401
- const stat = fstatSync5(fd);
35402
- owned.push({ path: lock.path, fd, dev: stat.dev, ino: stat.ino });
35403
- const identity = localLockIdentity();
35404
- writeFileSync2(fd, `${JSON.stringify({ schemaVersion: identity ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity })}
35405
- `);
35458
+ owned.push(acquired);
35406
35459
  }
35407
35460
  return action(assertOwned);
35408
35461
  } finally {
35409
35462
  for (const lock of owned.reverse()) {
35410
- closeSync5(lock.fd);
35411
- const current = lstatSync3(lock.path, { throwIfNoEntry: false });
35412
- if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
35413
- unlinkSync(lock.path);
35463
+ try {
35464
+ for (const path of [lock.path, lock.stagingPath]) {
35465
+ const current = lstatSync3(path, { throwIfNoEntry: false });
35466
+ if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
35467
+ unlinkSync(path);
35468
+ }
35469
+ } finally {
35470
+ closeSync5(lock.fd);
35471
+ }
35414
35472
  }
35415
35473
  }
35416
35474
  }
@@ -38451,6 +38509,29 @@ function mcpControls(path, read) {
38451
38509
  refuse3();
38452
38510
  return sha2562;
38453
38511
  }
38512
+ function isBundledCleanupHooks(root, namespace, value) {
38513
+ const object3 = (v) => Boolean(v && typeof v === "object" && !Array.isArray(v));
38514
+ const exactKeys2 = (v, keys2) => Object.keys(v).length === keys2.length && keys2.every((key) => Object.hasOwn(v, key));
38515
+ if (basename3(dirname5(root)) !== namespace || basename3(dirname5(dirname5(root))) !== "openai-bundled")
38516
+ return false;
38517
+ const server = namespace === "unified-computer-use" ? "cua_repl" : ["browser", "chrome", "chrome-dev", "chrome-internal", "computer-use"].includes(namespace) ? "node_repl" : undefined;
38518
+ if (!server || !object3(value) || !exactKeys2(value, ["hooks"]) || !object3(value.hooks))
38519
+ return false;
38520
+ const events = Object.entries(value.hooks);
38521
+ if (!events.length || events.length > 3)
38522
+ return false;
38523
+ for (const [event, groups] of events) {
38524
+ if (!["Interrupt", "SubagentStop", "Stop"].includes(event) || !Array.isArray(groups) || groups.length !== 1)
38525
+ return false;
38526
+ const group = groups[0];
38527
+ if (!object3(group) || !exactKeys2(group, ["hooks"]) || !Array.isArray(group.hooks) || group.hooks.length !== 1)
38528
+ return false;
38529
+ const handler = group.hooks[0];
38530
+ if (!object3(handler) || !exactKeys2(handler, ["type", "server", "tool", "input"]) || handler.type !== "mcp_tool" || handler.server !== server || handler.tool !== "turn_ended" || !object3(handler.input) || !exactKeys2(handler.input, ["hook_event_name", "session_id", "turn_id"]) || handler.input.hook_event_name !== "${hook_event_name}" || handler.input.turn_id !== "${turn_id}" || handler.input.session_id !== (event === "SubagentStop" ? "${agent_id}" : "${session_id}"))
38531
+ return false;
38532
+ }
38533
+ return true;
38534
+ }
38454
38535
  function rootControls(root, read) {
38455
38536
  const manifestPath = join10(root, ".codex-plugin/plugin.json"), manifestFile = lstatSync6(manifestPath, { throwIfNoEntry: false });
38456
38537
  if (!lstatSync6(join10(root, ".codex-plugin"), { throwIfNoEntry: false })?.isDirectory() || !manifestFile?.isFile() || manifestFile.size > 1024 * 1024)
@@ -38468,9 +38549,11 @@ function rootControls(root, read) {
38468
38549
  const cacheVersion = basename3(root), declaredVersion = manifest.version === undefined ? "" : typeof manifest.version === "string" ? manifest.version.trim() : null;
38469
38550
  if (declaredVersion === null || (declaredVersion ? declaredVersion !== cacheVersion : cacheVersion !== CODEX_DEFAULT_AGENT_PLUGIN_VERSION))
38470
38551
  refuse3();
38471
- if (manifest.hooks !== undefined || manifest.commands !== undefined || manifest.mcpServers !== undefined && manifest.mcpServers !== "./.mcp.json" || manifest.apps !== undefined && manifest.apps !== "./.app.json" || lstatSync6(join10(root, "hooks"), { throwIfNoEntry: false }))
38552
+ if (manifest.hooks !== undefined && !isBundledCleanupHooks(root, manifest.name, manifest.hooks) || manifest.commands !== undefined || manifest.mcpServers !== undefined && manifest.mcpServers !== "./.mcp.json" || manifest.apps !== undefined && manifest.apps !== "./.app.json" || lstatSync6(join10(root, "hooks"), { throwIfNoEntry: false }))
38472
38553
  refuse3();
38473
38554
  const mcp = join10(root, ".mcp.json"), mcpPresent = lstatSync6(mcp, { throwIfNoEntry: false });
38555
+ if (manifest.hooks !== undefined && (manifest.mcpServers !== undefined || mcpPresent))
38556
+ refuse3();
38474
38557
  if (mcpPresent && (!mcpPresent.isFile() || mcpPresent.isSymbolicLink() || mcpPresent.size > 1024 * 1024 || realpathSync5(mcp) !== mcp))
38475
38558
  refuse3();
38476
38559
  if (Boolean(mcpPresent) !== (manifest.mcpServers !== undefined))
@@ -39090,7 +39173,7 @@ var NATIVE_SKILL_ROOTS = [
39090
39173
  // src/lib/agent-integration.ts
39091
39174
  import { createHash as createHash18, randomUUID as randomUUID3 } from "crypto";
39092
39175
  import { isDeepStrictEqual as isDeepStrictEqual6 } from "util";
39093
- import { existsSync as existsSync7, lstatSync as lstatSync20, statSync as statSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync14, readdirSync as readdirSync8, opendirSync as opendirSync5, readlinkSync as readlinkSync4, realpathSync as realpathSync8, renameSync as renameSync3, rmdirSync as rmdirSync3, writeFileSync as writeFileSync4, unlinkSync as unlinkSync4, chmodSync, openSync as openSync16, closeSync as closeSync16, fsyncSync as fsyncSync4, fstatSync as fstatSync16, readSync as readSync11, constants as constants12, linkSync as linkSync2 } from "fs";
39176
+ import { existsSync as existsSync7, lstatSync as lstatSync20, statSync as statSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync14, readdirSync as readdirSync8, opendirSync as opendirSync5, readlinkSync as readlinkSync4, realpathSync as realpathSync8, renameSync as renameSync3, rmdirSync as rmdirSync3, writeFileSync as writeFileSync4, unlinkSync as unlinkSync4, chmodSync, openSync as openSync16, closeSync as closeSync16, fsyncSync as fsyncSync4, fstatSync as fstatSync16, readSync as readSync11, constants as constants12, linkSync as linkSync3 } from "fs";
39094
39177
  import { dirname as dirname15, join as join23, resolve as resolve16, relative as relative4, isAbsolute as isAbsolute17, sep as sep5 } from "path";
39095
39178
  import { homedir as homedir3 } from "os";
39096
39179
 
@@ -40091,7 +40174,7 @@ function buildPluginProjection(entries) {
40091
40174
 
40092
40175
  // src/lib/plugin-projection-store.ts
40093
40176
  import { createHash as createHash13, randomUUID as randomUUID2 } from "crypto";
40094
- import { constants as constants8, closeSync as closeSync12, fchmodSync, fstatSync as fstatSync12, fsyncSync as fsyncSync2, linkSync, lstatSync as lstatSync13, mkdirSync as mkdirSync4, openSync as openSync12, opendirSync as opendirSync3, readSync as readSync7, renameSync as renameSync2, rmdirSync as rmdirSync2, rmSync, unlinkSync as unlinkSync3, writeSync as writeSync2 } from "fs";
40177
+ import { constants as constants8, closeSync as closeSync12, fchmodSync, fstatSync as fstatSync12, fsyncSync as fsyncSync2, linkSync as linkSync2, lstatSync as lstatSync13, mkdirSync as mkdirSync4, openSync as openSync12, opendirSync as opendirSync3, readSync as readSync7, renameSync as renameSync2, rmdirSync as rmdirSync2, rmSync, unlinkSync as unlinkSync3, writeSync as writeSync2 } from "fs";
40095
40178
  import { dirname as dirname10, isAbsolute as isAbsolute11, join as join16, parse as parse5, resolve as resolve10 } from "path";
40096
40179
  var READ_FLAGS = constants8.O_RDONLY | constants8.O_NOFOLLOW | constants8.O_NONBLOCK;
40097
40180
  var WRITE_FLAGS = constants8.O_WRONLY | constants8.O_CREAT | constants8.O_EXCL | constants8.O_NOFOLLOW | constants8.O_NONBLOCK;
@@ -40407,7 +40490,7 @@ function writePluginJsonImmutable(path, value) {
40407
40490
  written = stat(temporary);
40408
40491
  verifyAncestors(chain, true);
40409
40492
  try {
40410
- linkSync(temporary, path);
40493
+ linkSync2(temporary, path);
40411
40494
  } catch (error) {
40412
40495
  if (!(error && typeof error === "object" && ("code" in error) && error.code === "EEXIST"))
40413
40496
  throw error;
@@ -42051,13 +42134,13 @@ function resolveAgentDiscovery(options) {
42051
42134
  unresolved("JSONC configuration requires a supported format adapter");
42052
42135
  for (const root2 of [directory, join22(home, ".claude"), join22(home, ".agents")])
42053
42136
  for (const name of ["skill", "skills"])
42054
- roots.add(join22(root2, name));
42137
+ roots.add(canonical6(join22(root2, name)));
42055
42138
  if (config.skills !== undefined && (!Array.isArray(config.skills) || config.skills.some((path) => typeof path !== "string")))
42056
42139
  unresolved("skills must be a string array");
42057
42140
  for (const value of config.skills ?? []) {
42058
42141
  if (typeof value !== "string" || /[\0$]/.test(value) || !value || !(isAbsolute16(value) || value.startsWith("~/")))
42059
42142
  unresolved("remote or relative skill sources need a dedicated discovery adapter");
42060
- roots.add(value.startsWith("~/") ? join22(home, value.slice(2)) : value);
42143
+ roots.add(canonical6(value.startsWith("~/") ? join22(home, value.slice(2)) : value));
42061
42144
  }
42062
42145
  for (const key of ["plugin", "plugins"])
42063
42146
  if (config[key] !== undefined && !Array.isArray(config[key]))
@@ -43695,7 +43778,7 @@ function archiveNativeSkills(inventory, options) {
43695
43778
  continue;
43696
43779
  }
43697
43780
  if (entry.discoveryOnly) {
43698
- linkSync2(entry.archive, entry.source);
43781
+ linkSync3(entry.archive, entry.source);
43699
43782
  syncArchiveDirectory(dirname15(entry.source));
43700
43783
  unlinkSync4(entry.archive);
43701
43784
  } else {
@@ -364,3 +364,24 @@ their exact witnesses and trust state; they must still pass their own consumer
364
364
  checks before use. The plan's `managedAgentChecks.agents` and `nativeSkills`
365
365
  describe this scoped runtime check, not a fleet health result. A path-identity
366
366
  witness intersecting the write still refuses instead of adopting a new inode.
367
+
368
+ ## Codex bundled cleanup controls
369
+
370
+ Qualified native skill review supports a narrow subset of Codex 0.159.2 and
371
+ 0.160.0's bundled cleanup hooks. The identities `browser`, `chrome`, `chrome-dev`,
372
+ `chrome-internal` and `computer-use` in `openai-bundled` may call
373
+ `node_repl.turn_ended`; `unified-computer-use` may call `cua_repl.turn_ended`.
374
+ Only `Interrupt`, `SubagentStop` and `Stop` are accepted, with one group and one
375
+ MCP handler per event. Inputs are the exact native event, turn and session
376
+ placeholders; `SubagentStop` uses the child `agent_id` for its session value.
377
+ Commands, matchers, other arguments, external hook files and a plugin MCP
378
+ declaration alongside these hooks remain refused. Apps cleanup routing requires
379
+ connector identity from a native tool catalog and is not admitted by this path.
380
+
381
+ The complete inline hooks stay in the manifest fingerprint across cache versions,
382
+ including after all native skill bodies disappear. A changed hook fails guarded
383
+ apply and subsequent discovery checks. This review preserves native cleanup;
384
+ it neither enables plugins nor grants hook trust, browser authority or signing.
385
+ The upstream contracts are
386
+ [0.159.2 bundled hooks](https://github.com/openai/codex/blob/ff6aec96948b70d94983af2641a6b67c94faeff5/codex-rs/plugin/src/bundled_hooks.rs)
387
+ and [0.160.0 bundled hooks](https://github.com/openai/codex/blob/a956835d020762cb2b570053af06f643a11c0ecc/codex-rs/plugin/src/bundled_hooks.rs).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/skills",
3
- "version": "0.10.30",
3
+ "version": "0.10.33",
4
4
  "description": "Skills library for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {