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.
@@ -59,6 +59,77 @@ export class SkillTreeError extends Error {
59
59
  this.name = "SkillTreeError";
60
60
  }
61
61
  }
62
+ /** A version 2 skill tree fingerprint as it is stored. */
63
+ export const SKILL_TREE_FINGERPRINT_PATTERN = /^v2:[0-9a-f]{64}$/;
64
+ /** A version 1 fingerprint as 0.8.0 stored it: bare SHA-256 hex. */
65
+ export const LEGACY_SKILL_TREE_FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
66
+ const FINGERPRINT_DOMAIN = "pi-session-mcp skill tree v2\n";
67
+ function uint32(value) {
68
+ const bytes = Buffer.alloc(4);
69
+ bytes.writeUInt32BE(value);
70
+ return bytes;
71
+ }
72
+ function uint64(value) {
73
+ const bytes = Buffer.alloc(8);
74
+ bytes.writeBigUInt64BE(BigInt(value));
75
+ return bytes;
76
+ }
77
+ /**
78
+ * Version 2: a domain and version tag, the file count, then per file in byte order of the path
79
+ * the length-prefixed UTF-8 path and the length-prefixed content. Every field has an explicit
80
+ * length, so two different trees never share an encoding.
81
+ */
82
+ export function skillTreeFingerprint(files) {
83
+ const entries = files.map((file) => ({ path: Buffer.from(file.path, "utf8"), bytes: file.bytes }))
84
+ .sort((left, right) => Buffer.compare(left.path, right.path));
85
+ const hash = createHash("sha256").update(FINGERPRINT_DOMAIN, "utf8").update(uint32(entries.length));
86
+ entries.forEach((entry, index) => {
87
+ if (index > 0 && Buffer.compare(entries[index - 1].path, entry.path) === 0)
88
+ throw new SkillTreeError("skill_tree_invalid");
89
+ hash.update(uint32(entry.path.length)).update(entry.path).update(uint64(entry.bytes.length)).update(entry.bytes);
90
+ });
91
+ return `v2:${hash.digest("hex")}`;
92
+ }
93
+ /**
94
+ * Version 1, as 0.8.0 computed it: `path NUL content NUL` per file in reading order. File
95
+ * content may contain NUL bytes, so two different trees can share this value.
96
+ */
97
+ export function legacySkillTreeFingerprint(files) {
98
+ const hash = createHash("sha256");
99
+ for (const file of files)
100
+ hash.update(file.path).update("\0").update(file.bytes).update("\0");
101
+ return hash.digest("hex");
102
+ }
103
+ /**
104
+ * The shipped skills of every release that recorded version 1 fingerprints (0.8.0 only), as
105
+ * trusted references for recognizing such records. Computed from the `v0.8.0` tag and checked
106
+ * against the published npm package.
107
+ */
108
+ export const RELEASED_SKILL_TREES = Object.freeze([
109
+ Object.freeze({ name: "pi-session-orchestration", release: "0.8.0", legacyFingerprint: "e2a72b558ea14748a4badd0058ff65b8731cc125033a666706b103842784673a", fingerprint: "v2:39edc4c4783dec7f90345b22844a17e5e48b465722bb0f1b20059d6ee51e1523" }),
110
+ Object.freeze({ name: "pi-session-implementation", release: "0.8.0", legacyFingerprint: "5f719b44da266d3f66dd5bf3c18f3e3e04ca7df357e2fe2eedbf0409e6c1bf64", fingerprint: "v2:f8ce75eff83a5c952f6ca51c04e1e8025cb7c2c558c30d692f291a3f6594eb12" }),
111
+ Object.freeze({ name: "pi-session-review", release: "0.8.0", legacyFingerprint: "8cf58ccb8d473a3eedf12ce6b902b1e91dd9e2fef83ad110c4d8e2e6164cb257", fingerprint: "v2:610c0b4829eeb1ce6c9b2000ad73f8e88ca230707eef7f7b1708a80767952bf0" }),
112
+ ]);
113
+ /**
114
+ * Whether a version 1 fingerprint from a record proves that `candidate` is the tree it names:
115
+ * a trusted reference, a released shipped tree of this name or one of `references` (such as
116
+ * the current library tree), has exactly this version 1 fingerprint and is byte-identical to
117
+ * `candidate` by its version 2 fingerprint. The version 1 fingerprint alone never suffices.
118
+ */
119
+ export function provesLegacyFingerprint(name, legacyFingerprint, candidate, references = [], released = RELEASED_SKILL_TREES) {
120
+ if (!LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(legacyFingerprint) || !SKILL_TREE_FINGERPRINT_PATTERN.test(candidate.fingerprint))
121
+ return false;
122
+ const trusted = [...released.filter((tree) => tree.name === name), ...references];
123
+ return trusted.some((tree) => tree.legacyFingerprint === legacyFingerprint && tree.fingerprint === candidate.fingerprint);
124
+ }
125
+ /**
126
+ * The version 2 fingerprint of the released shipped skill that a version 1 fingerprint names,
127
+ * or undefined. Only released trees qualify: the current shipped tree could share a version 1
128
+ * fingerprint with an older original without being that original.
129
+ */
130
+ export function releasedSkillFingerprint(name, legacyFingerprint, released = RELEASED_SKILL_TREES) {
131
+ return released.find((tree) => tree.name === name && tree.legacyFingerprint === legacyFingerprint)?.fingerprint;
132
+ }
62
133
  /**
63
134
  * Reads the regular files below a skill directory in a stable order. Entries whose name starts
64
135
  * with `.` are skipped or rejected (see options). A symbolic link or special file inside the
@@ -113,8 +184,5 @@ export async function readSkillTree(directory, options = {}) {
113
184
  await visit(root, "", 0);
114
185
  if (!files.some((file) => file.path === "SKILL.md"))
115
186
  throw new SkillTreeError("skill_tree_missing");
116
- const hash = createHash("sha256");
117
- for (const file of files)
118
- hash.update(file.path).update("\0").update(file.bytes).update("\0");
119
- return Object.freeze({ files: Object.freeze(files), fingerprint: hash.digest("hex") });
187
+ return Object.freeze({ files: Object.freeze(files), fingerprint: skillTreeFingerprint(files), legacyFingerprint: legacySkillTreeFingerprint(files) });
120
188
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,97 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.1 — 2026-10-12
6
+
7
+ - `updatedAt` of a running turn now follows public SDK activity. It is the time
8
+ of the last observed assistant message start, update or end and tool
9
+ execution start, update or end of that turn, and freezes at the terminal
10
+ transition. Before, it advanced only after a completed tool execution or a
11
+ completed assistant message, so a turn of an extension provider with its own
12
+ agent loop, such as `pi-cursor-sdk`, kept `updatedAt == startedAt` while it
13
+ streamed. Streaming is activity, not evidence of finished or useful work, and
14
+ work without a public event stays invisible, so an unchanged `updatedAt` is
15
+ still no proof of a hang. No event content is stored or projected, and the
16
+ answer still comes only from the final assistant message. See [tool
17
+ contracts](docs/tool-contracts.md). Issue #47.
18
+ - Skill tree fingerprints are now version 2 (`v2:` and 64 hex digits), an
19
+ unambiguous encoding with explicit lengths. Version 1 of 0.8.0 hashed
20
+ `path NUL content NUL` and could give two different trees the same value,
21
+ because content may contain NUL bytes. A version 1 value never proves
22
+ identity alone: it counts only when a trusted reference (the shipped skills of
23
+ 0.8.0, or for setup's installed copies the current library tree) has that
24
+ value and is byte-identical to the tree in question. Issue #51.
25
+ - Skill customization is now serialized across processes. `--apply
26
+ --customize-skill` takes an exclusive kernel lock on
27
+ `<override dir>/.pi-session-mcp-bases.lock`, re-reads the bases file under the
28
+ lock and replaces it atomically, so two runs for different skills no longer
29
+ lose a base. The wait is bounded at 30 seconds. New codes:
30
+ `skill_customize_lock_busy` and `skill_customize_lock_unavailable`. The
31
+ dry-run takes no lock and writes nothing. The bases file is now schema version
32
+ 2; version 1 files are still read, and every write migrates each legacy value
33
+ that names a released 0.8.0 shipped skill. New codes for a legacy base:
34
+ `skill_base_migration_planned` and `skill_base_migrated` (it names the current
35
+ shipped skill), and `skill_base_unverified` (it names no released skill; the
36
+ Doctor warning `skill_override_outdated` says that the base cannot be
37
+ confirmed). `skill_customize_planned` and `skill_customized` now also complete
38
+ a customization whose bases commit failed. See [Pi
39
+ skills](docs/skills.md#concurrent-runs-and-the-override-lock). Issue #50.
40
+ - Setup's installed skill copies. Ownership records of skill copies now hold
41
+ version 2 fingerprints. A record that 0.8.0 wrote is migrated only when a
42
+ trusted reference proves the copy's exact bytes; otherwise dry-run, apply,
43
+ remove and rollback report the new code `skill_legacy_unverified` and change
44
+ neither the copy nor the record. Updates now name their intermediate
45
+ directories after the pending record's transaction id
46
+ (`.pi-session-mcp-<skill>-<transactionId>.staging`, `.backup`, `.trash`), and
47
+ `--apply`, `--remove` and `--rollback` first settle an interrupted update.
48
+ Anything modified or foreign stays and is reported as the new code
49
+ `skill_recovery_retained`. A failed update leaves its staging directory until
50
+ the next run settles it. Directories of 0.8.0 updates are never searched for
51
+ or deleted. When `--apply` settled an interrupted update and needs no further
52
+ update it reports the new code `skill_recovered`, and a dry-run of a pending
53
+ update reports `skill_recovery_planned`. Issues #51 and #52.
54
+ - Upgrading from 0.8.0: run `pi-session-mcp-setup` as a dry-run and then with
55
+ `--apply`. Unchanged copies of the 0.8.0 controller skill are proven against
56
+ the 0.8.0 table and migrated; others stay and report
57
+ `skill_legacy_unverified`. Doctor resolves 0.8.0 bases against the same table,
58
+ and the next `--apply --customize-skill` rewrites the bases file as version 2.
59
+ 0.8.0 rejects the new formats as corrupt (the bases file, and the whole
60
+ ownership file once it holds a version 2 skill record), so it never acts on
61
+ them. See [Upgrade from 0.8.0](docs/skills.md#upgrade-from-080) and
62
+ [Downgrade](docs/skills.md#downgrade).
63
+ - The orchestration skill now says that a successful `pi_session_start` does not
64
+ prove that the provider accepts requests, asks for a bounded observation and
65
+ cleanup budget after `abort_timeout`, separates start errors that need the
66
+ operator from ones that can be transient, and describes `updatedAt` as the
67
+ last observed public SDK activity. `docs/acceptance.md` records the v0.8.0
68
+ post-release acceptance and names the method limits of earlier multi-session
69
+ records. Issue #54.
70
+ - The multi-session acceptance harness (`scripts/multi-session-acceptance.mjs`,
71
+ not in the npm package) now ends a run within validated time bounds and
72
+ terminates only processes that provably belong to it (PID and start time). It
73
+ checks scope against a baseline stored outside the worker workspace (working
74
+ tree, index and HEAD, so committed changes count) and adds its own business
75
+ checks to the fixture tests, with Git pinned to the fixture and hooks,
76
+ `fsmonitor` and replace objects switched off. It binds the build, the
77
+ controller and worker skills, the override directory, the configuration, the
78
+ fixture baselines, itself and the operator's own `pi-session-orchestration`
79
+ copies in `~/.claude/skills` and `~/.agents/skills` at `prepare` and rechecks
80
+ them before launch and after the run; a Codex run is refused while the home
81
+ copy differs from the candidate's skill. It records each launch before the
82
+ controller starts, writes each original result once, records reanalyses
83
+ separately, and lists every launch in the evidence, marking one without a
84
+ result. A failed analysis, or a signal to the harness during it, is recorded
85
+ as `analysisFailed`, never passes, still terminates the run's processes and
86
+ makes the CLI exit with 1. `prepare --with-extensions` copies the exact
87
+ extension allowlist of a selected profile for extension providers such as
88
+ Cursor. Each session reports whether a polled running turn showed
89
+ `updatedAt` beyond `startedAt`. See
90
+ [Acceptance](docs/acceptance.md#multi-session-skill-acceptance). Issue #53.
91
+ - A release now travels in the pull request of the change it ships, with the
92
+ version, the changelog heading and the pre-release acceptance record; records
93
+ that need the published release follow with the next change. See
94
+ [Releasing](docs/releasing.md#prepare-a-release).
95
+
5
96
  ## 0.8.0 — 2026-10-12
6
97
 
7
98
  This is a minor release because the public contract changes, as ADR 0003
package/README.md CHANGED
@@ -30,7 +30,7 @@ It is not the right tool when:
30
30
 
31
31
  ## Status
32
32
 
33
- The package version is **0.8.0**.
33
+ The package version is **0.8.1**.
34
34
 
35
35
  - **Pre-1.0.** The public tool contract is stable within a minor line: a change to tools, fields, states or error codes needs a new minor release.
36
36
  - **Linux and macOS**, Node.js 22.19.0 or newer. macOS is tested in CI on macOS 15 and 26 on Apple silicon; Intel Macs run the same code but are untested. Setup acceptance on a real Mac is recorded for Claude Code only; Codex is still pending. See [Acceptance](docs/acceptance.md#status). On other platforms `setup` refuses with `platform_unsupported`.
package/dist/doctor.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type AppConfig } from "./config.js";
2
2
  import { type ExtensionSpec } from "./extension-spec.js";
3
+ import { type ReleasedSkillTree } from "./skill-library.js";
3
4
  export type DoctorSeverity = "ok" | "warning" | "error";
4
5
  export type DoctorExitCode = 0 | 1 | 2 | 64;
5
6
  export type DoctorCheckId = "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";
@@ -44,6 +45,8 @@ export interface DoctorDependencies {
44
45
  readonly packageMetadata?: () => Promise<DoctorPackageMetadata>;
45
46
  /** Reads the installed manifest of a pinned extension; never imports extension code. */
