owlrunkit 0.24.1 → 0.25.0

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
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0
4
+
5
+ - Adds opt-in same-host peer recovery for macOS-contained workers. A cooperating
6
+ agent can request a wake, observe a fresh correlated checkpoint, or continue
7
+ from the last checkpoint as the designated successor.
8
+ - Records a single successor generation and rejects writes and checkpoint
9
+ submissions from the superseded attempt. Recovery remains scoped to the
10
+ managed task and preserves the existing manual path for legacy leases.
11
+ - Retains 0.24.1 as the exact published rollback baseline.
12
+
3
13
  ## 0.24.1
4
14
 
5
15
  - Corrects the optional Stop hook to notify on RunKit data file changes, including
package/README.md CHANGED
@@ -136,7 +136,7 @@ separate authority boundaries.
136
136
 
137
137
  ## Install
138
138
 
139
- This package is the lifecycle-stable, registry-gated `owlrunkit@0.24.1`
139
+ This package is the lifecycle-stable, registry-gated `owlrunkit@0.25.0`
140
140
  artifact. Registry adoption remains fail-closed until its exact official npm
141
141
  version, shasum, integrity, and tarball URL are independently verified. Local
142
142
  tarballs remain candidate-test inputs and are never adoption provenance. Until
@@ -144,7 +144,7 @@ that registry evidence exists, use the package only for isolated candidate
144
144
  verification; do not treat the command below as available release truth.
145
145
 
146
146
  ```bash
147
- npm install --save-exact owlrunkit@0.24.1
147
+ npm install --save-exact owlrunkit@0.25.0
148
148
  ```
149
149
 
150
150
  Use `npx --no-install owlrunkit` for project commands. The package bootstrap
@@ -160,9 +160,9 @@ the project Config must independently bind the same Core.
160
160
  ```bash
161
161
  npx --no-install owlrunkit --version
162
162
  npx --no-install owlrunkit bootstrap --workspace /absolute/path/to/project \
163
- --exact owlrunkit@0.24.1 --dry-run
163
+ --exact owlrunkit@0.25.0 --dry-run
164
164
  npx --no-install owlrunkit bootstrap --workspace /absolute/path/to/project \
165
- --exact owlrunkit@0.24.1 --apply
165
+ --exact owlrunkit@0.25.0 --apply
166
166
  npx --no-install owlrunkit init --workspace /absolute/path/to/project
167
167
  npx --no-install owlrunkit doctor --workspace /absolute/path/to/project
168
168
  npx --no-install owlrunkit inspect --workspace /absolute/path/to/project --json --compact
@@ -995,6 +995,55 @@ Installing the package enables nothing and changes no global configuration,
995
995
  shared Skill or other workspace. This is same-session synchronous Stop
996
996
  continuation; fully idle Desktop wake requires a separate supported ingress.
997
997
 
998
+ ## Managed local peer recovery
999
+
1000
+ On macOS, opt a new task into peer recovery before starting its writer. The
1001
+ project controller names the current agent, successor, and exact checkpoint
1002
+ paths in `peer.json`, for example:
1003
+
1004
+ ```json
1005
+ {"owner":"alpha","successor":"bravo","paths":["result.txt"]}
1006
+ ```
1007
+
1008
+ ```bash
1009
+ npx --no-install owlrunkit peer create --workspace . --task repair --request peer.json
1010
+ npx --no-install owlrunkit peer run --workspace . --task repair --agent alpha --generation 1 -- /absolute/worker
1011
+ npx --no-install owlrunkit peer inspect --workspace . --task repair
1012
+ npx --no-install owlrunkit peer recover --workspace . --task repair --agent bravo --generation 1 --revision 2
1013
+ ```
1014
+
1015
+ Run the last two commands from the cooperating agent while it is investigating
1016
+ an interruption. Use the actual generation and revision returned by `inspect`.
1017
+ Recovery requests a wake from the current worker. A matching, changed checkpoint
1018
+ keeps that worker in charge. If wake remains unconfirmed after the bounded wait,
1019
+ recovery can move ownership to the designated successor because the current
1020
+ attempt already runs in an isolated directory. One durable revision binds the
1021
+ successor generation to the last accepted checkpoint. The successor explicitly
1022
+ runs its own worker using that generation. An ACK or timeout is not a proof of
1023
+ worker death. Retry the same observed revision to read back a race or recovery.
1024
+
1025
+ Use a local worker that speaks newline-delimited JSON. RunKit seeds the attempt
1026
+ directory from the previous checkpoint and sets `OWLRUNKIT_PEER_PATHS` to the
1027
+ ordered path array. The worker reads a wake challenge from stdin as
1028
+ `{"type":"wake","wakeId":"..."}`. Stdout is reserved for protocol messages.
1029
+ Report work as `{"type":"checkpoint","wakeId":"...","files":[{"path":"result.txt","content":"..."}]}`.
1030
+ Use the wake ID only when reporting progress in response to that challenge.
1031
+ The `files` array covers the exact ordered paths, with canonical base64 bytes
1032
+ or `null` for a missing file. The decoded checkpoint limit is 1 MiB. Emit
1033
+ `finish` with the same `files` shape to record the result. Human diagnostics
1034
+ go to stderr. `inspect` returns the checkpoint path and hash for handoff and
1035
+ review. Only accepted checkpoints seed the next attempt, and the previous
1036
+ scratch directory is reported for inspection while it remains on disk.
1037
+
1038
+ The macOS backend confines each worker's writes to its own attempt and denies
1039
+ network and additional processes. The host caller explicitly authorizes the
1040
+ worker command, and supplies the peer identity. This is a supported boundary
1041
+ for contained workers and their checkpoints. An unmanaged file lease, a raw
1042
+ shared-directory writer, or an existing Desktop session needs its own recovery
1043
+ procedure. Legacy lease records and `control.lock` are untouched. The result
1044
+ is task evidence for the existing acceptance process. Canonical source
1045
+ integration remains a separate authorized step.
1046
+
998
1047
  ## Shared Skill upgrades
999
1048
 
1000
1049
  Process-wide Codex Skill updates automatically use the registered fleet:
@@ -7,18 +7,29 @@ description: Run receipt-backed, project-owned work with OwlCoda RunKit. Use whe
7
7
 
8
8
  Use the bundled deterministic Core to coordinate work through project artifacts rather than chat memory. Require Node.js 20 or later.
9
9
 
10
- Bundled registry-gated artifact: standalone `owlrunkit@0.24.1`, bundling Contract
11
- `0.2` and Core `0.24.1` with manifest
12
- `sha256:94aaa2e3a6d504374fa78cfcd341e31b33087806c9ddf5f84b3d3b47a6328e08`.
10
+ Bundled registry-gated artifact: standalone `owlrunkit@0.25.0`, bundling Contract
11
+ `0.2` and Core `0.25.0` with manifest
12
+ `sha256:20e75c386f1408f11e908a6d3ee1a39316d9728d5719fa06f8d6a5aa45abd83e`.
13
13
  Registry adoption remains fail-closed until exact official npm registry
14
- provenance for `owlrunkit@0.24.1` is independently verified. The
15
- published rollback baseline for this artifact is `owlrunkit@0.24.0`, with SHA-1
16
- `9408b579faf58f85a3e8d7bd3d64c34256f85fe8` and canonical npm tarball URL
17
- `https://registry.npmjs.org/owlrunkit/-/owlrunkit-0.24.0.tgz`.
14
+ provenance for `owlrunkit@0.25.0` is independently verified. The
15
+ published rollback baseline for this artifact is `owlrunkit@0.24.1`, with SHA-1
16
+ `b70b857c128f67d19d401acaa096a6cf9f1f348e` and canonical npm tarball URL
17
+ `https://registry.npmjs.org/owlrunkit/-/owlrunkit-0.24.1.tgz`.
18
18
  The root `owlcoda` package has a separate version lifecycle.
