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.
- package/.pi-session-mcp-cli/doctor.js +14 -7
- package/.pi-session-mcp-cli/override-lock.js +125 -0
- package/.pi-session-mcp-cli/setup-platform.js +195 -0
- package/.pi-session-mcp-cli/skill-customize.js +131 -46
- package/.pi-session-mcp-cli/skill-library.js +72 -4
- package/CHANGELOG.md +91 -0
- package/README.md +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +14 -7
- package/dist/override-lock.d.ts +34 -0
- package/dist/override-lock.js +125 -0
- package/dist/pi-adapter.d.ts +6 -4
- package/dist/sdk-pi-adapter.js +28 -5
- package/dist/session-registry.d.ts +8 -6
- package/dist/session-registry.js +3 -2
- package/dist/setup-ownership.d.ts +4 -1
- package/dist/setup-ownership.js +6 -3
- package/dist/setup-skills.d.ts +40 -1
- package/dist/setup-skills.js +311 -78
- package/dist/skill-customize.d.ts +19 -1
- package/dist/skill-customize.js +131 -46
- package/dist/skill-library.d.ts +46 -1
- package/dist/skill-library.js +72 -4
- package/docs/acceptance.md +365 -15
- package/docs/adr/0007-profile-skill-selection.md +11 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +106 -20
- package/docs/client-setup.md +19 -7
- package/docs/doctor-design.md +1 -1
- package/docs/installation.md +8 -2
- package/docs/pi-extensions-design.md +11 -0
- package/docs/releasing.md +18 -9
- package/docs/skills.md +145 -16
- package/docs/threat-model.md +3 -3
- package/docs/tool-contracts.md +2 -2
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +5 -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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
package/dist/pi-adapter.d.ts
CHANGED
|
@@ -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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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>;
|
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
* transition.
|
|
22
|
-
*
|
|
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.
|
|
85
|
-
* never another turn or the session.
|
|
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
|
*/
|
package/dist/session-registry.js
CHANGED
|
@@ -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.
|
|
277
|
-
* never another turn or the session.
|
|
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
|
}
|
package/dist/setup-ownership.js
CHANGED
|
@@ -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" &&
|
|
238
|
+
if ("replaces" in x && !(skill && x.phase === "pending" && validFingerprint(x.replaces)))
|
|
236
239
|
return false;
|
|
237
|
-
return (registration || skill) &&
|
|
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
|
}
|
package/dist/setup-skills.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|