@dopamint-fun/open-sdk 0.1.0-dev.0 → 0.2.0-dev.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.
@@ -0,0 +1,45 @@
1
+ /** The one move a refused caller makes next. */
2
+ export type RefusalNext = {
3
+ action: "retry";
4
+ after_ms: number;
5
+ } | {
6
+ action: "wait";
7
+ until_ms?: number;
8
+ poll?: string;
9
+ } | {
10
+ action: "read";
11
+ route: string;
12
+ } | {
13
+ action: "ask_owner";
14
+ what: "claim" | "hand_out" | "invite" | "sign";
15
+ } | {
16
+ action: "use";
17
+ command: string;
18
+ } | {
19
+ action: "stop";
20
+ };
21
+ /** Every refusal shape the product answers with, read loosely: the identity
22
+ * and offer routes say `detail`, the agent and table routes say `message`. */
23
+ export interface RefusalBody {
24
+ code?: string;
25
+ detail?: string;
26
+ message?: string;
27
+ retryable?: boolean;
28
+ next?: RefusalNext;
29
+ docs?: string;
30
+ }
31
+ /** `next …`, as one line an agent can act on. */
32
+ export declare function describeNext(next: RefusalNext): string;
33
+ /** The lines a refusal prints: its code, what it said, the move, the entry. */
34
+ export declare function refusalLines(body: unknown): string[];
35
+ /** `<what> refused (<status>)`, then the refusal's own lines. A body that is
36
+ * not a refusal the product named is printed as it came. */
37
+ export declare function refusalMessage(what: string, status: number, body: unknown): string;
38
+ /** The same, for a body still in the text it arrived as. */
39
+ export declare function refusalMessageFromText(what: string, status: number, text: string): string;
40
+ /** A refusal this client decides on its own, before the product is asked,
41
+ * carrying a move in the same shape the product's refusals do. */
42
+ export declare class ClientRefusal extends Error {
43
+ readonly next: RefusalNext;
44
+ constructor(message: string, next: RefusalNext);
45
+ }
@@ -0,0 +1,84 @@
1
+ /* What a refused agent does next, as the product names it.
2
+ *
3
+ * Every refusal body carries `code`, and since the envelope grew one, `next`:
4
+ * a closed set of moves with their parameters, and `docs`, the code's entry in
5
+ * the errors reference. The client prints all of it verbatim, so an agent
6
+ * reading a refused command's output reads its move off the same lines rather
7
+ * than off a table in a skill document. */
8
+ /** `next …`, as one line an agent can act on. */
9
+ export function describeNext(next) {
10
+ switch (next.action) {
11
+ case "retry":
12
+ return `next retry after ${next.after_ms} ms`;
13
+ case "wait": {
14
+ const parts = ["next wait"];
15
+ if (next.until_ms !== undefined)
16
+ parts.push(`until ${new Date(next.until_ms).toISOString()}`);
17
+ if (next.poll !== undefined)
18
+ parts.push(`poll ${next.poll}`);
19
+ return parts.join(" ");
20
+ }
21
+ case "read":
22
+ return `next read ${next.route}`;
23
+ case "ask_owner":
24
+ return `next ask_owner ${next.what}`;
25
+ case "use":
26
+ return `next use ${next.command}`;
27
+ case "stop":
28
+ return "next stop";
29
+ }
30
+ }
31
+ const isNext = (value) => typeof value === "object" &&
32
+ value !== null &&
33
+ typeof value.action === "string";
34
+ /** The lines a refusal prints: its code, what it said, the move, the entry. */
35
+ export function refusalLines(body) {
36
+ if (typeof body !== "object" || body === null)
37
+ return [];
38
+ const refusal = body;
39
+ const lines = [];
40
+ if (refusal.code)
41
+ lines.push(`code ${refusal.code}`);
42
+ const said = refusal.detail ?? refusal.message;
43
+ if (said)
44
+ lines.push(`detail ${said}`);
45
+ if (isNext(refusal.next))
46
+ lines.push(describeNext(refusal.next));
47
+ if (refusal.docs)
48
+ lines.push(`docs ${refusal.docs}`);
49
+ return lines;
50
+ }
51
+ /** `<what> refused (<status>)`, then the refusal's own lines. A body that is
52
+ * not a refusal the product named is printed as it came. */
53
+ export function refusalMessage(what, status, body) {
54
+ const lines = refusalLines(body);
55
+ if (lines.length === 0) {
56
+ /* A body the product did not write -- a proxy's 502 while it restarts --
57
+ arrives empty, and `JSON.stringify(undefined)` printed "undefined". */
58
+ const said = body === undefined || body === null
59
+ ? "no body"
60
+ : typeof body === "string"
61
+ ? body
62
+ : JSON.stringify(body);
63
+ return `${what} refused (${status}): ${said}`;
64
+ }
65
+ return [`${what} refused (${status})`, ...lines].join("\n");
66
+ }
67
+ /** The same, for a body still in the text it arrived as. */
68
+ export function refusalMessageFromText(what, status, text) {
69
+ try {
70
+ return refusalMessage(what, status, JSON.parse(text));
71
+ }
72
+ catch {
73
+ return `${what} refused (${status}): ${text}`;
74
+ }
75
+ }
76
+ /** A refusal this client decides on its own, before the product is asked,
77
+ * carrying a move in the same shape the product's refusals do. */
78
+ export class ClientRefusal extends Error {
79
+ next;
80
+ constructor(message, next) {
81
+ super(`${message}\n${describeNext(next)}`);
82
+ this.next = next;
83
+ }
84
+ }
package/dist/room.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { AgentKeypair } from "./keypair.js";
2
+ export interface RoomClient {
3
+ productUrl: string;
4
+ agent: AgentKeypair;
5
+ /** 32-byte agent id, as the arena registered it. */
6
+ agentId: Uint8Array;
7
+ }
8
+ /** A room as the product answers it on create. */
9
+ export interface RoomOpened {
10
+ tableId: string;
11
+ seatCount: number;
12
+ settlement: string;
13
+ mode: string;
14
+ /** Present once the authority has an execution behind the table. */
15
+ executionId?: string;
16
+ }
17
+ /** A join. `offerId` and `seat` are present on an authority stack, which is
18
+ * where the guest's seat is taken by accepting an offer with its own key. On
19
+ * a mock stack the room is joined and there is nothing to accept. */
20
+ export interface RoomJoined {
21
+ tableId: string;
22
+ joined: boolean;
23
+ offerId?: string;
24
+ seat?: number;
25
+ }
26
+ /** The seat counts a private room can be opened with, both ends included.
27
+ *
28
+ * Refused here as well as by the server: a room is opened once and its size
29
+ * cannot be changed afterwards, so an out-of-range number is better caught
30
+ * before the agent tells anybody a room exists. */
31
+ export declare const MIN_ROOM_SEATS = 2;
32
+ export declare const MAX_ROOM_SEATS = 10;
33
+ /** Open a room this agent will host. Seats above the two taken are house. */
34
+ export declare function openRoom(client: RoomClient, seatCount: number): Promise<RoomOpened>;
35
+ /** A join the product turned down, with the code it named. */
36
+ export declare class RoomJoinRefusal extends Error {
37
+ readonly status: number;
38
+ readonly code: string | undefined;
39
+ constructor(status: number, code: string | undefined, body: unknown);
40
+ }
41
+ /** Join a room by the id its opener handed out. */
42
+ export declare function joinRoom(client: RoomClient, tableId: string): Promise<RoomJoined>;
43
+ /** Join, and wait for the room to fill when this agent already holds a seat
44
+ * in it.
45
+ *
46
+ * A room is opened with its opener's seat already reserved, so the opener's
47
+ * own join answers `already_seated` until the guest arrives and the room
48
+ * composes into an offer; after that the same join answers the opener's seat.
49
+ * The document tells the opener to sit down with this command, and it used to
50
+ * end on that first refusal: an opener who sat before its guest arrived had
51
+ * nothing running when the room composed. So `already_seated` is waited out,
52
+ * as the fill timer is on the queue, and every other refusal still ends here. */
53
+ export declare function joinRoomWhenComposed(client: RoomClient, tableId: string, options?: {
54
+ pollMs?: number;
55
+ timeoutMs?: number;
56
+ /** Called once, the first time the room is found still waiting. */
57
+ onWaiting?: () => void;
58
+ sleep?: (ms: number) => Promise<void>;
59
+ now?: () => number;
60
+ }): Promise<RoomJoined>;
61
+ /** The one line an opener hands its guest, which is the whole invitation.
62
+ *
63
+ * A prompt rather than a URL: what the guest needs is the document that says
64
+ * what to do and the id to do it with, and an operator pasting two things in
65
+ * the right order is how a room went unjoined. */
66
+ export declare function roomInvitePrompt(productUrl: string, tableId: string): string;
package/dist/room.js ADDED
@@ -0,0 +1,154 @@
1
+ /* The private room, on this agent's own key.
2
+ *
3
+ * Two calls: an agent opens a room and is handed a table id, and a second
4
+ * agent joins by that id. On an authority stack the join answers the offer the
5
+ * room composed, and the guest sits down by accepting it — the product holds no
6
+ * key that could accept for either of them, which is why a room is a
7
+ * reservation rather than a seat already taken.
8
+ *
9
+ * Both calls are signed the way every other Product API mutation is: an
10
+ * `AgentHttpCapability` over the exact method, target and body. Nothing here
11
+ * hand-rolls that; it is the same minting the tour path uses.
12
+ */
13
+ import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
14
+ import { textBytes } from "./bytes.js";
15
+ import { refusalMessage } from "./refusal.js";
16
+ async function signedFetch(client, method, target, body) {
17
+ const payload = body === undefined ? new Uint8Array() : textBytes(JSON.stringify(body));
18
+ const binding = {
19
+ method,
20
+ requestTarget: target,
21
+ body: payload,
22
+ };
23
+ const { header } = await mintAgentHttpCapability(client.agent, client.agentId, binding);
24
+ const headers = {
25
+ [AGENT_HTTP_CAPABILITY_HEADER]: header,
26
+ };
27
+ if (body !== undefined)
28
+ headers["content-type"] = "application/json";
29
+ const response = await fetch(`${client.productUrl.replace(/\/$/, "")}${target}`, {
30
+ method,
31
+ headers,
32
+ /* The capability covers these exact bytes, so the body is sent verbatim
33
+ rather than re-serialized by fetch from an object. */
34
+ body: body === undefined ? undefined : payload,
35
+ });
36
+ const text = await response.text();
37
+ return {
38
+ status: response.status,
39
+ json: text.length === 0 ? undefined : JSON.parse(text),
40
+ };
41
+ }
42
+ /** The seat counts a private room can be opened with, both ends included.
43
+ *
44
+ * Refused here as well as by the server: a room is opened once and its size
45
+ * cannot be changed afterwards, so an out-of-range number is better caught
46
+ * before the agent tells anybody a room exists. */
47
+ export const MIN_ROOM_SEATS = 2;
48
+ export const MAX_ROOM_SEATS = 10;
49
+ /** Open a room this agent will host. Seats above the two taken are house. */
50
+ export async function openRoom(client, seatCount) {
51
+ if (!Number.isInteger(seatCount) ||
52
+ seatCount < MIN_ROOM_SEATS ||
53
+ seatCount > MAX_ROOM_SEATS)
54
+ throw new Error(`a private room seats ${MIN_ROOM_SEATS} to ${MAX_ROOM_SEATS}, not ${seatCount}`);
55
+ const { status, json } = await signedFetch(client, "POST", "/open/v1/tables/private-rooms", { seatCount });
56
+ if (status !== 201) {
57
+ if (json?.code === "agent_not_claimed")
58
+ throw notClaimed();
59
+ throw new Error(refusalMessage("room open", status, json));
60
+ }
61
+ return {
62
+ tableId: json.tableId,
63
+ seatCount: json.seatCount,
64
+ settlement: json.settlement,
65
+ mode: json.mode,
66
+ executionId: json.executionId ?? undefined,
67
+ };
68
+ }
69
+ /** The precondition both calls share, said as something to do.
70
+ *
71
+ * A private room is watchable live only by the wallets that own its seats, so
72
+ * every seat has to be claimed -- the opener and the guest alike. Checked when
73
+ * the room is opened and again when somebody joins, rather than discovered by
74
+ * an owner who cannot watch. */
75
+ function notClaimed() {
76
+ return new Error("this agent is not claimed, and every seat of a private room must be: " +
77
+ "the claim names the wallet allowed to watch the room while it plays. " +
78
+ "Run `dopa-open claim-invite` and give the link to the wallet that owns " +
79
+ "this agent.\nnext ask_owner claim");
80
+ }
81
+ /** A join the product turned down, with the code it named. */
82
+ export class RoomJoinRefusal extends Error {
83
+ status;
84
+ code;
85
+ constructor(status, code, body) {
86
+ super(refusalMessage("room join", status, body));
87
+ this.status = status;
88
+ this.code = code;
89
+ }
90
+ }
91
+ /** Join a room by the id its opener handed out. */
92
+ export async function joinRoom(client, tableId) {
93
+ const target = `/open/v1/tables/private-rooms/${tableId}/join`;
94
+ const { status, json } = await signedFetch(client, "POST", target);
95
+ if (status !== 201) {
96
+ if (json?.code === "agent_not_claimed")
97
+ throw notClaimed();
98
+ if (json?.code === "private_room_finished")
99
+ throw new Error(`room ${tableId} has played its sitting, so it seats nobody again; ` +
100
+ "its record is on the history route, and a new room is opened the way this one was\nnext stop");
101
+ throw new RoomJoinRefusal(status, json?.code, json);
102
+ }
103
+ return {
104
+ tableId: json.tableId,
105
+ joined: json.joined === true,
106
+ offerId: json.offerId ?? undefined,
107
+ seat: json.seat ?? undefined,
108
+ };
109
+ }
110
+ /** Join, and wait for the room to fill when this agent already holds a seat
111
+ * in it.
112
+ *
113
+ * A room is opened with its opener's seat already reserved, so the opener's
114
+ * own join answers `already_seated` until the guest arrives and the room
115
+ * composes into an offer; after that the same join answers the opener's seat.
116
+ * The document tells the opener to sit down with this command, and it used to
117
+ * end on that first refusal: an opener who sat before its guest arrived had
118
+ * nothing running when the room composed. So `already_seated` is waited out,
119
+ * as the fill timer is on the queue, and every other refusal still ends here. */
120
+ export async function joinRoomWhenComposed(client, tableId, options = {}) {
121
+ const pollMs = options.pollMs ?? 2_000;
122
+ const timeoutMs = options.timeoutMs ?? 30 * 60_000;
123
+ const sleep = options.sleep ??
124
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
125
+ const now = options.now ?? Date.now;
126
+ const deadline = now() + timeoutMs;
127
+ let told = false;
128
+ for (;;) {
129
+ try {
130
+ return await joinRoom(client, tableId);
131
+ }
132
+ catch (error) {
133
+ if (!(error instanceof RoomJoinRefusal) || error.code !== "already_seated")
134
+ throw error;
135
+ if (now() >= deadline)
136
+ throw new Error(`room ${tableId} still had nobody in its other seat after ${timeoutMs} ms; ` +
137
+ "send the invite line to the guest, or open the room again later");
138
+ if (!told) {
139
+ told = true;
140
+ options.onWaiting?.();
141
+ }
142
+ await sleep(pollMs);
143
+ }
144
+ }
145
+ }
146
+ /** The one line an opener hands its guest, which is the whole invitation.
147
+ *
148
+ * A prompt rather than a URL: what the guest needs is the document that says
149
+ * what to do and the id to do it with, and an operator pasting two things in
150
+ * the right order is how a room went unjoined. */
151
+ export function roomInvitePrompt(productUrl, tableId) {
152
+ const base = productUrl.replace(/\/$/, "");
153
+ return `read ${base}/skills/private-room.md and join table ${tableId}`;
154
+ }
@@ -0,0 +1,91 @@
1
+ import type { SessionContext } from "./sessionWire.js";
2
+ import type { ResumeCursor, ViewSnapshot } from "./sessionCodec.js";
3
+ /** Written beside the key file, as `.dopa-keypair` is. */
4
+ export declare const DEFAULT_SEAT_STATE_FILE = ".dopa-seat";
5
+ export interface SeatSessionState {
6
+ /** Which arena, and which offer the seat was admitted through. */
7
+ productUrl: string;
8
+ offerId: string;
9
+ seat: number;
10
+ /** 32 bytes, `0x`-prefixed: the client nonce this session is keyed by. */
11
+ clientNonce: string;
12
+ /** Absent until the first join answers with one. */
13
+ context: SerialisedContext | null;
14
+ /** The session token the authority issued at join.
15
+ *
16
+ * Kept because `resume` only re-issues one when the authority chooses to,
17
+ * and a fresh process that resumed without a token has nothing to
18
+ * authenticate its next request with -- which surfaces as "session token
19
+ * missing" at the moment it tries to act, not at the moment it resumed. */
20
+ token: string | null;
21
+ cursor: SerialisedCursor;
22
+ /** The position `turn` last printed, so `act` answers that one. */
23
+ view: SerialisedView | null;
24
+ /** The state nonce of the position `act` last answered, as a decimal string.
25
+ *
26
+ * `act` does not move the cursor -- the commit it causes is read by the next
27
+ * `turn` -- so that `turn` resumes from before the answered position and is
28
+ * handed it again. Without this it printed the same turn as `yours`, `act`
29
+ * signed it again, and the loop spun on an answered turn until its clock
30
+ * ran out. Absent in a file written before it existed. */
31
+ answeredNonce?: string | null;
32
+ /** The agent this seat plays as, `0x`-prefixed, once `turn` has found it.
33
+ *
34
+ * Kept so `act` needs nothing the first `turn` did not already learn: the
35
+ * roster lists an agent under the wallet that claimed it, so a key whose
36
+ * agent registered itself and was never claimed found no agent there, and
37
+ * the documented `act` refused its first turn. Absent in a file written
38
+ * before it existed. */
39
+ agentId?: string | null;
40
+ }
41
+ interface SerialisedContext {
42
+ wireVersion: number;
43
+ sessionVersion: number;
44
+ sessionId: string;
45
+ executionId: string;
46
+ executionManifestDigest: string;
47
+ protocolId: string;
48
+ protocolVersion: number;
49
+ participantId: string;
50
+ seat: number;
51
+ }
52
+ interface SerialisedState {
53
+ nonce: string;
54
+ commitment: string;
55
+ }
56
+ interface SerialisedReceipt {
57
+ digest: string;
58
+ resultingState: SerialisedState;
59
+ }
60
+ interface SerialisedCursor {
61
+ sequence: string;
62
+ witnessedReceipt: SerialisedReceipt | null;
63
+ }
64
+ interface SerialisedView {
65
+ state: SerialisedState;
66
+ participantView: string;
67
+ participantViewSchema: number;
68
+ legalActions: string;
69
+ legalActionsSchema: number;
70
+ participantDeadlineMs: string;
71
+ latestReceipt: SerialisedReceipt | null;
72
+ }
73
+ export declare function encodeContext(context: SessionContext): SerialisedContext;
74
+ export declare function decodeContext(stored: SerialisedContext): SessionContext;
75
+ export declare function encodeCursor(cursor: ResumeCursor): SerialisedCursor;
76
+ export declare function decodeCursor(stored: SerialisedCursor): ResumeCursor;
77
+ export declare function encodeView(view: ViewSnapshot): SerialisedView;
78
+ export declare function decodeView(stored: SerialisedView): ViewSnapshot;
79
+ /** Read the seat's state, or null where it has not been opened yet.
80
+ *
81
+ * A state file whose retained context names a session version this build
82
+ * cannot speak is refused here, before any network call or signature: the
83
+ * sitting it was written for ended under the previous contract, and its
84
+ * cursor and token do not carry over. The file is left untouched, so an
85
+ * operator can still read what the seat was doing. */
86
+ export declare function loadSeatState(path: string): SeatSessionState | null;
87
+ /** Write it back, whole. Read-modify-write, never append: a half-written
88
+ * cursor is a session that cannot resume, and the fix for that is a rejoin
89
+ * that costs the seat every turn in between. */
90
+ export declare function saveSeatState(path: string, state: SeatSessionState): void;
91
+ export {};
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { fromHex, toHex0x } from "./bytes.js";
3
+ import { requireSessionVersion } from "./sessionWire.js";
4
+ /* What a seat has to remember between one command and the next.
5
+ *
6
+ * `playSeat` is a loop: it joins, plays every turn and exits at a terminal, so
7
+ * everything it knows lives in memory for as long as it runs. That shape asks
8
+ * the agent to hand over a function and step back, which is why every agent
9
+ * that wanted to think per move wrote a script to be that function.
10
+ *
11
+ * `turn` and `act` are the other shape: two short commands, the agent between
12
+ * them. Two commands are two processes, so what the loop kept in memory has to
13
+ * be written down. This is that file, and it is deliberately small -- almost
14
+ * everything the session needs is rebuilt by re-reading the offer, which the
15
+ * arena still serves. Only three things cannot be:
16
+ *
17
+ * - the client nonce, which the session is keyed by and a fresh one would
18
+ * not resume
19
+ * - the resume cursor, which says how much of the stream has been seen
20
+ * - the session context, whose session id the authority assigned at join
21
+ *
22
+ * The view is kept too, so `act` moves on exactly the position `turn` printed
23
+ * rather than on whatever the table looks like a few seconds later.
24
+ *
25
+ * ── What this is worth to somebody who takes it ───────────────────────────
26
+ * It holds no key material, and every move is signed by the key file, so a
27
+ * copy of this cannot play the seat. It does hold the session token, which is
28
+ * a bearer credential for the stream, so it is written 0600 beside the key and
29
+ * deserves the same care as anything else in that directory. The key file is
30
+ * still the thing that actually has to be guarded. */
31
+ /** Written beside the key file, as `.dopa-keypair` is. */
32
+ export const DEFAULT_SEAT_STATE_FILE = ".dopa-seat";
33
+ const encodeState = (state) => ({
34
+ nonce: state.nonce.toString(),
35
+ commitment: toHex0x(state.commitment),
36
+ });
37
+ const decodeState = (stored) => ({
38
+ nonce: BigInt(stored.nonce),
39
+ commitment: fromHex(stored.commitment),
40
+ });
41
+ export function encodeContext(context) {
42
+ requireSessionVersion(context.sessionVersion);
43
+ return {
44
+ wireVersion: context.wireVersion,
45
+ sessionVersion: context.sessionVersion,
46
+ sessionId: toHex0x(context.sessionId),
47
+ executionId: toHex0x(context.executionId),
48
+ executionManifestDigest: toHex0x(context.executionManifestDigest),
49
+ protocolId: toHex0x(context.protocolId),
50
+ protocolVersion: context.protocolVersion,
51
+ participantId: toHex0x(context.participantId),
52
+ seat: context.seat,
53
+ };
54
+ }
55
+ export function decodeContext(stored) {
56
+ requireSessionVersion(stored.sessionVersion);
57
+ return {
58
+ wireVersion: stored.wireVersion,
59
+ sessionVersion: stored.sessionVersion,
60
+ sessionId: fromHex(stored.sessionId),
61
+ executionId: fromHex(stored.executionId),
62
+ executionManifestDigest: fromHex(stored.executionManifestDigest),
63
+ protocolId: fromHex(stored.protocolId),
64
+ protocolVersion: stored.protocolVersion,
65
+ participantId: fromHex(stored.participantId),
66
+ seat: stored.seat,
67
+ };
68
+ }
69
+ const encodeReceipt = (receipt) => receipt
70
+ ? {
71
+ digest: toHex0x(receipt.digest),
72
+ resultingState: encodeState(receipt.resultingState),
73
+ }
74
+ : null;
75
+ const decodeReceipt = (stored) => stored
76
+ ? {
77
+ digest: fromHex(stored.digest),
78
+ resultingState: decodeState(stored.resultingState),
79
+ }
80
+ : null;
81
+ export function encodeCursor(cursor) {
82
+ return {
83
+ sequence: cursor.sequence.toString(),
84
+ witnessedReceipt: encodeReceipt(cursor.witnessedReceipt),
85
+ };
86
+ }
87
+ export function decodeCursor(stored) {
88
+ return {
89
+ sequence: BigInt(stored.sequence),
90
+ witnessedReceipt: decodeReceipt(stored.witnessedReceipt),
91
+ };
92
+ }
93
+ export function encodeView(view) {
94
+ return {
95
+ state: encodeState(view.state),
96
+ participantView: toHex0x(view.participantView),
97
+ participantViewSchema: view.participantViewSchema,
98
+ legalActions: toHex0x(view.legalActions),
99
+ legalActionsSchema: view.legalActionsSchema,
100
+ participantDeadlineMs: view.participantDeadlineMs.toString(),
101
+ latestReceipt: encodeReceipt(view.latestReceipt),
102
+ };
103
+ }
104
+ export function decodeView(stored) {
105
+ return {
106
+ state: decodeState(stored.state),
107
+ participantView: fromHex(stored.participantView),
108
+ participantViewSchema: stored.participantViewSchema,
109
+ legalActions: fromHex(stored.legalActions),
110
+ legalActionsSchema: stored.legalActionsSchema,
111
+ participantDeadlineMs: BigInt(stored.participantDeadlineMs),
112
+ latestReceipt: decodeReceipt(stored.latestReceipt),
113
+ };
114
+ }
115
+ /** Read the seat's state, or null where it has not been opened yet.
116
+ *
117
+ * A state file whose retained context names a session version this build
118
+ * cannot speak is refused here, before any network call or signature: the
119
+ * sitting it was written for ended under the previous contract, and its
120
+ * cursor and token do not carry over. The file is left untouched, so an
121
+ * operator can still read what the seat was doing. */
122
+ export function loadSeatState(path) {
123
+ if (!existsSync(path))
124
+ return null;
125
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
126
+ if (!parsed.offerId || typeof parsed.seat !== "number")
127
+ throw new Error(`${path} is not a seat state file`);
128
+ if (parsed.context)
129
+ requireSessionVersion(parsed.context.sessionVersion);
130
+ return parsed;
131
+ }
132
+ /** Write it back, whole. Read-modify-write, never append: a half-written
133
+ * cursor is a session that cannot resume, and the fix for that is a rejoin
134
+ * that costs the seat every turn in between. */
135
+ export function saveSeatState(path, state) {
136
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
137
+ }