opencode-session-port 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +84 -2
- package/package.json +56 -4
- package/src/core/contracts/crypto.ts +66 -0
- package/src/core/contracts/index.ts +13 -0
- package/src/core/contracts/json.ts +142 -0
- package/src/core/contracts/session.ts +12 -0
- package/src/core/crypto/container.ts +686 -0
- package/src/core/crypto/envelope.ts +232 -0
- package/src/core/crypto/errors.ts +52 -0
- package/src/core/crypto/gzip-stream.ts +388 -0
- package/src/core/crypto/index.ts +13 -0
- package/src/core/crypto/kdf.ts +101 -0
- package/src/core/crypto/policy.ts +202 -0
- package/src/core/crypto/zero.ts +33 -0
- package/src/core/restore/identity.ts +202 -0
- package/src/core/restore/index.ts +24 -0
- package/src/core/restore/planner.ts +1122 -0
- package/src/core/sessions/collector.ts +323 -0
- package/src/core/sessions/errors.ts +37 -0
- package/src/core/sessions/guards.ts +11 -0
- package/src/core/sessions/index.ts +29 -0
- package/src/core/sessions/policy.ts +80 -0
- package/src/core/sessions/port.ts +88 -0
- package/src/core/sessions/serialize.ts +15 -0
- package/src/core/sessions/unsettled.ts +35 -0
- package/src/integrations/omo/index.ts +28 -0
- package/src/tui/adapters/host-client.ts +254 -0
- package/src/tui/adapters/index.ts +72 -0
- package/src/tui/adapters/location-resolver.ts +281 -0
- package/src/tui/adapters/project-destinations.ts +271 -0
- package/src/tui/adapters/session-id-inventory.ts +378 -0
- package/src/tui/adapters/session-import.ts +189 -0
- package/src/tui/adapters/session-read.ts +115 -0
- package/src/tui/artifacts/files.ts +285 -0
- package/src/tui/orchestration/checkpoint.ts +160 -0
- package/src/tui/orchestration/index.ts +55 -0
- package/src/tui/orchestration/restore-auth.ts +94 -0
- package/src/tui/orchestration/restore-execution.ts +381 -0
- package/src/tui/orchestration/restore-preflight.ts +223 -0
- package/src/tui/orchestration/shared.ts +75 -0
- package/src/tui/ui/password-dialog.tsx +251 -0
- package/src/tui/ui/password-flow.ts +288 -0
- package/src/tui/ui/restore-project-selector.ts +62 -0
- package/src/tui/ui/restore-report-dialog.tsx +251 -0
- package/src/tui/ui/restore-report-flow.ts +94 -0
- package/src/tui/ui/sessionport-commands.tsx +65 -0
- package/src/tui/wiring/composition.ts +354 -0
- package/src/tui/wiring/experimental-profile.ts +86 -0
- package/src/tui/wiring/index.ts +51 -0
- package/src/tui/wiring/install.ts +108 -0
- package/src/tui/wiring/reports.ts +340 -0
- package/src/tui.ts +34 -0
- package/tsconfig.json +18 -0
- package/tui.ts +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maivand Rahmani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SessionPort
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Encrypted session checkpoints for OpenCode.**
|
|
4
|
+
|
|
5
|
+
SessionPort is a local plugin for the OpenCode TUI. It captures the sessions of your current project into a single password-encrypted artifact and restores them in another checkout, worktree, or machine. Artifacts travel with Git or any other channel you choose — SessionPort never runs Git commands itself.
|
|
6
|
+
|
|
7
|
+
> **Status: experimental pre-release (0.1.0).** SessionPort completed one bounded synthetic checkpoint/restore round trip on Windows with OpenCode v2.0.22. That test used test-only password and project-selector seams and staged source, not the npm tarball. Real modal interactions and loading the packed package in OpenCode have not been verified. The checkpoint format may change; no compatibility, security, or platform-support guarantee is made.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
- **`/checkpoint [artifact-path]`** — collects the current project's sessions as returned by the host (child/subagent and archived sessions included when the host returns them) and writes one encrypted artifact. It fails cleanly, with no artifact, when the set is empty, a session cannot be captured completely, or the output file already exists.
|
|
12
|
+
- **`/restore <artifact-path>`** — decrypts and validates the artifact before session imports, then restores it into an existing project you select. Restore does not overwrite existing sessions: exact destination matches are skipped, conflicting IDs are remapped deterministically, and parents are imported before children.
|
|
13
|
+
|
|
14
|
+
Both commands are registered by the plugin as `sessionport.checkpoint` and `sessionport.restore`, and appear in the slash-command list and the command palette.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- OpenCode with the v2 TUI plugin API (exercised with OpenCode v2.0.22).
|
|
19
|
+
- Windows. The current build enables the commands on Windows only; macOS and Linux are intended targets but are not yet enabled or validated.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Install the package plugin with the OpenCode CLI:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
opencode plugin add opencode-session-port@0.1.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or add it to the `plugins` array in your `opencode.jsonc` (or `opencode.json`):
|
|
30
|
+
|
|
31
|
+
```jsonc
|
|
32
|
+
{
|
|
33
|
+
"$schema": "https://opencode.ai/config.json",
|
|
34
|
+
"plugins": ["opencode-session-port@0.1.0"]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
These are the documented OpenCode v2 setup paths; the published tarball has not yet been loaded by OpenCode. The final tarball passed npm consumer checks, but a direct Bun package-manager install timed out in the verification environment, so that install path remains unverified.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
### Checkpoint
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
/checkpoint [artifact-path]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Without an argument, the artifact is written to `.sessionport/checkpoint-<UTC timestamp>.sessionport` inside the current project. An argument overrides the location (absolute, or relative to the project). An existing file is never overwritten — a collision fails the checkpoint.
|
|
49
|
+
|
|
50
|
+
Checkpoint prompts for a password twice in a masked input (paste is allowed); both entries must match. Strength guidance is advisory — weak passwords are not rejected.
|
|
51
|
+
|
|
52
|
+
### Restore
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
/restore <artifact-path>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Restore first asks which existing project to import into, then prompts for the password (up to three attempts). The artifact is fully checked before the first session import. Project resolution may still update bounded host project metadata through OpenCode's public `location.get` API. Imports run parents-first, one session at a time: independent branches continue after a failure, descendants of a failed parent are blocked, and outcomes are reported per session. There is no atomic rollback. A later manual rerun may skip exact matching records, but exact retry behavior on a real host has not been established. Imported sessions are not opened or navigated to automatically.
|
|
59
|
+
|
|
60
|
+
### Moving artifacts
|
|
61
|
+
|
|
62
|
+
Move the artifact with Git (commit it, stash it, or sync it your own way) or any other channel. SessionPort never runs Git commands, and the password is never stored with the artifact or in the repository — if you lose it, the artifact cannot be recovered.
|
|
63
|
+
|
|
64
|
+
## Security notes
|
|
65
|
+
|
|
66
|
+
- Encryption is mandatory: no plaintext fallback, no plaintext temporary file.
|
|
67
|
+
- Payloads are compressed, then encrypted with an Argon2id-derived key and XChaCha20-Poly1305 secretstream; truncated or tampered artifacts are rejected.
|
|
68
|
+
- Password input is masked and capped at 4 KiB of UTF-8. SessionPort does not intentionally log it, write it to disk, or copy it to the clipboard. JavaScript strings cannot be reliably zeroed; no secure-erasure claim is made.
|
|
69
|
+
- Protection applies to the artifact at rest only. Git history and metadata, a compromised machine, and secrets copied into session content are outside SessionPort's protection; no secure-deletion promise is made.
|
|
70
|
+
- SessionPort makes no model/LLM calls and never replays historical tool calls.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
bun install --frozen-lockfile
|
|
76
|
+
bun run typecheck
|
|
77
|
+
bun test
|
|
78
|
+
bun run verify:package # host-free package verification
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Design and validation records live under `docs/` in this repository; they are not part of the published package.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT © 2026 Maivand Rahmani
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-session-port",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "SessionPort: experimental Windows-first OpenCode TUI plugin for password-encrypted session checkpoint and restore.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"opencode",
|
|
9
|
+
"opencode-plugin",
|
|
10
|
+
"tui",
|
|
11
|
+
"session",
|
|
12
|
+
"checkpoint",
|
|
13
|
+
"restore",
|
|
14
|
+
"encryption"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"files": [
|
|
18
|
+
"tui.ts",
|
|
19
|
+
"src/**",
|
|
20
|
+
"tsconfig.json"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./tui": "./src/tui.ts"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"verify:package": "bun run scripts/package-verify/verify.ts"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@opencode/plugin": "2.0.22",
|
|
32
|
+
"libsodium-wrappers-sumo": "0.8.4",
|
|
33
|
+
"pako": "3.0.2"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@opentui/core": ">=0.5.14",
|
|
37
|
+
"@opentui/solid": ">=0.5.14",
|
|
38
|
+
"solid-js": ">=1.9.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@opentui/core": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"@opentui/solid": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"solid-js": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@opentui/core": "0.5.14",
|
|
53
|
+
"@opentui/solid": "0.5.14",
|
|
54
|
+
"@types/node": "24.12.2",
|
|
55
|
+
"solid-js": "1.9.15",
|
|
56
|
+
"typescript": "5.9.3"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot byte source. Each yielded chunk is borrowed: producers must not
|
|
3
|
+
* mutate it while the consumer is processing it; consumers must copy a chunk
|
|
4
|
+
* if they retain it after advancing the source.
|
|
5
|
+
*/
|
|
6
|
+
export type ByteSource = AsyncIterable<Uint8Array>;
|
|
7
|
+
|
|
8
|
+
/** Raw in-memory password bytes; text encoding/normalization remains an explicit open decision. */
|
|
9
|
+
export type PasswordMaterial = Uint8Array;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fixed byte length of an `ArtifactIdentity` (128 bits). The owner-selected
|
|
13
|
+
* O-22 scheme (2026-10-09) generates one random identity per checkpoint
|
|
14
|
+
* artifact; its exact artifact placement remains PROVISIONAL (O-02).
|
|
15
|
+
*/
|
|
16
|
+
export const ARTIFACT_IDENTITY_BYTES = 16;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Opaque per-artifact identity: exactly 16 random bytes (128 bits) generated
|
|
20
|
+
* once per `seal` and authenticated as part of the artifact. It is public
|
|
21
|
+
* artifact data, not a secret, and must never be confused with the Argon2
|
|
22
|
+
* salt. Callers must treat it as opaque and use it only as the stable input
|
|
23
|
+
* for deterministic remapped-ID derivation (O-22).
|
|
24
|
+
*/
|
|
25
|
+
export type ArtifactIdentity = Uint8Array;
|
|
26
|
+
|
|
27
|
+
/** Opaque identifier for an explicitly selected, allow-listed crypto policy. */
|
|
28
|
+
export interface CryptoPolicy {
|
|
29
|
+
/** A code-selected profile identifier; never take policy values from an artifact. */
|
|
30
|
+
readonly id: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fully authenticated and decompressed bytes, staged in bounded memory.
|
|
35
|
+
* This value is returned only after the complete stream (including final tag),
|
|
36
|
+
* clean gzip termination, and configured bounds have all passed. The caller
|
|
37
|
+
* still must parse/validate the payload and preflight every session before any
|
|
38
|
+
* import. Ownership of `bytes` transfers exclusively to the caller; callers
|
|
39
|
+
* MUST treat it as immutable and must not retain/mutate aliases before parsing
|
|
40
|
+
* and preflight. No partial plaintext stream/callback is exposed by this boundary.
|
|
41
|
+
*/
|
|
42
|
+
export interface AuthenticatedPayload {
|
|
43
|
+
readonly bytes: Uint8Array;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The artifact's authenticated identity, surfaced under the same conditions
|
|
47
|
+
* as `bytes`: only after the complete stream (including the final tag),
|
|
48
|
+
* clean strict-gzip end-of-input, and every configured bound have passed.
|
|
49
|
+
* Caller-owned copy; opaque; never secret. Not exposed on any failure path.
|
|
50
|
+
*/
|
|
51
|
+
readonly artifactIdentity: ArtifactIdentity;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Crypto service contract. `create` requires an explicit, allow-listed policy; no default profile is implied. */
|
|
55
|
+
export interface CheckpointCrypto {
|
|
56
|
+
/** Promise boundary permits asynchronous execution; it does not guarantee the synchronous KDF will not block. */
|
|
57
|
+
seal(plaintext: ByteSource, password: PasswordMaterial): Promise<ByteSource>;
|
|
58
|
+
|
|
59
|
+
/** Must reject until the entire artifact has authenticated and decompressed successfully. */
|
|
60
|
+
openAndStage(artifact: ByteSource, password: PasswordMaterial): Promise<AuthenticatedPayload>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Public construction boundary: omitting policy is a type error; factories must reject unknown IDs. */
|
|
64
|
+
export interface CheckpointCryptoFactory {
|
|
65
|
+
create(policy: CryptoPolicy): Promise<CheckpointCrypto>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { encodeJson, snapshotJsonValue } from "./json.ts";
|
|
2
|
+
export type { JsonObject, JsonPrimitive, JsonValue } from "./json.ts";
|
|
3
|
+
export { ARTIFACT_IDENTITY_BYTES } from "./crypto.ts";
|
|
4
|
+
export type {
|
|
5
|
+
ArtifactIdentity,
|
|
6
|
+
AuthenticatedPayload,
|
|
7
|
+
ByteSource,
|
|
8
|
+
CheckpointCrypto,
|
|
9
|
+
CheckpointCryptoFactory,
|
|
10
|
+
CryptoPolicy,
|
|
11
|
+
PasswordMaterial,
|
|
12
|
+
} from "./crypto.ts";
|
|
13
|
+
export type { CheckpointPayload, SessionRecord } from "./session.ts";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** JSON-compatible values accepted at the SessionPort payload boundary. */
|
|
2
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
3
|
+
|
|
4
|
+
export type JsonValue =
|
|
5
|
+
| JsonPrimitive
|
|
6
|
+
| readonly JsonValue[]
|
|
7
|
+
| { readonly [key: string]: JsonValue };
|
|
8
|
+
|
|
9
|
+
export type JsonObject = { readonly [key: string]: JsonValue };
|
|
10
|
+
|
|
11
|
+
type VisitFrame = {
|
|
12
|
+
kind: "visit";
|
|
13
|
+
value: unknown;
|
|
14
|
+
path: string;
|
|
15
|
+
assign: (copy: JsonValue) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type LeaveFrame = { kind: "leave"; value: object };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Copies a JSON-only snapshot, rejecting data that JSON would omit or coerce.
|
|
22
|
+
* Copies use null prototypes so inherited `toJSON` hooks cannot change the
|
|
23
|
+
* serialized representation. The traversal is iterative to avoid JS call-stack
|
|
24
|
+
* recursion. Resource limits are enforced by higher layers.
|
|
25
|
+
*
|
|
26
|
+
* `-0` is rejected because JSON.stringify would serialize it as `0`.
|
|
27
|
+
*/
|
|
28
|
+
export function snapshotJsonValue(value: unknown, rootPath = "$"): JsonValue {
|
|
29
|
+
let root: JsonValue | undefined;
|
|
30
|
+
const active = new WeakSet<object>();
|
|
31
|
+
const stack: Array<VisitFrame | LeaveFrame> = [
|
|
32
|
+
{ kind: "visit", value, path: rootPath, assign: (copy) => (root = copy) },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
while (stack.length > 0) {
|
|
36
|
+
const frame = stack.pop()!;
|
|
37
|
+
if (frame.kind === "leave") {
|
|
38
|
+
active.delete(frame.value);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const current = frame.value;
|
|
43
|
+
if (current === null || typeof current === "string" || typeof current === "boolean") {
|
|
44
|
+
frame.assign(current);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (typeof current === "number") {
|
|
48
|
+
if (!Number.isFinite(current)) throw new TypeError(`Non-finite number at ${frame.path}`);
|
|
49
|
+
if (Object.is(current, -0)) throw new TypeError(`Negative zero is not JSON-stable at ${frame.path}`);
|
|
50
|
+
frame.assign(current);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (typeof current !== "object") {
|
|
54
|
+
throw new TypeError(`Non-JSON value (${typeof current}) at ${frame.path}`);
|
|
55
|
+
}
|
|
56
|
+
if (active.has(current)) throw new TypeError(`Cyclic value at ${frame.path}`);
|
|
57
|
+
|
|
58
|
+
if (Array.isArray(current)) {
|
|
59
|
+
const prototype = Object.getPrototypeOf(current);
|
|
60
|
+
if (prototype !== Array.prototype && prototype !== null) {
|
|
61
|
+
throw new TypeError(`Non-plain array at ${frame.path}`);
|
|
62
|
+
}
|
|
63
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(current, "length");
|
|
64
|
+
const length = lengthDescriptor?.value;
|
|
65
|
+
if (!Number.isSafeInteger(length) || length < 0) {
|
|
66
|
+
throw new TypeError(`Invalid array length at ${frame.path}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const values = new Map<number, unknown>();
|
|
70
|
+
for (const key of Reflect.ownKeys(current)) {
|
|
71
|
+
if (key === "length") continue;
|
|
72
|
+
if (typeof key !== "string" || !/^(0|[1-9]\d*)$/.test(key)) {
|
|
73
|
+
throw new TypeError(`Non-JSON array property at ${frame.path}`);
|
|
74
|
+
}
|
|
75
|
+
const index = Number(key);
|
|
76
|
+
if (!Number.isSafeInteger(index) || index >= length) {
|
|
77
|
+
throw new TypeError(`Unsupported array entry at ${frame.path}[${key}]`);
|
|
78
|
+
}
|
|
79
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
80
|
+
if (!descriptor?.enumerable || !("value" in descriptor)) {
|
|
81
|
+
throw new TypeError(`Accessor or hidden array entry at ${frame.path}[${key}]`);
|
|
82
|
+
}
|
|
83
|
+
values.set(index, descriptor.value);
|
|
84
|
+
}
|
|
85
|
+
if (values.size !== length) throw new TypeError(`Sparse array at ${frame.path}`);
|
|
86
|
+
|
|
87
|
+
const copy = new Array<JsonValue>(length);
|
|
88
|
+
Object.setPrototypeOf(copy, null);
|
|
89
|
+
frame.assign(copy);
|
|
90
|
+
active.add(current);
|
|
91
|
+
stack.push({ kind: "leave", value: current });
|
|
92
|
+
for (let index = length - 1; index >= 0; index--) {
|
|
93
|
+
stack.push({
|
|
94
|
+
kind: "visit",
|
|
95
|
+
value: values.get(index),
|
|
96
|
+
path: `${frame.path}[${index}]`,
|
|
97
|
+
assign: (child) => (copy[index] = child),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const prototype = Object.getPrototypeOf(current);
|
|
104
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
105
|
+
throw new TypeError(`Non-plain object at ${frame.path}`);
|
|
106
|
+
}
|
|
107
|
+
const entries: Array<{ key: string; value: unknown }> = [];
|
|
108
|
+
for (const key of Reflect.ownKeys(current)) {
|
|
109
|
+
if (typeof key !== "string") throw new TypeError(`Symbol property at ${frame.path}`);
|
|
110
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
111
|
+
if (!descriptor?.enumerable || !("value" in descriptor)) {
|
|
112
|
+
throw new TypeError(`Non-enumerable or accessor property at ${frame.path}[${JSON.stringify(key)}]`);
|
|
113
|
+
}
|
|
114
|
+
entries.push({ key, value: descriptor.value });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const copy = Object.create(null) as { [key: string]: JsonValue };
|
|
118
|
+
frame.assign(copy);
|
|
119
|
+
active.add(current);
|
|
120
|
+
stack.push({ kind: "leave", value: current });
|
|
121
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
122
|
+
const entry = entries[index]!;
|
|
123
|
+
stack.push({
|
|
124
|
+
kind: "visit",
|
|
125
|
+
value: entry.value,
|
|
126
|
+
path: `${frame.path}[${JSON.stringify(entry.key)}]`,
|
|
127
|
+
assign: (child) => (copy[entry.key] = child),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (root === undefined) throw new TypeError("JSON snapshot was not produced");
|
|
133
|
+
return root;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Serializes a detached JSON snapshot; inherited hooks and source mutation cannot rewrite it. */
|
|
137
|
+
export function encodeJson(value: unknown): Uint8Array {
|
|
138
|
+
const snapshot = snapshotJsonValue(value);
|
|
139
|
+
const encoded = JSON.stringify(snapshot);
|
|
140
|
+
if (encoded === undefined) throw new TypeError("Value could not be serialized as JSON");
|
|
141
|
+
return new TextEncoder().encode(encoded);
|
|
142
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { JsonObject } from "./json.ts";
|
|
2
|
+
|
|
3
|
+
/** OpenCode transfer object retained as JSON without narrowing message variants. */
|
|
4
|
+
export interface SessionRecord {
|
|
5
|
+
readonly info: JsonObject;
|
|
6
|
+
readonly messages: readonly JsonObject[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Payload shared between session collection, serialization, and restore preflight. */
|
|
10
|
+
export interface CheckpointPayload {
|
|
11
|
+
readonly sessions: readonly SessionRecord[];
|
|
12
|
+
}
|