19
19
 
20
20
  The session is an execution interface. Project artifacts are the system of record.
21
21
 
22
+ ## Optional managed local peer recovery
23
+
24
+ For a task already enrolled in `owlrunkit peer` with an isolated worker, inspect
25
+ the task when your cooperating agent stops progressing. As the designated
26
+ successor, run `peer recover` with the observed generation and revision. A
27
+ new checkpoint tied to the wake challenge keeps the original agent active.
28
+ When recovery grants a successor generation, explicitly run the authorized
29
+ worker in that generation and resume from its seeded checkpoint. Treat an ACK
30
+ without progress as unconfirmed. Keep legacy shared-workspace leases on the
31
+ manual recovery path. Use the package README and `peer --help` for setup.
32
+
22
33
  ## Optional file-event continuation
23
34
 
24
35
  RunKit includes `scripts/runkit-contract/file-event-stop-hook.mjs` for an
@@ -357,9 +368,9 @@ below has no CLI entry.
357
368
 
358
369
  ```bash
359
370
  npx --no-install owlrunkit bootstrap --workspace "$PROJECT_ROOT" \
360
- --exact owlrunkit@0.24.1 --dry-run
371
+ --exact owlrunkit@0.25.0 --dry-run
361
372
  npx --no-install owlrunkit bootstrap --workspace "$PROJECT_ROOT" \
362
- --exact owlrunkit@0.24.1 --apply
373
+ --exact owlrunkit@0.25.0 --apply
363
374
  npx --no-install owlrunkit init --workspace "$PROJECT_ROOT"
364
375
  npx --no-install owlrunkit inspect --json --workspace "$PROJECT_ROOT"
365
376
  npx --no-install owlrunkit inspect --json --compact --workspace "$PROJECT_ROOT"
@@ -2,9 +2,9 @@
2
2
  "schemaVersion": "OwlCodaRunKitConfigV2",
3
3
  "core": {
4
4
  "contractVersion": "0.2",
5
- "coreVersion": "0.24.1",
6
- "coreManifestSha256": "sha256:94aaa2e3a6d504374fa78cfcd341e31b33087806c9ddf5f84b3d3b47a6328e08",
7
- "coreSourceRef": "artifact:sha256:94aaa2e3a6d504374fa78cfcd341e31b33087806c9ddf5f84b3d3b47a6328e08"
5
+ "coreVersion": "0.25.0",
6
+ "coreManifestSha256": "sha256:20e75c386f1408f11e908a6d3ee1a39316d9728d5719fa06f8d6a5aa45abd83e",
7
+ "coreSourceRef": "artifact:sha256:20e75c386f1408f11e908a6d3ee1a39316d9728d5719fa06f8d6a5aa45abd83e"
8
8
  },
9
9
  "authorizationPolicy": "external_explicit_authority_required"
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlrunkit",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Project Driver for cross-Agent project continuity and progression; execution, evidence, verification, and recovery are the underlying trust substrate.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "author": "yeemio <owlclaw@163.com>",
@@ -42,6 +42,11 @@
42
42
  "sha256": "sha256:3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
43
43
  },
44
44
  "supportedProducerCoreIdentities": [
45
+ {
46
+ "contractVersion": "0.2",
47
+ "coreVersion": "0.25.0",
48
+ "coreManifestSha256": "sha256:20e75c386f1408f11e908a6d3ee1a39316d9728d5719fa06f8d6a5aa45abd83e"
49
+ },
45
50
  {
46
51
  "contractVersion": "0.2",
47
52
  "coreVersion": "0.24.1",
@@ -23,6 +23,11 @@ import {
23
23
  export { validQuickReceiptShape } from "./quick-receipt-contract.mjs";
24
24
 
25
25
  export const SUPPORTED_QUICK_CORE_IDENTITIES = Object.freeze([
26
+ {
27
+ contractVersion: "0.2",
28
+ coreVersion: "0.25.0",
29
+ coreManifestSha256: "sha256:20e75c386f1408f11e908a6d3ee1a39316d9728d5719fa06f8d6a5aa45abd83e",
30
+ },
26
31
  {
27
32
  contractVersion: "0.2",
28
33
  coreVersion: "0.24.1",
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": "OwlCodaRunKitRegistryReleasePolicyV2",
3
3
  "status": "release_artifact_registry_gated",
4
4
  "packageName": "owlrunkit",
5
- "artifactVersion": "0.24.1",
5
+ "artifactVersion": "0.25.0",
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "registryRequiredBeforeAdoption": true,
8
8
  "exactVersionRequired": true,
@@ -23,10 +23,10 @@
23
23
  ],
24
24
  "rollbackRelease": {
25
25
  "packageName": "owlrunkit",
26
- "version": "0.24.0",
27
- "shasum": "9408b579faf58f85a3e8d7bd3d64c34256f85fe8",
28
- "integrity": "sha512-AbivZPDxYhOCxemfsT8Q01P6igKCed2P0V15kSzhrNLW548aHqUmIHVmvdZOnvWI/f8p5YBe53cVa4wRq8O1lg==",
29
- "tarballUrl": "https://registry.npmjs.org/owlrunkit/-/owlrunkit-0.24.0.tgz"
26
+ "version": "0.24.1",
27
+ "shasum": "b70b857c128f67d19d401acaa096a6cf9f1f348e",
28
+ "integrity": "sha512-8Fr5XTAbtRlxyYboDSB8C257sxSWWiAFqGU6yexF99NXgilJ3pbxtMwq4FcbrlHN5V6yJLME04vTceI2s9F4bA==",
29
+ "tarballUrl": "https://registry.npmjs.org/owlrunkit/-/owlrunkit-0.24.1.tgz"
30
30
  },
31
31
  "registryEvidenceMustBeExternal": true,
32
32
  "registryEvidenceSchemaVersion": "OwlCodaRunKitRegistryReleaseEvidenceV1",
package/sbom.spdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "owlrunkit-0.24.1",
6
- "documentNamespace": "https://owlcoda.com/spdx/owlrunkit/0.24.1",
5
+ "name": "owlrunkit-0.25.0",
6
+ "documentNamespace": "https://owlcoda.com/spdx/owlrunkit/0.25.0",
7
7
  "creationInfo": {
8
- "created": "2026-10-08T00:00:00Z",
8
+ "created": "2026-11-11T00:00:00Z",
9
9
  "creators": [
10
10
  "Organization: OwlCoda"
11
11
  ]
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "name": "owlrunkit",
16
16
  "SPDXID": "SPDXRef-Package-OwlRunKit",
17
- "versionInfo": "0.24.1",
17
+ "versionInfo": "0.25.0",
18
18
  "downloadLocation": "NOASSERTION",
19
19
  "filesAnalyzed": false,
20
20
  "licenseConcluded": "GPL-3.0-or-later",
@@ -24,7 +24,7 @@
24
24
  {
25
25
  "referenceCategory": "PACKAGE-MANAGER",
26
26
  "referenceType": "purl",
27
- "referenceLocator": "pkg:npm/owlrunkit@0.24.1"
27
+ "referenceLocator": "pkg:npm/owlrunkit@0.25.0"
28
28
  }
29
29
  ]
30
30
  }
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { parseProjectControlState } from "./project-control-parser.mjs";
15
15
 
16
16
  export const CONTRACT_VERSION = "0.2";
17
- export const CORE_VERSION = "0.24.1";
17
+ export const CORE_VERSION = "0.25.0";
18
18
  export const RUNTIME_ROOT = ".owlcoda/runkit";
19
19
 
20
20
  const CORE_FILES = [
@@ -42,6 +42,7 @@ const CORE_FILES = [
42
42
  "lifecycle-orchestration.mjs",
43
43
  "offline-store.mjs",
44
44
  "outcome-presentation.mjs",
45
+ "peer-recovery.mjs",
45
46
  "onboarding-doctor.mjs",
46
47
  "onboarding-bootstrap.mjs",
47
48
  "owner-authority-trust.mjs",
@@ -0,0 +1,442 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ closeSync, constants, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync,
5
+ mkdtempSync, openSync, readSync, readdirSync, realpathSync, unlinkSync,
6
+ watch, writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import path from "node:path";
10
+
11
+ import { RUNTIME_ROOT, currentCoreIdentity } from "./core-contract.mjs";
12
+ import { safeIdentifier } from "./provenance-common.mjs";
13
+ import { resolveVerificationEnvelopeBackendV1 } from "./verification-envelope.mjs";
14
+
15
+ const SCHEMA = "OwlRunKitPeerRecoveryRevisionV1";
16
+ const CHECKPOINT_LIMIT = 1024 * 1024;
17
+ const RECORD_LIMIT = 2 * CHECKPOINT_LIMIT;
18
+ const MAX_REVISIONS = 4096;
19
+ const REVISION_NAME = /^([0-9]{6})\.json$/u;
20
+
21
+ function digest(value) {
22
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
23
+ }
24
+
25
+ function realDirectory(ref) {
26
+ if (!lstatSync(ref).isDirectory() || realpathSync(ref) !== ref) {
27
+ throw new Error("peer_recovery_redirected_directory");
28
+ }
29
+ return ref;
30
+ }
31
+
32
+ function directoryBelow(root, segments, create) {
33
+ let current = root;
34
+ for (const segment of segments) {
35
+ current = path.join(current, segment);
36
+ if (create && !existsSync(current)) {
37
+ try { mkdirSync(current, { mode: 0o700 }); }
38
+ catch (error) { if (error.code !== "EEXIST") throw error; }
39
+ }
40
+ realDirectory(current);
41
+ }
42
+ return current;
43
+ }
44
+
45
+ function taskDirectory(workspaceRoot, taskId, create = false) {
46
+ safeIdentifier(taskId, "peer recovery task");
47
+ const root = realpathSync(workspaceRoot);
48
+ const runtime = directoryBelow(root, RUNTIME_ROOT.split("/"), false);
49
+ const directory = directoryBelow(runtime, ["peer-recovery", taskId], create);
50
+ return { root, directory, taskId, core: currentCoreIdentity() };
51
+ }
52
+
53
+ function regularBytes(ref, limit) {
54
+ const stat = lstatSync(ref);
55
+ if (!stat.isFile() || stat.size > limit || realpathSync(ref) !== ref) {
56
+ throw new Error("peer_recovery_invalid_file");
57
+ }
58
+ const fd = openSync(ref, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
59
+ try {
60
+ const buffer = Buffer.alloc(limit + 1);
61
+ let length = 0;
62
+ while (length < buffer.length) {
63
+ const bytes = readSync(fd, buffer, length, buffer.length - length, null);
64
+ if (bytes === 0) break;
65
+ length += bytes;
66
+ }
67
+ if (length > limit) throw new Error("peer_recovery_file_too_large");
68
+ return buffer.subarray(0, length);
69
+ } finally { closeSync(fd); }
70
+ }
71
+
72
+ function revisionPath(task, revision) {
73
+ return path.join(task.directory, `${String(revision).padStart(6, "0")}.json`);
74
+ }
75
+
76
+ function readRevision(task, revision) {
77
+ const record = JSON.parse(regularBytes(revisionPath(task, revision), RECORD_LIMIT).toString("utf8"));
78
+ if (record.schemaVersion !== SCHEMA || record.workspaceRoot !== task.root
79
+ || record.taskId !== task.taskId || record.revision !== revision
80
+ || record.authorizationGranted !== false || !Number.isSafeInteger(record.generation)
81
+ || record.generation < 1 || !Number.isSafeInteger(record.checkpointRevision)
82
+ || record.checkpointRevision < 1 || record.checkpointRevision > revision
83
+ || !["ready", "running", "exited", "completed"].includes(record.phase)
84
+ || JSON.stringify(validatePaths(record.paths)) !== JSON.stringify(record.paths)) {
85
+ throw new Error("peer_recovery_revision_invalid");
86
+ }
87
+ if (record.core?.coreManifestSha256 !== task.core.coreManifestSha256
88
+ || record.core?.coreVersion !== task.core.coreVersion) {
89
+ throw new Error("peer_recovery_core_pin_mismatch");
90
+ }
91
+ safeIdentifier(record.owner, "recorded current peer");
92
+ safeIdentifier(record.successor, "recorded successor peer");
93
+ return record;
94
+ }
95
+
96
+ function current(task) {
97
+ const revisions = readdirSync(task.directory).map(name => REVISION_NAME.exec(name))
98
+ .filter(Boolean).map(match => Number(match[1])).sort((a, b) => a - b);
99
+ if (revisions.length === 0) throw new Error("peer_recovery_task_uninitialized");
100
+ if (revisions.some((revision, index) => revision !== index + 1)) {
101
+ throw new Error("peer_recovery_revision_gap");
102
+ }
103
+ return readRevision(task, revisions.length);
104
+ }
105
+
106
+ // Exactly one contender can link the next revision. A crash before the link
107
+ // leaves only ignored scratch, and after the link the whole transition is visible.
108
+ function append(task, previous, fields) {
109
+ const revision = (previous?.revision ?? 0) + 1;
110
+ if (revision > MAX_REVISIONS) throw new Error("peer_recovery_revision_limit");
111
+ const record = {
112
+ ...previous, files: undefined, ...fields,
113
+ schemaVersion: SCHEMA, workspaceRoot: task.root, taskId: task.taskId, core: task.core,
114
+ revision, recordedAt: new Date().toISOString(), authorizationGranted: false,
115
+ };
116
+ const bytes = Buffer.from(`${JSON.stringify(record)}\n`);
117
+ if (bytes.length > RECORD_LIMIT) throw new Error("peer_recovery_record_too_large");
118
+ const scratch = path.join(task.directory, `.tmp-${randomUUID()}`);
119
+ const fd = openSync(scratch, "wx", 0o600);
120
+ try {
121
+ writeFileSync(fd, bytes);
122
+ fsyncSync(fd);
123
+ try { linkSync(scratch, revisionPath(task, revision)); }
124
+ catch (error) { if (error.code === "EEXIST") return null; throw error; }
125
+ const directoryFd = openSync(task.directory, "r");
126
+ try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
127
+ return record;
128
+ } finally { closeSync(fd); unlinkSync(scratch); }
129
+ }
130
+
131
+ function validatePaths(paths) {
132
+ if (!Array.isArray(paths) || paths.length < 1 || paths.length > 64) {
133
+ throw new Error("peer_recovery_paths_required");
134
+ }
135
+ const normalized = paths.map(ref => {
136
+ if (typeof ref !== "string" || ref.length > 240 || /[\\:\p{Cc}]/u.test(ref)
137
+ || ref.split("/").some(part => !part || part === "." || part === ".."
138
+ || /^(?:\.git|\.owlcoda|node_modules)$/iu.test(part))) {
139
+ throw new Error("peer_recovery_invalid_path");
140
+ }
141
+ return ref;
142
+ }).sort();
143
+ const folded = normalized.map(ref => ref.normalize("NFC").toLowerCase()).sort();
144
+ if (folded.some((ref, index) => index && (ref === folded[index - 1]
145
+ || folded.some(parent => ref.startsWith(`${parent}/`))))) {
146
+ throw new Error("peer_recovery_overlapping_paths");
147
+ }
148
+ return normalized;
149
+ }
150
+
151
+ function checkedFiles(paths, input) {
152
+ if (!Array.isArray(input) || input.length !== paths.length) {
153
+ throw new Error("peer_recovery_checkpoint_paths_mismatch");
154
+ }
155
+ let bytes = 0;
156
+ const files = paths.map((ref, index) => {
157
+ const item = input[index];
158
+ if (item?.path !== ref || (item.content !== null && typeof item.content !== "string")) {
159
+ throw new Error("peer_recovery_checkpoint_paths_mismatch");
160
+ }
161
+ if (item.content !== null) {
162
+ const content = Buffer.from(item.content, "base64");
163
+ if (content.toString("base64") !== item.content) throw new Error("peer_recovery_checkpoint_base64_invalid");
164
+ bytes += content.length;
165
+ if (bytes > CHECKPOINT_LIMIT) throw new Error("peer_recovery_checkpoint_too_large");
166
+ }
167
+ return { path: ref, content: item.content };
168
+ });
169
+ return { files, sha256: digest(JSON.stringify(files)) };
170
+ }
171
+
172
+ function initialFile(root, ref) {
173
+ const target = path.join(root, ref);
174
+ try {
175
+ const content = regularBytes(target, CHECKPOINT_LIMIT).toString("base64");
176
+ return { path: ref, content };
177
+ } catch (error) {
178
+ if (error.code === "ENOENT") return { path: ref, content: null };
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ function checkpoint(task, state) {
184
+ const source = readRevision(task, state.checkpointRevision);
185
+ const result = checkedFiles(state.paths, source.files);
186
+ if (result.sha256 !== state.checkpointSha256) throw new Error("peer_recovery_checkpoint_drift");
187
+ return result;
188
+ }
189
+
190
+ function projection(task, state) {
191
+ const { files: _files, ...record } = state;
192
+ return {
193
+ ...record, checkpointPath: revisionPath(task, state.checkpointRevision),
194
+ status: state.event, exitCode: 0, authorizationGranted: false,
195
+ };
196
+ }
197
+
198
+ function requireBackend() {
199
+ const backend = resolveVerificationEnvelopeBackendV1();
200
+ if (!backend.available) throw new Error("peer_recovery_enforced_backend_unavailable");
201
+ return backend;
202
+ }
203
+
204
+ export function createPeerRecoveryTask({ workspaceRoot, taskId, owner, successor, paths }) {
205
+ requireBackend();
206
+ safeIdentifier(owner, "initial peer");
207
+ safeIdentifier(successor, "successor peer");
208
+ if (owner === successor) throw new Error("peer_recovery_requires_distinct_peers");
209
+ const ownedPaths = validatePaths(paths);
210
+ const root = realpathSync(workspaceRoot);
211
+ const initial = checkedFiles(ownedPaths, ownedPaths.map(ref => initialFile(root, ref)));
212
+ const task = taskDirectory(root, taskId, true);
213
+ const created = append(task, null, {
214
+ generation: 1, owner, successor, paths: ownedPaths, phase: "ready", attempt: null,
215
+ wake: null, event: "peer_task_ready", checkpointRevision: 1,
216
+ checkpointSha256: initial.sha256, files: initial.files,
217
+ });
218
+ if (!created) throw new Error("peer_recovery_task_already_exists");
219
+ return projection(task, created);
220
+ }
221
+
222
+ export function inspectPeerRecoveryTask({ workspaceRoot, taskId }) {
223
+ const task = taskDirectory(workspaceRoot, taskId);
224
+ return projection(task, current(task));
225
+ }
226
+
227
+ function observe(task, examine, timeoutMs) {
228
+ return new Promise((resolve, reject) => {
229
+ let watcher, timer, settled = false;
230
+ const finish = (error, value) => {
231
+ if (settled) return;
232
+ settled = true;
233
+ clearTimeout(timer);
234
+ watcher?.close();
235
+ if (error) reject(error);
236
+ else resolve(value);
237
+ };
238
+ const check = () => {
239
+ try {
240
+ const value = examine(current(task));
241
+ if (value) finish(null, value);
242
+ } catch (error) { finish(error); }
243
+ };
244
+ try {
245
+ watcher = watch(task.directory, check);
246
+ watcher.once("error", finish);
247
+ timer = setTimeout(() => { check(); finish(null, null); }, timeoutMs);
248
+ check();
249
+ } catch (error) { finish(error); }
250
+ });
251
+ }
252
+
253
+ export async function recoverPeerTask({ workspaceRoot, taskId, agent, generation, revision, waitMs = 5000 }) {
254
+ requireBackend();
255
+ safeIdentifier(agent, "successor peer");
256
+ if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(revision)
257
+ || revision < 1 || !Number.isInteger(waitMs)
258
+ || waitMs < 100 || waitMs > 60000) throw new Error("peer_recovery_invalid_request");
259
+ const task = taskDirectory(workspaceRoot, taskId);
260
+ let requestedWakeId;
261
+ for (;;) {
262
+ const state = current(task);
263
+ if (state.generation !== generation) {
264
+ if (state.generation === generation + 1 && state.owner === agent) return projection(task, state);
265
+ throw new Error("peer_recovery_generation_changed");
266
+ }
267
+ if (state.successor !== agent) throw new Error("peer_recovery_successor_mismatch");
268
+ if (state.phase === "completed") return projection(task, state);
269
+ if (state.wake?.observed && (state.wake.id === requestedWakeId
270
+ || state.wake.requestedFromRevision === revision)) return projection(task, state);
271
+ if (state.phase === "running" && (!state.wake || state.wake.observed)) {
272
+ if (state.revision !== revision) throw new Error("peer_recovery_observation_stale");
273
+ const wake = { id: randomUUID(), requestedFromRevision: revision,
274
+ deadline: Date.now() + waitMs, observed: false };
275
+ if (append(task, state, { wake, event: "peer_wake_requested" })) requestedWakeId = wake.id;
276
+ continue;
277
+ }
278
+ if (state.wake && !state.wake.observed && state.wake.requestedFromRevision === revision) {
279
+ requestedWakeId = state.wake.id;
280
+ }
281
+ if (state.revision !== revision && (!requestedWakeId || state.wake?.id !== requestedWakeId)) {
282
+ throw new Error("peer_recovery_observation_stale");
283
+ }
284
+ if (state.phase === "running" && Date.now() < state.wake.deadline) {
285
+ requestedWakeId = state.wake.id;
286
+ await observe(task, next => next.generation !== generation || next.phase !== "running"
287
+ || next.wake?.id !== requestedWakeId || next.wake.observed,
288
+ Math.max(1, state.wake.deadline - Date.now()));
289
+ continue;
290
+ }
291
+ checkpoint(task, state);
292
+ const replacement = append(task, state, {
293
+ owner: agent, successor: state.owner, generation: generation + 1,
294
+ phase: "ready", attempt: null, wake: null, processExit: null, event: "peer_takeover_ready",
295
+ recovery: {
296
+ fromGeneration: generation, fromAgent: state.owner, previousAttempt: state.attempt,
297
+ wakeId: state.wake?.id ?? null,
298
+ evidence: state.phase === "running" ? "wake_unconfirmed_isolated"
299
+ : state.phase === "exited" ? "managed_process_exited" : "attempt_not_started",
300
+ },
301
+ });
302
+ if (replacement) return projection(task, replacement);
303
+ }
304
+ }
305
+
306
+ function materialize(files, directory) {
307
+ for (const file of files) {
308
+ if (file.content === null) continue;
309
+ const ref = path.join(directory, file.path);
310
+ mkdirSync(path.dirname(ref), { recursive: true, mode: 0o700 });
311
+ writeFileSync(ref, Buffer.from(file.content, "base64"), { flag: "wx", mode: 0o600 });
312
+ }
313
+ }
314
+
315
+ function sandboxPolicy(executable, directory) {
316
+ // Only the host process receives control-store access. The worker inherits
317
+ // pipes, never a writable descriptor into the project or the next attempt.
318
+ return [
319
+ "(version 1)", "(allow default)", "(deny file-write*)", "(deny file-read-data)",
320
+ `(allow file-read-data file-write* (subpath ${JSON.stringify(directory)}))`,
321
+ `(allow file-read-data (literal ${JSON.stringify(executable)}))`,
322
+ '(allow file-read-data (literal "/") (subpath "/System") (subpath "/usr/lib"))',
323
+ "(deny network*)", "(deny mach-lookup)", "(deny signal)", "(deny process-fork)", "(deny process-exec)",
324
+ `(allow process-exec (literal ${JSON.stringify(executable)}))`,
325
+ ].join("\n");
326
+ }
327
+
328
+ function reportCheckpoint(task, attemptId, message) {
329
+ for (;;) {
330
+ const state = current(task);
331
+ if (state.phase !== "running" || state.attempt?.id !== attemptId) return false;
332
+ const value = checkedFiles(state.paths, message.files);
333
+ const finish = message.type === "finish";
334
+ const changed = value.sha256 !== state.checkpointSha256;
335
+ if (!finish && !changed) return true;
336
+ const observed = changed && state.wake?.id === message.wakeId && state.wake !== null;
337
+ if (append(task, state, {
338
+ files: value.files, checkpointRevision: state.revision + 1, checkpointSha256: value.sha256,
339
+ wake: observed ? { ...state.wake, observed: true } : state.wake,
340
+ phase: finish ? "completed" : "running",
341
+ event: finish ? "peer_task_completed" : observed ? "peer_wake_progress_observed" : "peer_checkpoint_recorded",
342
+ })) return true;
343
+ }
344
+ }
345
+
346
+ function reportExit(task, attemptId, processExit) {
347
+ for (;;) {
348
+ const state = current(task);
349
+ if (state.attempt?.id !== attemptId) {
350
+ return { ...projection(task, state), status: "peer_attempt_superseded", exitCode: 2 };
351
+ }
352
+ if (state.phase !== "running") return projection(task, state);
353
+ const next = append(task, state, {
354
+ phase: "exited", event: "peer_attempt_exited", processExit,
355
+ });
356
+ if (next) return { ...projection(task, next), exitCode: 2 };
357
+ }
358
+ }
359
+
360
+ export async function runPeerTask({ workspaceRoot, taskId, agent, generation, command }) {
361
+ const backend = requireBackend();
362
+ safeIdentifier(agent, "current peer");
363
+ if (!Array.isArray(command) || !path.isAbsolute(command[0] ?? "")) {
364
+ throw new Error("peer_recovery_absolute_worker_required");
365
+ }
366
+ const executable = realpathSync(command[0]);
367
+ if (!lstatSync(executable).isFile()) throw new Error("peer_recovery_worker_invalid");
368
+ const task = taskDirectory(workspaceRoot, taskId);
369
+ const state = current(task);
370
+ if (state.phase !== "ready" || state.owner !== agent || state.generation !== generation) {
371
+ throw new Error("peer_recovery_attempt_not_available");
372
+ }
373
+ const id = randomUUID();
374
+ const directory = mkdtempSync(path.join(realpathSync(tmpdir()), "owlrunkit-peer-"));
375
+ materialize(checkpoint(task, state).files, directory);
376
+ const attempt = { id, directory, backend: backend.id };
377
+ const claimed = append(task, state, {
378
+ attempt, phase: "running", processExit: null, event: "peer_attempt_started",
379
+ });
380
+ if (!claimed) throw new Error("peer_recovery_attempt_already_claimed");
381
+
382
+ return new Promise((resolve, reject) => {
383
+ const child = spawn(backend.executable, ["-p", sandboxPolicy(executable, directory),
384
+ executable, ...command.slice(1)], {
385
+ cwd: directory, env: {
386
+ PATH: "/usr/bin:/bin", TMPDIR: directory, OWLRUNKIT_PEER_ATTEMPT: id,
387
+ OWLRUNKIT_PEER_PATHS: JSON.stringify(state.paths),
388
+ }, stdio: ["pipe", "pipe", "pipe"],
389
+ });
390
+ let watcher, pending = Buffer.alloc(0), sentWake, protocolError = null;
391
+ let diagnostic = Buffer.alloc(0);
392
+ const stop = error => {
393
+ protocolError ??= error.message;
394
+ child.kill("SIGKILL");
395
+ };
396
+ const wake = () => {
397
+ try {
398
+ const live = current(task);
399
+ if (live.attempt?.id !== id || live.phase !== "running") return;
400
+ if (live.wake && !live.wake.observed && live.wake.id !== sentWake) {
401
+ sentWake = live.wake.id;
402
+ child.stdin.write(`${JSON.stringify({ type: "wake", wakeId: sentWake })}\n`);
403
+ }
404
+ } catch (error) { stop(error); }
405
+ };
406
+ child.stdin.on("error", error => { if (error.code !== "EPIPE") stop(error); });
407
+ child.stderr.on("data", chunk => {
408
+ const remaining = 4096 - diagnostic.length;
409
+ if (remaining > 0) diagnostic = Buffer.concat([diagnostic, chunk.subarray(0, remaining)]);
410
+ });
411
+ child.stdout.on("data", chunk => {
412
+ if (protocolError) return;
413
+ try {
414
+ pending = Buffer.concat([pending, chunk]);
415
+ let end;
416
+ while ((end = pending.indexOf(10)) >= 0) {
417
+ if (end > RECORD_LIMIT) throw new Error("peer_recovery_worker_message_too_large");
418
+ const message = JSON.parse(pending.subarray(0, end).toString("utf8"));
419
+ pending = pending.subarray(end + 1);
420
+ if (message?.type === "ack") continue;
421
+ if (!new Set(["checkpoint", "finish"]).has(message?.type)) {
422
+ throw new Error("peer_recovery_worker_message_invalid");
423
+ }
424
+ reportCheckpoint(task, id, message);
425
+ }
426
+ if (pending.length > RECORD_LIMIT) throw new Error("peer_recovery_worker_message_too_large");
427
+ } catch (error) { stop(error); }
428
+ });
429
+ child.once("error", error => { protocolError ??= error.message; });
430
+ child.once("close", (code, signal) => {
431
+ watcher?.close();
432
+ try { resolve(reportExit(task, id, { code, signal, protocolError,
433
+ diagnostic: diagnostic.toString("utf8") || null })); }
434
+ catch (error) { reject(error); }
435
+ });
436
+ try {
437
+ watcher = watch(task.directory, wake);
438
+ watcher.once("error", stop);
439
+ wake();
440
+ } catch (error) { stop(error); }
441
+ });
442
+ }
@@ -26,10 +26,14 @@ const SELF_NORMALIZED_QUICK_CORE_VERSIONS = new Set([
26
26
  "0.23.0",
27
27
  "0.24.0",
28
28
  "0.24.1",
29
+ "0.25.0",
29
30
  ]);
30
31
  const EXACT_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
31
32
  const SHA256_REF = /^sha256:[a-f0-9]{64}$/;
32
33
  const TRUSTED_PRIOR_CORE_IDENTITIES = new Map([
34
+ ["0.24.1", new Set([
35
+ "sha256:94aaa2e3a6d504374fa78cfcd341e31b33087806c9ddf5f84b3d3b47a6328e08",
36
+ ])],
33
37
  ["0.24.0", new Set([
34
38
  "sha256:02e3607701a275014fe95e95e025aeca7cac34ebe4a47e603b8c9db0e45596ff",
35
39
  ])],
@@ -92,6 +92,12 @@ import {
92
92
  formatInspectHuman,
93
93
  } from "./inspect-presentation.mjs";
94
94
  import { buildHookRecoveryV1 } from "./hook-recovery.mjs";
95
+ import {
96
+ createPeerRecoveryTask,
97
+ inspectPeerRecoveryTask,
98
+ recoverPeerTask,
99
+ runPeerTask,
100
+ } from "./peer-recovery.mjs";
95
101
  import { attestQuickReceiptDetails } from "./quick-attest.mjs";
96
102
  import { readLocalQuickMetrics } from "./quick-metrics.mjs";
97
103
  import { runQuickEvidenceReuse } from "./quick-evidence-reuse.mjs";
@@ -265,6 +271,7 @@ Agent team projects
265
271
  npx --no-install owlrunkit project <init|assign|checkpoint|handoff|reject-and-return|reopen-upstream|transfer|work-item revise-scope|work-item require-evidence|reconcile|delivery record|decision|verification|capture|target-snapshot|import-delivery|status|takeover|successor|successor scaffold> [options]
266
272
  npx --no-install owlrunkit team <recommend|status|packet validate> [options]
267
273
  Durable project truth plus read-only Team recommendation and packet validation. No Agent dispatch.
274
+ Managed local peer recovery: npx --no-install owlrunkit peer --help
268
275
  Core successor: npx --no-install owlrunkit core-successor <plan|apply|resume> --workspace . [...]
269
276
  Other onboarding commands:
270
277
  npx --no-install owlrunkit profiles validate --workspace .; npx --no-install owlrunkit profiles impact --workspace . --changed <path>
@@ -2443,6 +2450,52 @@ function requestCommand(options, handler) {
2443
2450
  return handler({ workspaceRoot: root, runId, request: readJson(requestPath) });
2444
2451
  }
2445
2452
 
2453
+ const PEER_HELP = `Usage:
2454
+ owlrunkit peer create --workspace <dir> --task <id> --request <json>
2455
+ owlrunkit peer inspect --workspace <dir> --task <id>
2456
+ owlrunkit peer run --workspace <dir> --task <id> --agent <id> --generation <n> -- /absolute/worker [args]
2457
+ owlrunkit peer recover --workspace <dir> --task <id> --agent <id> --generation <n> --revision <n> [--wait-ms <n>]
2458
+ `;
2459
+
2460
+ function runPeerCommand(rest) {
2461
+ const [action, ...args] = rest;
2462
+ const separator = args.indexOf("--");
2463
+ const optionArgs = separator === -1 ? args : args.slice(0, separator);
2464
+ if (["--help", "-h"].includes(action) || optionArgs.includes("--help") || optionArgs.includes("-h")) {
2465
+ return withHumanOutput({ status: "help", exitCode: 0, authorizationGranted: false }, PEER_HELP);
2466
+ }
2467
+ const flags = {
2468
+ create: ["request"], inspect: [], run: ["agent", "generation"],
2469
+ recover: ["agent", "generation", "revision", "wait-ms"],
2470
+ };
2471
+ if (!Object.hasOwn(flags, action) || (separator !== -1 && action !== "run")) {
2472
+ throw new Error(PEER_HELP);
2473
+ }
2474
+ const options = parseOptions(optionArgs);
2475
+ const allowed = new Set(["workspace", "task", "json", ...flags[action]]);
2476
+ if (Object.keys(options).some(key => !allowed.has(key))) throw new Error(PEER_HELP);
2477
+ const task = { workspaceRoot: workspace(options), taskId: requireOption(options, "task") };
2478
+ if (action === "inspect") return inspectPeerRecoveryTask(task);
2479
+ if (action === "create") {
2480
+ const request = readJson(realpathSync(requireOption(options, "request")));
2481
+ return createPeerRecoveryTask({ ...task, owner: request.owner,
2482
+ successor: request.successor, paths: request.paths });
2483
+ }
2484
+ const peer = {
2485
+ ...task, agent: requireOption(options, "agent"),
2486
+ generation: parseCanonicalNonnegativeSafeInteger(requireOption(options, "generation"), "generation"),
2487
+ };
2488
+ if (action === "run") {
2489
+ if (separator === -1) throw new Error(PEER_HELP);
2490
+ return runPeerTask({ ...peer, command: args.slice(separator + 1) });
2491
+ }
2492
+ return recoverPeerTask({ ...peer,
2493
+ revision: parseCanonicalNonnegativeSafeInteger(requireOption(options, "revision"), "revision"),
2494
+ waitMs: options["wait-ms"] === undefined ? undefined
2495
+ : parseCanonicalNonnegativeSafeInteger(options["wait-ms"], "wait-ms"),
2496
+ });
2497
+ }
2498
+
2446
2499
  export async function runCli(argv = process.argv.slice(2), runtimeHooks = {}) {
2447
2500
  try {
2448
2501
  const [command, ...rest] = argv;
@@ -2461,6 +2514,7 @@ export async function runCli(argv = process.argv.slice(2), runtimeHooks = {}) {
2461
2514
  authorizationGranted: false,
2462
2515
  }, `owlrunkit ${CORE_VERSION}\n`);
2463
2516
  }
2517
+ if (command === "peer") return await runPeerCommand(rest);
2464
2518
  const separatorIndex = rest.indexOf("--");
2465
2519
  const acceptsExactCommand = command === "verify"
2466
2520
  || command === "quick-verify"