pi-session-mcp 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.pi-session-mcp-cli/doctor.js +14 -7
  2. package/.pi-session-mcp-cli/override-lock.js +125 -0
  3. package/.pi-session-mcp-cli/setup-platform.js +195 -0
  4. package/.pi-session-mcp-cli/skill-customize.js +131 -46
  5. package/.pi-session-mcp-cli/skill-library.js +72 -4
  6. package/CHANGELOG.md +159 -0
  7. package/CONTRIBUTING.md +5 -3
  8. package/README.md +5 -3
  9. package/dist/doctor.d.ts +3 -0
  10. package/dist/doctor.js +14 -7
  11. package/dist/override-lock.d.ts +34 -0
  12. package/dist/override-lock.js +125 -0
  13. package/dist/pi-adapter.d.ts +6 -4
  14. package/dist/sdk-pi-adapter.js +28 -5
  15. package/dist/session-registry.d.ts +8 -6
  16. package/dist/session-registry.js +3 -2
  17. package/dist/setup-ownership.d.ts +4 -1
  18. package/dist/setup-ownership.js +6 -3
  19. package/dist/setup-skills.d.ts +46 -1
  20. package/dist/setup-skills.js +349 -78
  21. package/dist/setup.js +21 -4
  22. package/dist/skill-customize.d.ts +19 -1
  23. package/dist/skill-customize.js +131 -46
  24. package/dist/skill-library.d.ts +46 -1
  25. package/dist/skill-library.js +72 -4
  26. package/docs/acceptance.md +747 -26
  27. package/docs/adr/0007-profile-skill-selection.md +11 -0
  28. package/docs/architecture.md +1 -1
  29. package/docs/client-setup-design.md +157 -24
  30. package/docs/client-setup.md +38 -7
  31. package/docs/doctor-design.md +1 -1
  32. package/docs/installation.md +8 -2
  33. package/docs/pi-extensions-design.md +11 -0
  34. package/docs/releasing.md +25 -10
  35. package/docs/reviews/2026-10-13-post-0.8.1-audit.md +393 -0
  36. package/docs/roadmap.md +127 -0
  37. package/docs/skills.md +173 -16
  38. package/docs/sprints/2026-10-13-s1-correctness.md +355 -0
  39. package/docs/sprints/2026-10-13-s2-evidence.md +431 -0
  40. package/docs/sprints/2026-10-13-s3-live-usage.md +267 -0
  41. package/docs/sprints/2026-10-13-s4-decision-inputs.md +241 -0
  42. package/docs/threat-model.md +3 -3
  43. package/docs/tool-contracts.md +20 -2
  44. package/package.json +1 -1
  45. package/skills/pi-session-orchestration/SKILL.md +8 -4
