pi-session-mcp 0.8.1 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,74 @@
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
+
5
73
  ## 0.8.1 — 2026-10-12
6
74
 
7
75
  - `updatedAt` of a running turn now follows public SDK activity. It is the time
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.1**.
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
 
@@ -60,13 +60,19 @@ export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
60
60
  * under the backup name.
61
61
  */
62
62
  private discardBackup;
63
- /** Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree. */
63
+ /**
64
+ * Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree.
65
+ * Callers must have proven the trash to be the complete replaced copy; a later recovery retains
66
+ * a partial tree instead of deleting it.
67
+ */
64
68
  private discardTrash;
65
69
  /**
66
70
  * Settles an interrupted update of the pending record's transaction, inside the ownership
67
71
  * transaction. Only directories with this transaction's exact names are examined, and only
68
72
  * 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.
73
+ * the target or an interrupted write of it, the trash the complete replaced copy. Anything
74
+ * else stays and is reported, including a partial or changed trash.
75
+ * A proven trash is still deleted when a later intermediate (backup or staging) is retained.
70
76
  *
71
77
  * apply, slot absent: a complete staging directory is renamed into the slot (forward);
72
78
  * otherwise a verified backup is moved back. remove and rollback do not restore, they delete
@@ -162,8 +162,11 @@ export class FileSetupSkillInstaller {
162
162
  return finding("skill_recovery_retained", "divergent");
163
163
  recovered = outcome === "recovered";
164
164
  }
165
- // A dry-run never recovers; it announces the settlement apply would perform for a version 2 transaction.
165
+ // A dry-run never recovers; it announces the settlement apply would perform for a version 2 transaction, or its retention.
166
166
  const recoveryPlanned = operation === "dry-run" && owned?.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint) && !hasLegacy(owned);
167
+ // The classification apply's recovery acts on, read-only: an intermediate apply would retain is not announced as planned.
168
+ if (recoveryPlanned && (await classifyIntermediates(path, name, owned, desired, linkTarget)).state === "retained")
169
+ return finding("skill_recovery_retained", "divergent");
167
170
  const current = await inspectSlot(path, linkTarget);
168
171
  if (current.state === "linked") {
169
172
  if (operation === "apply" && owned) {
@@ -307,7 +310,11 @@ export class FileSetupSkillInstaller {
307
310
  await this.discardTrash(names, parent, ownership);
308
311
  await this.step("trash-deleted");
309
312
  }
310
- /** Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree. */
313
+ /**
314
+ * Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree.
315
+ * Callers must have proven the trash to be the complete replaced copy; a later recovery retains
316
+ * a partial tree instead of deleting it.
317
+ */
311
318
  async discardTrash(names, parent, ownership) {
312
319
  await removeEntries(names.trash, parent, ownership, () => this.step("trash-entry-removed"));
313
320
  }