46
47
  readonly resolveExtension?: (extension: ExtensionSpec) => Promise<string>;
48
+ /** Reference trees that resolve legacy skill bases; defaults to the released shipped skills. */
49
+ readonly releasedSkillTrees?: readonly ReleasedSkillTree[];
47
50
  }
48
51
  export declare function runDoctor(dependencies?: DoctorDependencies): Promise<DoctorResult>;
49
52
  export declare function renderDoctorJson(result: DoctorResult): string;
package/dist/doctor.js CHANGED
@@ -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,34 @@
1
+ import { type StatfsReader } from "./setup-platform.js";
2
+ /**
3
+ * The kernel lock that serializes every read-modify-write of the skill bases file across
4
+ * processes. It lives on a stable lock file, never on the bases file: that file is replaced by a
5
+ * rename, which would move the lock onto an unlinked inode. The lock file is never deleted, and a
6
+ * holder that dies releases the lock through the kernel.
7
+ */
8
+ export declare const SKILL_BASES_LOCK_FILE = ".pi-session-mcp-bases.lock";
9
+ export declare const DEFAULT_OVERRIDE_LOCK_TIMEOUT_MS = 30000;
10
+ export declare class OverrideLockError extends Error {
11
+ readonly code: "override_lock_busy" | "override_lock_unavailable";
12
+ constructor(code: "override_lock_busy" | "override_lock_unavailable");
13
+ }
14
+ export interface OverrideLockOptions {
15
+ /** Acquisition bound; a busy lock after this fails with `override_lock_busy`. */
16
+ readonly timeoutMs?: number;
17
+ readonly signal?: AbortSignal;
18
+ /** Test seams for the platform gate; the defaults describe this process. */
19
+ readonly platform?: NodeJS.Platform;
20
+ readonly flockCandidates?: readonly string[];
21
+ readonly isTrustedBinary?: (path: string) => boolean;
22
+ readonly readStatfs?: StatfsReader;
23
+ }
24
+ export interface OverrideLock {
25
+ /** Closes the descriptor that holds the lock; safe to call more than once. */
26
+ release(): Promise<void>;
27
+ }
28
+ /**
29
+ * Takes the exclusive lock for one override directory, which must exist. Fails closed: an
30
+ * unsupported platform, a missing trusted `flock`, an unsupported filesystem, a link or non-file
31
+ * at the lock path and a descriptor that no longer names the path all end in
32
+ * `override_lock_unavailable`.
33
+ */
34
+ export declare function acquireOverrideLock(overrideDirectory: string, options?: OverrideLockOptions): Promise<OverrideLock>;
@@ -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
+ }
@@ -23,10 +23,12 @@ export interface PiSessionHandle {
23
23
  /** Only whitelisted, post-verified selection metadata may cross the adapter boundary. */
24
24
  readonly appliedSelection: PiAppliedSelection;
25
25
  /**
26
- * `onActivity` is an optional content-free liveness callback, invoked after a completed
27
- * tool execution and after a completed assistant message. It receives no event payload
28
- * and must only be used for timestamping; it never carries tool names, arguments,
29
- * results, text, paths, or credentials.
26
+ * `onActivity` is an optional content-free liveness callback, invoked on observed public
27
+ * SDK activity of the running prompt: assistant message start, update and end, and tool
28
+ * execution start, update and end. Streaming is activity, not evidence of finished or
29
+ * useful work, and internal work without a public event stays invisible. It receives no
30
+ * event payload and must only be used for timestamping; it never carries tool names,
31
+ * arguments, results, text, paths, or credentials.
30
32
  */
31
33
  prompt(text: string, preflight: (accepted: boolean) => void, onActivity?: () => void): Promise<PiTurnOutcome>;
32
34
  abort(): Promise<void>;
@@ -192,12 +192,31 @@ class SdkHandle {
192
192
  }
193
193
  catch { /* Liveness reporting is best effort and content-free. */ }
194
194
  };