@@ -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,165 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.2 — 2026-10-13
6
+
7
+ - Fixed: recovery of an interrupted skill update no longer deletes operator
8
+ content in the transaction's `.trash` directory. It deletes that directory
9
+ only when its contents are the complete, unchanged replaced copy (its
10
+ version 2 fingerprint equals the replaced fingerprint in the pending record).
11
+ A partial, modified, added-to, foreign or otherwise unprovable trash, and any
12
+ trash when the record names no usable replaced fingerprint, is retained and
13
+ reported as `skill_recovery_retained`; the pending record, the skill
14
+ directory and the other intermediates stay unchanged. This deliberately
15
+ replaces the automatic deletion of partial trash trees. See [Pi
16
+ skills](docs/skills.md#interrupted-updates). Issue #59.
17
+ - Fixed: `--verify` now reports `unsupported` when the client is unsupported or
18
+ not discoverable (for example its CLI is not on `PATH`) and `inspect_failed`
19
+ when the inspection fails, instead of `registration_not_equivalent`, which
20
+ stays for an absent, divergent or differently fingerprinted registration. A
21
+ failed Doctor ends the target's verify before inspection and the handshake.
22
+ None of these cases mutates a registration or starts the handshake. See
23
+ [client setup](docs/client-setup.md#verify-diagnostics). Issue #56.
24
+ - Fixed: `--dry-run` of a pending version 2 skill transaction now reports
25
+ `skill_recovery_retained` (divergent, exit 1) whenever `--apply` would retain
26
+ an intermediate, using the same read-only classification as the recovery, and
27
+ otherwise `skill_recovery_planned`. Before, it announced
28
+ `skill_recovery_planned` without inspecting the intermediates. A dry-run
29
+ never writes. See [Pi skills](docs/skills.md#interrupted-updates). Issue #60.
30
+ - Changed: the `pi-session-orchestration` skill classifies every documented
31
+ `pi_session_start` error code as operator needed, transient or unknown, by
32
+ the stable code only. A code that is not listed is unknown, and the
33
+ `startup_failed` diagnostics mean that no MCP server is available. The MCP
34
+ tool contract and the error codes are unchanged. Issue #62.
35
+ - Acceptance harness (maintainer script `scripts/multi-session-acceptance.mjs`,
36
+ not part of the npm package and not the package runtime): a run binds the
37
+ contents of the loaded Pi extensions, Pi's npm installation and the
38
+ candidate's skills, including content behind links, and passes the agent
39
+ directory to its server (#66); it binds every Codex skill source, launches
40
+ Codex with the recorded `CODEX_HOME` and refuses a run when any source holds
41
+ a differing controller skill, found by directory or declared name (#67); an
42
+ unreadable or too large bound resource refuses the run and fails the verdict
43
+ with the new code `resourcesUnbound` (#66, #67); and the handshake test skill
44
+ is offered only to the task that needs it, through a separate profile (#68).
45
+ See [acceptance](docs/acceptance.md#multi-session-skill-acceptance).
46
+ - Acceptance harness: a `trio` scenario runs three concurrent sessions from one
47
+ controller, two writers and a read-only reviewer of a frozen fixture, with a
48
+ neighbor abort (#71). `abort` and `trio` now judge every `pi_session_abort`
49
+ call, so an abort that fails or does not target the `wait` session fails the
50
+ run (new code `offTargetAbort`); `abort` is stricter than before (#71).
51
+ Harness tests that built several runs inside one test no longer time out on
52
+ slow macOS runners (#70).
53
+ - Documentation: the Sprint 3 status file
54
+ ([`docs/sprints/2026-10-13-s3-live-usage.md`](docs/sprints/2026-10-13-s3-live-usage.md))
55
+ with the first ten-task Level 3 cycle on Pi SDK `1.x`, the R06, R07 and R08
56
+ live records and a macOS runbook; the Sprint 3 section of
57
+ [acceptance](docs/acceptance.md#sprint-3-live-acceptance-2026-10-13).
58
+ - CI: the test step runs with `NODE_OPTIONS=--unhandled-rejections=strict` on
59
+ the existing matrix, the same command that README, [CONTRIBUTING](CONTRIBUTING.md),
60
+ [Releasing](docs/releasing.md) and acceptance now name. Issue #63.
61
+ - Documentation: delivery-status notes on the historical macOS and Pi
62
+ extension plans (#64); acceptance wording that separates targeted
63
+ multi-session evidence from a Level 3 cycle, and one definition of the
64
+ package version, the MCP tool contract and the setup report `schemaVersion`
65
+ in the [tool contracts](docs/tool-contracts.md#versions) (#65); the Sprint 2
66
+ status file ([`docs/sprints/2026-10-13-s2-evidence.md`](docs/sprints/2026-10-13-s2-evidence.md))
67
+ with the Sprint 1 closure and the roadmap status.
68
+ - Documentation: the Sprint 1 status file
69
+ ([`docs/sprints/2026-10-13-s1-correctness.md`](docs/sprints/2026-10-13-s1-correctness.md)),
70
+ the roadmap status, and the v0.8.1 post-release evidence carried from the
71
+ release notes into [acceptance](docs/acceptance.md).
72
+
73
+ ## 0.8.1 — 2026-10-12
74
+
75
+ - `updatedAt` of a running turn now follows public SDK activity. It is the time
76
+ of the last observed assistant message start, update or end and tool
77
+ execution start, update or end of that turn, and freezes at the terminal
78
+ transition. Before, it advanced only after a completed tool execution or a
79
+ completed assistant message, so a turn of an extension provider with its own
80
+ agent loop, such as `pi-cursor-sdk`, kept `updatedAt == startedAt` while it
81
+ streamed. Streaming is activity, not evidence of finished or useful work, and
82
+ work without a public event stays invisible, so an unchanged `updatedAt` is
83
+ still no proof of a hang. No event content is stored or projected, and the
84
+ answer still comes only from the final assistant message. See [tool
85
+ contracts](docs/tool-contracts.md). Issue #47.
86
+ - Skill tree fingerprints are now version 2 (`v2:` and 64 hex digits), an
87
+ unambiguous encoding with explicit lengths. Version 1 of 0.8.0 hashed
88
+ `path NUL content NUL` and could give two different trees the same value,
89
+ because content may contain NUL bytes. A version 1 value never proves
90
+ identity alone: it counts only when a trusted reference (the shipped skills of
91
+ 0.8.0, or for setup's installed copies the current library tree) has that
92
+ value and is byte-identical to the tree in question. Issue #51.
93
+ - Skill customization is now serialized across processes. `--apply
94
+ --customize-skill` takes an exclusive kernel lock on
95
+ `<override dir>/.pi-session-mcp-bases.lock`, re-reads the bases file under the
96
+ lock and replaces it atomically, so two runs for different skills no longer
97
+ lose a base. The wait is bounded at 30 seconds. New codes:
98
+ `skill_customize_lock_busy` and `skill_customize_lock_unavailable`. The
99
+ dry-run takes no lock and writes nothing. The bases file is now schema version
100
+ 2; version 1 files are still read, and every write migrates each legacy value
101
+ that names a released 0.8.0 shipped skill. New codes for a legacy base:
102
+ `skill_base_migration_planned` and `skill_base_migrated` (it names the current
103
+ shipped skill), and `skill_base_unverified` (it names no released skill; the
104
+ Doctor warning `skill_override_outdated` says that the base cannot be
105
+ confirmed). `skill_customize_planned` and `skill_customized` now also complete
106
+ a customization whose bases commit failed. See [Pi
107
+ skills](docs/skills.md#concurrent-runs-and-the-override-lock). Issue #50.
108
+ - Setup's installed skill copies. Ownership records of skill copies now hold
109
+ version 2 fingerprints. A record that 0.8.0 wrote is migrated only when a
110
+ trusted reference proves the copy's exact bytes; otherwise dry-run, apply,
111
+ remove and rollback report the new code `skill_legacy_unverified` and change
112
+ neither the copy nor the record. Updates now name their intermediate
113
+ directories after the pending record's transaction id
114
+ (`.pi-session-mcp-<skill>-<transactionId>.staging`, `.backup`, `.trash`), and
115
+ `--apply`, `--remove` and `--rollback` first settle an interrupted update.
116
+ Anything modified or foreign stays and is reported as the new code
117
+ `skill_recovery_retained`. A failed update leaves its staging directory until
118
+ the next run settles it. Directories of 0.8.0 updates are never searched for
119
+ or deleted. When `--apply` settled an interrupted update and needs no further
120
+ update it reports the new code `skill_recovered`, and a dry-run of a pending
121
+ update reports `skill_recovery_planned`. Issues #51 and #52.
122
+ - Upgrading from 0.8.0: run `pi-session-mcp-setup` as a dry-run and then with
123
+ `--apply`. Unchanged copies of the 0.8.0 controller skill are proven against
124
+ the 0.8.0 table and migrated; others stay and report
125
+ `skill_legacy_unverified`. Doctor resolves 0.8.0 bases against the same table,
126
+ and the next `--apply --customize-skill` rewrites the bases file as version 2.
127
+ 0.8.0 rejects the new formats as corrupt (the bases file, and the whole
128
+ ownership file once it holds a version 2 skill record), so it never acts on
129
+ them. See [Upgrade from 0.8.0](docs/skills.md#upgrade-from-080) and
130
+ [Downgrade](docs/skills.md#downgrade).
131
+ - The orchestration skill now says that a successful `pi_session_start` does not
132
+ prove that the provider accepts requests, asks for a bounded observation and
133
+ cleanup budget after `abort_timeout`, separates start errors that need the
134
+ operator from ones that can be transient, and describes `updatedAt` as the
135
+ last observed public SDK activity. `docs/acceptance.md` records the v0.8.0
136
+ post-release acceptance and names the method limits of earlier multi-session
137
+ records. Issue #54.
138
+ - The multi-session acceptance harness (`scripts/multi-session-acceptance.mjs`,
139
+ not in the npm package) now ends a run within validated time bounds and
140
+ terminates only processes that provably belong to it (PID and start time). It
141
+ checks scope against a baseline stored outside the worker workspace (working
142
+ tree, index and HEAD, so committed changes count) and adds its own business
143
+ checks to the fixture tests, with Git pinned to the fixture and hooks,
144
+ `fsmonitor` and replace objects switched off. It binds the build, the
145
+ controller and worker skills, the override directory, the configuration, the
146
+ fixture baselines, itself and the operator's own `pi-session-orchestration`
147
+ copies in `~/.claude/skills` and `~/.agents/skills` at `prepare` and rechecks
148
+ them before launch and after the run; a Codex run is refused while the home
149
+ copy differs from the candidate's skill. It records each launch before the
150
+ controller starts, writes each original result once, records reanalyses
151
+ separately, and lists every launch in the evidence, marking one without a
152
+ result. A failed analysis, or a signal to the harness during it, is recorded
153
+ as `analysisFailed`, never passes, still terminates the run's processes and
154
+ makes the CLI exit with 1. `prepare --with-extensions` copies the exact
155
+ extension allowlist of a selected profile for extension providers such as
156
+ Cursor. Each session reports whether a polled running turn showed
157
+ `updatedAt` beyond `startedAt`. See
158
+ [Acceptance](docs/acceptance.md#multi-session-skill-acceptance). Issue #53.
159
+ - A release now travels in the pull request of the change it ships, with the
160
+ version, the changelog heading and the pre-release acceptance record; records
161
+ that need the published release follow with the next change. See
162
+ [Releasing](docs/releasing.md#prepare-a-release).
163
+
5
164
  ## 0.8.0 — 2026-10-12
6
165
 
7
166
  This is a minor release because the public contract changes, as ADR 0003
package/CONTRIBUTING.md CHANGED
@@ -14,12 +14,14 @@ Linux or macOS and Node.js 22.19.0 or newer:
14
14
  npm ci
15
15
  npm run typecheck
16
16
  npm run build:cli
17
- npm test
17
+ NODE_OPTIONS=--unhandled-rejections=strict npm test
18
18
  npm run build
19
19
  ```
20
20
 
21
- Tests use a fake Pi adapter or a scripted model. They need no provider
22
- credentials and no network access beyond the npm registry.
21
+ The strict option makes an unhandled promise rejection fail the test run, and
22
+ CI runs the tests with the same setting. Tests use a fake Pi adapter or a
23
+ scripted model. They need no provider credentials and no network access beyond
24
+ the npm registry.
23
25
 
24
26
  ## Rules
25
27
 
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.2**.
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`.
@@ -173,6 +173,8 @@ Contract and design:
173
173
  Maintain:
174
174
 
175
175
  - [Acceptance](docs/acceptance.md)
176
+ - [Maintenance roadmap](docs/roadmap.md)
177
+ - [Post-0.8.1 audit and backlog snapshot](docs/reviews/2026-10-13-post-0.8.1-audit.md)
176
178
  - [Releasing](docs/releasing.md)
177
179
  - [Contributing](CONTRIBUTING.md)
178
180
  - [Security policy](SECURITY.md)
@@ -183,11 +185,11 @@ Maintain:
183
185
  npm ci
184
186
  npm run typecheck
185
187
  npm run build:cli
186
- npm test
188
+ NODE_OPTIONS=--unhandled-rejections=strict npm test
187
189
  npm run build
188
190
  ```
189
191
 
190
- These are the checks that CI runs. Tests use a fake Pi adapter or a scripted model and need no provider credentials. Every direct dependency is pinned exactly, and the lockfile is committed. The project rules for contributors and coding agents are in [AGENTS.md](AGENTS.md).
192
+ These are the checks that CI runs, with the same strict handling of unhandled promise rejections. Tests use a fake Pi adapter or a scripted model and need no provider credentials. Every direct dependency is pinned exactly, and the lockfile is committed. The project rules for contributors and coding agents are in [AGENTS.md](AGENTS.md).
191
193
 
192
194
  The Pi SDK moves quickly, so a nightly canary checks the unchanged sources against the newest published SDK without touching the pin. [Releasing](docs/releasing.md) describes how to update the pin, record a client contract, patch audit advisories and prepare a release.
193
195
 
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
  */