@@ -315,7 +322,9 @@ export class FileSetupSkillInstaller {
315
322
  * Settles an interrupted update of the pending record's transaction, inside the ownership
316
323
  * transaction. Only directories with this transaction's exact names are examined, and only
317
324
  * verified ones are deleted: the backup must equal the replaced copy, the staging directory
318
- * the target or an interrupted write of it. Anything else stays and is reported.
325
+ * the target or an interrupted write of it, the trash the complete replaced copy. Anything
326
+ * else stays and is reported, including a partial or changed trash.
327
+ * A proven trash is still deleted when a later intermediate (backup or staging) is retained.
319
328
  *
320
329
  * apply, slot absent: a complete staging directory is renamed into the slot (forward);
321
330
  * otherwise a verified backup is moved back. remove and rollback do not restore, they delete
@@ -326,26 +335,20 @@ export class FileSetupSkillInstaller {
326
335
  const names = intermediates(path, name, record.transactionId);
327
336
  const target = record.fingerprint;
328
337
  const previous = record.replaces !== undefined && SKILL_TREE_FINGERPRINT_PATTERN.test(record.replaces) ? record.replaces : undefined;
329
- const slot = await inspectSlot(path, linkTarget);
330
- // Only an empty slot, a link or one of setup's two copies can have intermediates of this transaction settled.
331
- if (slot.state === "divergent" || (slot.state === "tree" && slot.fingerprint !== target && slot.fingerprint !== previous))
338
+ let found = await classifyIntermediates(path, name, record, library, linkTarget);
339
+ if (found.state === "untouched")
332
340
  return "untouched";
333
- // A trash directory with the transaction's name was verified before it was renamed, so on
334
- // recovery it may be partial. It is deleted after a structural recheck: a real directory,
335
- // not a link, only regular files and directories, no hidden entries, within the tree limits.
336
341
  let changed = false;
337
- if (await present(names.trash)) {
338
- if (await collectPlain(names.trash) === undefined)
339
- return "retained";
342
+ if (found.deleteTrash) {
340
343
  await this.discardTrash(names, parent, ownership);
341
344
  changed = true;
345
+ // Backup and staging are read again once the trash is gone; the slot stays the one first read.
346
+ found = await classifyIntermediates(path, name, record, library, linkTarget, found.slot);
342
347
  }
343
- let backup = await inspectSlot(names.backup);
344
- if (backup.state === "divergent" || (backup.state === "tree" && backup.fingerprint !== previous))
345
- return "retained";
346
- let staging = await classifyStaging(names.staging, target, library);
347
- if (staging === "foreign")
348
- return "retained";
348
+ if (found.state !== "settle")
349
+ return found.state;
350
+ const { slot } = found;
351
+ let { backup, staging } = found;
349
352
  if (operation === "apply" && slot.state === "absent") {
350
353
  if (staging === "complete" || backup.state === "tree") {
351
354
  const source = staging === "complete" ? names.staging : names.backup;
@@ -380,6 +383,41 @@ export class FileSetupSkillInstaller {
380
383
  return changed ? "recovered" : "settled";
381
384
  }
382
385
  }
386
+ /**
387
+ * Reads a pending version 2 transaction's slot, trash, backup and staging directory, by their
388
+ * exact names, and never writes. The slot is only inspected when `known` is not given. Shared by
389
+ * recovery and the dry-run, so both apply the same rules.
390
+ *
391
+ * Only an empty slot, a link or one of setup's two copies can have intermediates settled. A trash
392
+ * is deletable only while it is the complete replaced copy; a partial or changed trash, a backup
393
+ * that is not the replaced copy and a foreign staging directory are retained.
394
+ */
395
+ async function classifyIntermediates(path, name, record, library, linkTarget, known) {
396
+ const names = intermediates(path, name, record.transactionId);
397
+ const target = record.fingerprint;
398
+ const previous = record.replaces !== undefined && SKILL_TREE_FINGERPRINT_PATTERN.test(record.replaces) ? record.replaces : undefined;
399
+ const slot = known ?? await inspectSlot(path, linkTarget);
400
+ if (slot.state === "divergent" || (slot.state === "tree" && slot.fingerprint !== target && slot.fingerprint !== previous))
401
+ return { state: "untouched" };
402
+ // A trash directory with the transaction's name holds the verified replaced copy, but only
403
+ // while it is complete: it is deleted only when it still equals the record's replaced
404
+ // fingerprint. A partial trash (an interrupted deletion) or any changed content cannot be
405
+ // proven to be setup's own, so it is retained and reported for the operator to settle.
406
+ let deleteTrash = false;
407
+ if (await present(names.trash)) {
408
+ const trash = await inspectSlot(names.trash);
409
+ if (trash.state !== "tree" || previous === undefined || trash.fingerprint !== previous)
410
+ return { state: "retained", slot, deleteTrash };
411
+ deleteTrash = true;
412
+ }
413
+ const backup = await inspectSlot(names.backup);
414
+ if (backup.state === "divergent" || (backup.state === "tree" && backup.fingerprint !== previous))
415
+ return { state: "retained", slot, deleteTrash };
416
+ const staging = await classifyStaging(names.staging, target, library);
417
+ if (staging === "foreign")
418
+ return { state: "retained", slot, deleteTrash };
419
+ return { state: "settle", slot, deleteTrash, backup, staging };
420
+ }
383
421
  function healthy(ownership) { ownership.assertHealthy?.(); }
384
422
  /** The owned form of a record, without the replaced fingerprint of a finished update. */
385
423
  function ownedRecord(record, fingerprint) {
package/dist/setup.js CHANGED
@@ -211,11 +211,28 @@ export class SetupOrchestrator {
211
211
  findings.push({ target, status: "failed", code: "release_binding_mismatch" });
212
212
  continue;
213
213
  }
214
- const doctor = this.dependencies.doctor ? await this.safe(() => this.dependencies.doctor.check()) : { ok: true, code: "doctor_skipped" };
214
+ if (this.dependencies.doctor) {
215
+ const doctor = await this.safe(() => this.dependencies.doctor.check());
216
+ if (!doctor?.ok) {
217
+ findings.push({ target, status: "failed", code: doctor?.code ?? "doctor_failed" });
218
+ continue;
219
+ }
220
+ }
215
221
  const inspected = await this.safe(() => adapter.inspect(target, this.dependencies.intendedFingerprint));
216
- const equivalent = inspected?.state === "equivalent" && inspected.fingerprint === this.dependencies.intendedFingerprint;
217
- const verified = equivalent ? await this.safe(() => adapter.verify(target)) : undefined;
218
- findings.push({ target, status: doctor?.ok && equivalent && verified?.ok ? "ok" : "failed", code: !doctor?.ok ? doctor?.code ?? "doctor_failed" : !equivalent ? "registration_not_equivalent" : verified?.code ?? "verify_failed" });
222
+ if (!inspected) {
223
+ findings.push({ target, status: "failed", code: "inspect_failed" });
224
+ continue;
225
+ }
226
+ if (inspected.state === "unsupported") {
227
+ findings.push({ target, status: "unsupported", code: "unsupported" });
228
+ continue;
229
+ }
230
+ if (inspected.state !== "equivalent" || inspected.fingerprint !== this.dependencies.intendedFingerprint) {
231
+ findings.push({ target, status: "failed", code: "registration_not_equivalent" });
232
+ continue;
233
+ }
234
+ const verified = await this.safe(() => adapter.verify(target));
235
+ findings.push({ target, status: verified?.ok ? "ok" : "failed", code: verified?.code ?? "verify_failed" });
219
236
  }
220
237
  return result("verify", findings);
221
238
  }