195
+ // Activity is the public SDK stream of this prompt: assistant message start, update and end, and
196
+ // tool execution start, update and end. Only the event discriminant and the message role are
197
+ // inspected: delta text, thinking, tool arguments and partial results are never read, stored or
198
+ // forwarded. Streaming is activity, not evidence of finished work; only the last assistant
199
+ // `message_end` is the answer, and the terminal state comes from the completion paths below.
195
200
  const unsubscribe = this.session.subscribe((event) => {
196
- if (event.type === "tool_execution_end")
197
- reportActivity();
198
- else if (event.type === "message_end" && isAssistantMessage(event.message)) {
199
- finalAssistant = event.message;
200
- reportActivity();
201
+ switch (event.type) {
202
+ case "tool_execution_start":
203
+ case "tool_execution_update":
204
+ case "tool_execution_end":
205
+ reportActivity();
206
+ break;
207
+ case "message_start":
208
+ case "message_update":
209
+ if (hasAssistantRole(event.message))
210
+ reportActivity();
211
+ break;
212
+ case "message_end":
213
+ if (isAssistantMessage(event.message)) {
214
+ finalAssistant = event.message;
215
+ reportActivity();
216
+ }
217
+ break;
218
+ default:
219
+ break;
201
220
  }
202
221
  });
203
222
  let preflightSeen = false;
@@ -264,6 +283,10 @@ function isAssistantMessage(message) {
264
283
  const candidate = message;
265
284
  return candidate.role === "assistant" && Array.isArray(candidate.content);
266
285
  }
286
+ /** Role check only: streaming messages are never inspected for content. */
287
+ function hasAssistantRole(message) {
288
+ return typeof message === "object" && message !== null && message.role === "assistant";
289
+ }
267
290
  function isTextBlock(block) {
268
291
  if (!block || typeof block !== "object")
269
292
  return false;
@@ -16,10 +16,11 @@ export interface SessionView {
16
16
  lastError?: string;
17
17
  }
18
18
  /**
19
- * `updatedAt` is the registry's own last-activity stamp: while a turn runs it advances on a
20
- * completed tool execution or completed assistant message, then freezes at the terminal
21
- * transition. It is a liveness lower bound, not a progress count, and does not prove that a
22
- * silent turn is healthy.
19
+ * `updatedAt` is the time of the last observed public SDK activity of the turn (assistant
20
+ * message start, update and end; tool execution start, update and end), then freezes at the
21
+ * terminal transition. Streaming is activity, not evidence of finished or useful work, and
22
+ * internal work without a public event stays invisible. It is a liveness lower bound, not a
23
+ * progress count, and does not prove that a silent turn is healthy.
23
24
  */
24
25
  export interface TurnView {
25
26
  turnId: string;
@@ -81,8 +82,9 @@ export declare class SessionRegistry {
81
82
  private completeTurn;
82
83
  private failTurn;
83
84
  /**
84
- * Liveness only. A completed tool execution or assistant message advances this turn,
85
- * never another turn or the session. Activity after accepted preflight but before
85
+ * Liveness only. Observed public SDK activity (assistant message start, update and end; tool
86
+ * execution start, update and end) advances this turn, never another turn or the session.
87
+ * Streaming is activity, not evidence of finished or useful work. Activity after accepted preflight but before
86
88
  * publication is stamped and applied only if the turn is published; callbacks before
87
89
  * acceptance or after terminal cleanup cannot publish or revive a turn.
88
90
  */
@@ -273,8 +273,9 @@ export class SessionRegistry {
273
273
  entry.turn = undefined; if (entry.turnPromise)
274
274
  entry.turnPromise = undefined; entry.state = "failed"; entry.updatedAt = turn.updatedAt; entry.lastError = "Pi turn failed"; }
275
275
  /**
276
- * Liveness only. A completed tool execution or assistant message advances this turn,
277
- * never another turn or the session. Activity after accepted preflight but before
276
+ * Liveness only. Observed public SDK activity (assistant message start, update and end; tool
277
+ * execution start, update and end) advances this turn, never another turn or the session.
278
+ * Streaming is activity, not evidence of finished or useful work. Activity after accepted preflight but before
278
279
  * publication is stamped and applied only if the turn is published; callbacks before
279
280
  * acceptance or after terminal cleanup cannot publish or revive a turn.
280
281
  */
@@ -8,7 +8,10 @@ export interface OwnershipRecord {
8
8
  readonly transactionId: string;
9
9
  /**
10
10
  * Skill records only, while pending: the fingerprint of setup's own copy that the pending
11
- * update replaces, so an interrupted update can still be completed or removed.
11
+ * update replaces, so an interrupted update can still be completed or removed. The
12
+ * transaction id also names the update's intermediate directories, so recovery needs no path.
13
+ * Skill fingerprints are `v2:` values; a bare 64-hex value is a record that 0.8.0 wrote and
14
+ * names a tree only once a trusted reference proves it.
12
15
  */
13
16
  readonly replaces?: string;
14
17
  }
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { dirname } from "node:path";
5
5
  import { acquireSetupGuardian } from "./setup-process.js";
6
6
  import { assertSupportedFilesystem, currentSetupPlatform, openLockedDarwin } from "./setup-platform.js";
7
- import { SKILL_NAME_PATTERN } from "./skill-library.js";
7
+ import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN } from "./skill-library.js";
8
8
  /** Safe default for one process; callers needing recovery inject a durable store. */
9
9
  export class MemoryOwnershipStore {
10
10
  entries = new Map();
@@ -231,10 +231,13 @@ export function isRecord(value) {
231
231
  const skill = skillAlias !== null && SKILL_NAME_PATTERN.test(skillAlias[1]) &&
232
232
  (client === "codex" ? scope === "user" && skillAlias[2] === undefined
233
233
  : client === "claude-code" && ((scope === "user" && skillAlias[2] === undefined) || (scope === "project" && skillAlias[2] !== undefined)));
234
+ // A registration fingerprint is a snapshot hash; a skill fingerprint is a version 2 tree value or a 0.8.0 legacy one.
235
+ const validFingerprint = (value) => typeof value === "string" &&
236
+ (skill ? SKILL_TREE_FINGERPRINT_PATTERN.test(value) || LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) : /^[a-f0-9]{64}$/.test(value));
234
237
  // `replaces` belongs only to a pending skill record.
235
- if ("replaces" in x && !(skill && x.phase === "pending" && typeof x.replaces === "string" && /^[a-f0-9]{64}$/.test(x.replaces)))
238
+ if ("replaces" in x && !(skill && x.phase === "pending" && validFingerprint(x.replaces)))
236
239
  return false;
237
- return (registration || skill) && typeof x.fingerprint === "string" && /^[a-f0-9]{64}$/.test(x.fingerprint) &&
240
+ return (registration || skill) && validFingerprint(x.fingerprint) &&
238
241
  (x.phase === "pending" || x.phase === "owned") && x.previous === "absent" &&
239
242
  typeof x.transactionId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(x.transactionId);
240
243
  }
@@ -1,3 +1,4 @@
1
+ import { type ReleasedSkillTree } from "./skill-library.js";
1
2
  import type { OwnershipAccess } from "./setup-ownership.js";
2
3
  import type { SetupFinding, SetupOperation, SetupTarget } from "./setup-result.js";
3
4
  export interface SetupSkillInstaller {
@@ -15,12 +16,22 @@ export interface SetupSkillRoots {
15
16
  readonly home: string;
16
17
  readonly cwd: string;
17
18
  }
19
+ /** The crash-relevant points of an update, in order. Only a test seam observes them. */
20
+ export type SkillCheckpoint = "staged" | "backup-renamed" | "installed" | "trash-renamed" | "trash-entry-removed" | "trash-deleted" | "before-ownership";
21
+ export interface SetupSkillDependencies {
22
+ /** Trusted released trees that prove 0.8.0 records; defaults to the shipped table. */
23
+ readonly released?: readonly ReleasedSkillTree[];
24
+ /** Test seam, called after each step in `SkillCheckpoint` completed; defaults to a no-op. */
25
+ readonly checkpoint?: (step: SkillCheckpoint) => void | Promise<void>;
26
+ }
18
27
  /** Paths remain internal. Ownership identifies a client slot, never its location. */
19
28
  export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
20
29
  private readonly roots;
21
- constructor(roots: SetupSkillRoots);
30
+ private readonly dependencies;
31
+ constructor(roots: SetupSkillRoots, dependencies?: SetupSkillDependencies);
22
32
  run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
23
33
  owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
34
+ private step;
24
35
  /**
25
36
  * The slot path and its ownership key. A project may be untrusted: only its root is
26
37
  * canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
@@ -32,6 +43,34 @@ export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
32
43
  */
33
44
  private location;
34
45
  private library;
46
+ /**
47
+ * Whether the record names this installed tree: its version 2 value, or a 0.8.0 value that a
48
+ * trusted reference proves for exactly these bytes. A bare legacy value never proves identity.
49
+ */
50
+ private ownsTree;
35
51
  private skill;
52
+ /**
53
+ * Installs the new tree through the transaction's own intermediates. A failure leaves them for
54
+ * recovery; nothing here deletes by guess.
55
+ */
36
56
  private replace;
57
+ /**
58
+ * Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
59
+ * transaction's trash name first, so the deletion never leaves an unverifiable partial tree
60
+ * under the backup name.
61
+ */
62
+ private discardBackup;
63
+ /** Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree. */
64
+ private discardTrash;
65
+ /**
66
+ * Settles an interrupted update of the pending record's transaction, inside the ownership
67
+ * transaction. Only directories with this transaction's exact names are examined, and only
68
+ * verified ones are deleted: the backup must equal the replaced copy, the staging directory
69
+ * the target or an interrupted write of it. Anything else stays and is reported.
70
+ *
71
+ * apply, slot absent: a complete staging directory is renamed into the slot (forward);
72
+ * otherwise a verified backup is moved back. remove and rollback do not restore, they delete
73
+ * what is verified and leave the slot to the caller.
74
+ */
75
+ private recover;
37
76
  }