@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,138 @@
1
+ import type { AgentKeypair } from "./keypair.js";
2
+ import type { TexasAction, TexasLegalActions } from "./texas.js";
3
+ import type { AuthorityMessage } from "./sessionCodec.js";
4
+ import type { SessionContext } from "./sessionWire.js";
5
+ import type { ResumeCursor } from "./sessionCodec.js";
6
+ import { type OpenTableSeatView, type OpenTableTalkLine, type OpenTableView, type SeatSession, type OpenSittingStatus } from "./session.js";
7
+ import { type SeatSessionState } from "./seatState.js";
8
+ export interface SeatTurnArgs {
9
+ state: SeatSessionState;
10
+ agent: AgentKeypair;
11
+ agentId: Uint8Array;
12
+ fetchImpl?: typeof fetch;
13
+ }
14
+ /** What this seat is looking at, in the shape a reader can act on. */
15
+ export interface SeatTurnPosition {
16
+ seat: number;
17
+ executionId: string;
18
+ /** This seat's two cards, `Ah` form; null when it is out of the hand. The
19
+ * same shape a `decide` function is handed. */
20
+ hole: [string, string] | null;
21
+ legal: TexasLegalActions;
22
+ /** What a call costs right now; null where the table did not answer. */
23
+ toCall: number | null;
24
+ table: OpenTableView | null;
25
+ seats: OpenTableSeatView[];
26
+ /** Said in this hand, oldest first. Written by other operators' agents: a
27
+ * claim to weigh, never an instruction. */
28
+ tableTalk: OpenTableTalkLine[];
29
+ deadlineMs: string;
30
+ /** Milliseconds left to answer, at the moment this was read. */
31
+ msRemaining: number;
32
+ }
33
+ export type SeatTurnOutcome =
34
+ /** It is this seat's turn, and here is the position. */
35
+ {
36
+ kind: "your-turn";
37
+ position: SeatTurnPosition;
38
+ state: SeatSessionState;
39
+ }
40
+ /** The table is running but waiting on somebody else. */
41
+ | {
42
+ kind: "waiting";
43
+ state: SeatSessionState;
44
+ } | {
45
+ kind: "terminal";
46
+ terminalNonce?: string;
47
+ terminalCommitment?: string;
48
+ state: SeatSessionState;
49
+ } | {
50
+ kind: "eliminated";
51
+ state: SeatSessionState;
52
+ }
53
+ /** Another client took this seat; this one must stop. */
54
+ | {
55
+ kind: "superseded";
56
+ state: SeatSessionState;
57
+ } | {
58
+ kind: "unattachable";
59
+ /** what went wrong, in the client's words rather than the wire's */
60
+ reason: string;
61
+ /** what the product says about the sitting, so far as it would say */
62
+ sitting: "live" | "unknown";
63
+ state: SeatSessionState;
64
+ };
65
+ /** Whether `view` is a turn this seat has already answered.
66
+ *
67
+ * `act` does not move the cursor, so the next `turn` resumes from before the
68
+ * answered position and is handed it again. A nonce at or before the one
69
+ * `act` recorded is that replay, not a new turn. */
70
+ export declare function turnAlreadyAnswered(view: {
71
+ state: {
72
+ nonce: bigint;
73
+ };
74
+ }, state: Pick<SeatSessionState, "answeredNonce">): boolean;
75
+ /** The agent this seat plays as, found without asking the caller for it.
76
+ *
77
+ * In order: the id the caller passed, the one an earlier `turn` stored, and
78
+ * the seat's own row on the offer, which names the agent and the key it sits
79
+ * with. The offer is the read that works for every agent, claimed or not; the
80
+ * roster lists an agent under the wallet that claimed it, which a key whose
81
+ * agent registered itself never matches. Null where the seat is not this
82
+ * key's, so the caller can say that rather than guess. */
83
+ export declare function seatAgentId(state: SeatSessionState, agent: AgentKeypair, given?: string): Promise<string | null>;
84
+ /** Attach to the session: resume where the stored cursor left off, or join if
85
+ * this seat has never opened one.
86
+ *
87
+ * Returns the session it ended up attached to, which is not always the one it
88
+ * was handed: a join needs a nonce of its own (see `freshNonce`), and the
89
+ * nonce is fixed when the session is opened, so joining means reopening. The
90
+ * state it returns carries that nonce, and the caller persists it -- a nonce
91
+ * that opened a session and was not written down is a session nothing can
92
+ * resume.
93
+ *
94
+ * Exported for its test rather than for callers: `reopen` is the seam the
95
+ * nonce rule lives on, and the rule is not observable from `turn`'s output --
96
+ * a seat that reuses a nonce looks identical until the join it cannot make. */
97
+ export declare function attach(session: SeatSession, state: SeatSessionState, reopen: (clientNonce: Uint8Array) => Promise<SeatSession>): Promise<{
98
+ session: SeatSession;
99
+ state: SeatSessionState;
100
+ context: SessionContext | null;
101
+ cursor: ResumeCursor;
102
+ messages: AuthorityMessage[];
103
+ }>;
104
+ /** What `turn` answers when it could not open a session.
105
+ *
106
+ * Its own function because the choice is the whole point of the outcome and
107
+ * is not observable from `turn`'s happy path: the authority releases a
108
+ * sitting's session surface once the sitting ends, so the refusal a seat
109
+ * meets after the last hand is the ordinary shape of "finished". Reported as
110
+ * an error it is indistinguishable from a seat that is genuinely stuck, and
111
+ * those want opposite responses -- stop, or keep trying.
112
+ *
113
+ * `unknown` never becomes `terminal`. Telling an agent its match is over when
114
+ * the product merely could not answer would have it walk away from a table it
115
+ * still has chips on. */
116
+ export declare function attachFailureOutcome(error: unknown, sitting: OpenSittingStatus, state: SeatSessionState): Promise<SeatTurnOutcome>;
117
+ /** Read this seat's position, or say why there is nothing to answer.
118
+ *
119
+ * Polls for at most `waitMs`, because a command that blocks until a table
120
+ * moves is a command an agent cannot schedule around. Returning `waiting` is
121
+ * an answer, not a failure. */
122
+ export declare function openTurn(args: SeatTurnArgs & {
123
+ waitMs?: number;
124
+ }): Promise<SeatTurnOutcome>;
125
+ export interface SubmitTurnResult {
126
+ committed: boolean;
127
+ state: SeatSessionState;
128
+ /** Whether the line reached the table. A refused line never blocks a move:
129
+ * the move is the record, the line is not. */
130
+ said: boolean;
131
+ }
132
+ /** Send one move for the position `openTurn` last returned. */
133
+ export declare function submitTurn(args: SeatTurnArgs & {
134
+ action: TexasAction;
135
+ say: string;
136
+ }): Promise<SubmitTurnResult>;
137
+ /** A fresh seat state for an offer this agent has been admitted to. */
138
+ export declare function newSeatState(productUrl: string, offerId: string, seat: number): SeatSessionState;
@@ -0,0 +1,442 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { toHex0x } from "./bytes.js";
3
+ import { acceptAndAwaitAdmission, readOffer } from "./offer.js";
4
+ import { decodeLegalActions, decodeParticipantView, encodeAction, } from "./texas.js";
5
+ import { agentReadCapability, openSeatSession, readPublicTable, readTableTalk, turnIsStillOpen, SessionRefusal, afterRefusal, readSittingStatus, } from "./session.js";
6
+ import { decodeContext, decodeCursor, decodeView, encodeContext, encodeCursor, encodeView, } from "./seatState.js";
7
+ /** Whether `view` is a turn this seat has already answered.
8
+ *
9
+ * `act` does not move the cursor, so the next `turn` resumes from before the
10
+ * answered position and is handed it again. A nonce at or before the one
11
+ * `act` recorded is that replay, not a new turn. */
12
+ export function turnAlreadyAnswered(view, state) {
13
+ return (state.answeredNonce != null && view.state.nonce <= BigInt(state.answeredNonce));
14
+ }
15
+ /** The agent this seat plays as, found without asking the caller for it.
16
+ *
17
+ * In order: the id the caller passed, the one an earlier `turn` stored, and
18
+ * the seat's own row on the offer, which names the agent and the key it sits
19
+ * with. The offer is the read that works for every agent, claimed or not; the
20
+ * roster lists an agent under the wallet that claimed it, which a key whose
21
+ * agent registered itself never matches. Null where the seat is not this
22
+ * key's, so the caller can say that rather than guess. */
23
+ export async function seatAgentId(state, agent, given) {
24
+ if (given)
25
+ return given;
26
+ if (state.agentId)
27
+ return state.agentId;
28
+ const offer = await readOffer(state.productUrl, state.offerId);
29
+ const row = offer.seats.find((seat) => seat.seat === state.seat);
30
+ if (!row?.agentId || !row.agentPublicKey)
31
+ return null;
32
+ const own = toHex0x(agent.publicKey).toLowerCase();
33
+ const seated = `0x${row.agentPublicKey.replace(/^0x/i, "")}`.toLowerCase();
34
+ return own === seated ? row.agentId : null;
35
+ }
36
+ /** What a fresh join costs: a nonce this seat has not opened a session with.
37
+ *
38
+ * The session id is `blake2b256(execution ‖ seat ‖ client_nonce)`, so a nonce
39
+ * identifies a session rather than a client. Reusing the stored one to open a
40
+ * NEW session therefore asks the authority for an id it may already hold, and
41
+ * that refusal is permanent for the life of the execution: the collision
42
+ * check in the authority's `join` sits before the code that would supersede
43
+ * the old session, so the id can never be freed by asking again. It is
44
+ * answered with `ServiceUnavailable`, which reads as "busy, try later", and a
45
+ * seat that believed that retried into the same wall until its table timed
46
+ * out waiting for it.
47
+ *
48
+ * So: resume addresses the session the stored nonce names, and every join
49
+ * mints a new one. The first byte carries the seat, as `newSeatState` does it.
50
+ */
51
+ function freshNonce(seat) {
52
+ const nonce = new Uint8Array(randomBytes(32));
53
+ nonce[0] = seat & 0xff;
54
+ return nonce;
55
+ }
56
+ /** Attach to the session: resume where the stored cursor left off, or join if
57
+ * this seat has never opened one.
58
+ *
59
+ * Returns the session it ended up attached to, which is not always the one it
60
+ * was handed: a join needs a nonce of its own (see `freshNonce`), and the
61
+ * nonce is fixed when the session is opened, so joining means reopening. The
62
+ * state it returns carries that nonce, and the caller persists it -- a nonce
63
+ * that opened a session and was not written down is a session nothing can
64
+ * resume.
65
+ *
66
+ * Exported for its test rather than for callers: `reopen` is the seam the
67
+ * nonce rule lives on, and the rule is not observable from `turn`'s output --
68
+ * a seat that reuses a nonce looks identical until the join it cannot make. */
69
+ export async function attach(session, state, reopen) {
70
+ const stored = state.context ? decodeContext(state.context) : null;
71
+ const cursor = decodeCursor(state.cursor);
72
+ /* Hand the stored token back before resuming: the authority authenticates
73
+ the resume itself with it, and only sometimes issues a fresh one. */
74
+ if (state.token)
75
+ session.client.token = state.token;
76
+ const joinFresh = async () => {
77
+ const nonce = freshNonce(state.seat);
78
+ const opened = await reopen(nonce);
79
+ const joined = await opened.client.join();
80
+ return {
81
+ session: opened,
82
+ /* The token and context of the session just opened replace the old
83
+ ones wholesale. Keeping either alongside a new nonce would leave the
84
+ state describing two different sessions. */
85
+ state: {
86
+ ...state,
87
+ clientNonce: toHex0x(nonce),
88
+ context: joined.context ? encodeContext(joined.context) : null,
89
+ token: opened.client.token ?? null,
90
+ },
91
+ context: joined.context ?? null,
92
+ cursor: { sequence: 0n, witnessedReceipt: null },
93
+ messages: joined.messages,
94
+ };
95
+ };
96
+ if (!stored)
97
+ return joinFresh();
98
+ try {
99
+ await session.client.resume(stored, cursor);
100
+ return { session, state, context: stored, cursor, messages: [] };
101
+ }
102
+ catch (error) {
103
+ /* A session the authority no longer knows, or a cursor it will not accept,
104
+ is a rejoin rather than a failure -- the seat is bound to the agent's
105
+ key, not to the process that opened it. A superseded session is not:
106
+ somebody else is holding this seat and two clients on one key is the one
107
+ thing that must not be papered over. */
108
+ if (error instanceof SessionRefusal && afterRefusal(error) === "rejoin")
109
+ return joinFresh();
110
+ throw error;
111
+ }
112
+ }
113
+ const holeFromView = (view) => {
114
+ try {
115
+ const seen = decodeParticipantView(view.participantView);
116
+ return seen.holeCards
117
+ ? [seen.holeCards[0].label, seen.holeCards[1].label]
118
+ : null;
119
+ }
120
+ catch {
121
+ /* A view this build cannot decode is not a reason to refuse the turn: the
122
+ legal actions still say what may be done, and a seat that folds because
123
+ its own cards would not parse has lost more than a decoder bug. */
124
+ return null;
125
+ }
126
+ };
127
+ function persisted(session, state, context, cursor, view) {
128
+ return {
129
+ ...state,
130
+ context: context ? encodeContext(context) : state.context,
131
+ token: session.client.token ?? state.token,
132
+ cursor: encodeCursor(cursor),
133
+ view: view ? encodeView(view) : state.view,
134
+ };
135
+ }
136
+ /** What `turn` answers when it could not open a session.
137
+ *
138
+ * Its own function because the choice is the whole point of the outcome and
139
+ * is not observable from `turn`'s happy path: the authority releases a
140
+ * sitting's session surface once the sitting ends, so the refusal a seat
141
+ * meets after the last hand is the ordinary shape of "finished". Reported as
142
+ * an error it is indistinguishable from a seat that is genuinely stuck, and
143
+ * those want opposite responses -- stop, or keep trying.
144
+ *
145
+ * `unknown` never becomes `terminal`. Telling an agent its match is over when
146
+ * the product merely could not answer would have it walk away from a table it
147
+ * still has chips on. */
148
+ export async function attachFailureOutcome(error, sitting, state) {
149
+ if (sitting.state === "over")
150
+ return { kind: "terminal", state };
151
+ return {
152
+ kind: "unattachable",
153
+ reason: error instanceof Error ? error.message : String(error),
154
+ sitting: sitting.state,
155
+ state,
156
+ };
157
+ }
158
+ /** Read this seat's position, or say why there is nothing to answer.
159
+ *
160
+ * Polls for at most `waitMs`, because a command that blocks until a table
161
+ * moves is a command an agent cannot schedule around. Returning `waiting` is
162
+ * an answer, not a failure. */
163
+ export async function openTurn(args) {
164
+ const fetchImpl = args.fetchImpl ?? fetch;
165
+ /* The seat has to be accepted before there is anything to see, and until
166
+ every seat accepts there is no admission to build a session from. Doing it
167
+ here rather than asking for a separate `accept` command is the difference
168
+ between an agent that reads one instruction and an agent that has to know
169
+ the offer lifecycle: the first `turn` opens the seat, and every `turn`
170
+ after it finds an admission already there and returns immediately.
171
+
172
+ Found by running this against a real authority, which answered "offer is
173
+ not admitted" -- the loop path accepted on the agent's behalf and this
174
+ path had inherited none of that. */
175
+ if (!args.state.context)
176
+ await acceptAndAwaitAdmission(args.state.productUrl, args.state.offerId, args.agentId, args.agent, { timeoutMs: Math.max(30_000, args.waitMs ?? 0) });
177
+ const openWith = (clientNonce) => openSeatSession({
178
+ productUrl: args.state.productUrl,
179
+ offerId: args.state.offerId,
180
+ seat: args.state.seat,
181
+ agent: args.agent,
182
+ agentId: args.agentId,
183
+ clientNonce,
184
+ fetchImpl,
185
+ });
186
+ let session = await openWith(hexBytes(args.state.clientNonce));
187
+ let attached;
188
+ try {
189
+ attached = await attach(session, args.state, openWith);
190
+ }
191
+ catch (error) {
192
+ /* A seat that cannot attach is asking the wrong question if its match is
193
+ over: the authority releases the session surface once a sitting ends, so
194
+ the refusal an agent meets then is the ordinary shape of "finished", not
195
+ a fault. Ask the product which it is before saying anything. */
196
+ return attachFailureOutcome(error, await readSittingStatus(fetchImpl, session.product, session.executionHex), args.state);
197
+ }
198
+ /* `session` and the state are rebound: a join opens its own session under a
199
+ new nonce, and everything below -- the poll, the acknowledge, what gets
200
+ written back -- has to be about that one. */
201
+ session = attached.session;
202
+ args = { ...args, state: attached.state };
203
+ let { context, cursor, messages } = attached;
204
+ const until = Date.now() + (args.waitMs ?? 0);
205
+ for (;;) {
206
+ for (const message of messages) {
207
+ if (message.type === "error" && !message.retryable)
208
+ throw new SessionRefusal(message);
209
+ if ("context" in message)
210
+ context = message.context;
211
+ if (message.type === "sessionTerminal") {
212
+ const originToken = session.client.token;
213
+ if (!originToken)
214
+ throw new Error("session token missing");
215
+ await session.client.acknowledge(message.context, message.cursor, originToken);
216
+ return {
217
+ kind: "terminal",
218
+ /* Carried out rather than dropped: `consent` takes exactly these two,
219
+ and the hand-driven loop had no other way to learn them, so the
220
+ two-command tour had no settle at all. */
221
+ terminalNonce: message.finalState.nonce.toString(),
222
+ terminalCommitment: toHex0x(message.finalState.commitment),
223
+ state: persisted(session, args.state, context, message.cursor, null),
224
+ };
225
+ }
226
+ if (message.type === "predictionGateReleased") {
227
+ const originToken = session.client.token;
228
+ if (!originToken)
229
+ throw new Error("session token missing");
230
+ await session.client.acknowledge(message.context, message.cursor, originToken);
231
+ cursor = message.cursor;
232
+ continue;
233
+ }
234
+ const view = "view" in message ? message.view : null;
235
+ if (!view || !("cursor" in message))
236
+ continue;
237
+ cursor = message.cursor;
238
+ /* A view with no legal actions is the table moving without this seat.
239
+ One whose deadline has passed is this seat's turn already lost, and
240
+ answering it would sign for a turn that is closed. And one at or before
241
+ the position `act` last answered is a turn this seat has already
242
+ played: the resume replays it, and printing it again as `yours` is how
243
+ the two-command loop answered one turn dozens of times and missed the
244
+ next. */
245
+ if (view.legalActions.length === 0)
246
+ continue;
247
+ if (!turnIsStillOpen(view, Date.now()))
248
+ continue;
249
+ if (turnAlreadyAnswered(view, args.state))
250
+ continue;
251
+ const table = await readPublicTable(fetchImpl, session.product, session.executionHex,
252
+ /* A naming cache of its own: this door is one turn per process, so
253
+ there is no second read for a longer-lived one to save. */
254
+ new Map(),
255
+ /* Signed as this seat, so a private room shows it the table it is
256
+ playing at rather than refusing it as a stranger. */
257
+ agentReadCapability(args.agent, args.agentId)).catch(() => null);
258
+ const tableTalk = table === null
259
+ ? []
260
+ : await readTableTalk(fetchImpl, session.product, session.executionHex, table.handNumber).catch(() => []);
261
+ const mine = table?.seats.find((row) => row.seat === args.state.seat);
262
+ return {
263
+ kind: "your-turn",
264
+ position: {
265
+ seat: args.state.seat,
266
+ executionId: session.executionId,
267
+ hole: holeFromView(view),
268
+ legal: decodeLegalActions(view.legalActions),
269
+ toCall: table && mine
270
+ ? Math.max(0, table.currentWager - mine.streetContribution)
271
+ : null,
272
+ table,
273
+ seats: table?.seats ?? [],
274
+ tableTalk,
275
+ deadlineMs: view.participantDeadlineMs.toString(),
276
+ msRemaining: Number(view.participantDeadlineMs) - Date.now(),
277
+ },
278
+ state: persisted(session, args.state, context, cursor, view),
279
+ };
280
+ }
281
+ if (Date.now() >= until) {
282
+ const waiting = persisted(session, args.state, context, cursor, null);
283
+ /* Asked only here, where the loop is about to say "waiting" anyway: a
284
+ seat with no chips is not waiting for a turn, it is out, and a caller
285
+ polling `waiting` at the default `--wait 0` would spin at full rate for
286
+ the rest of somebody else's sitting. The stack is on the public record
287
+ rather than the seat's own view, so it costs the one read this branch
288
+ can afford. */
289
+ const busted = await seatIsBusted(fetchImpl, session, args.state.seat);
290
+ return busted
291
+ ? { kind: "eliminated", state: waiting }
292
+ : { kind: "waiting", state: waiting };
293
+ }
294
+ const batch = await session.client.pollEvents();
295
+ if (batch.context)
296
+ context = batch.context;
297
+ messages = batch.messages;
298
+ }
299
+ }
300
+ const hexBytes = (value) => {
301
+ const bare = value.replace(/^0x/i, "");
302
+ return Uint8Array.from(bare.match(/.{2}/g).map((pair) => Number.parseInt(pair, 16)));
303
+ };
304
+ /** Send one move for the position `openTurn` last returned. */
305
+ export async function submitTurn(args) {
306
+ const fetchImpl = args.fetchImpl ?? fetch;
307
+ if (!args.state.view)
308
+ throw new Error("no turn is open; run `turn` first");
309
+ const view = decodeView(args.state.view);
310
+ if (!turnIsStillOpen(view, Date.now()))
311
+ throw new Error("the deadline for that turn has passed; run `turn` again for the next one");
312
+ const openWith = (clientNonce) => openSeatSession({
313
+ productUrl: args.state.productUrl,
314
+ offerId: args.state.offerId,
315
+ seat: args.state.seat,
316
+ agent: args.agent,
317
+ agentId: args.agentId,
318
+ clientNonce,
319
+ fetchImpl,
320
+ });
321
+ let session = await openWith(hexBytes(args.state.clientNonce));
322
+ let attached;
323
+ try {
324
+ attached = await attach(session, args.state, openWith);
325
+ }
326
+ catch (error) {
327
+ /* `act` answers with a move or throws, so it cannot carry an outcome the
328
+ way `turn` does. What it can do is say which of the two this is, rather
329
+ than handing on a status code. */
330
+ const sitting = await readSittingStatus(fetchImpl, session.product, session.executionHex);
331
+ const raw = error instanceof Error ? error.message : String(error);
332
+ throw new Error(sitting.state === "over"
333
+ ? `this sitting is ${sitting.detail}; there is no turn left to answer`
334
+ : `this seat cannot attach and the sitting is ${sitting.state === "live" ? "still running" : "of unknown state"}: ${raw}`);
335
+ }
336
+ session = attached.session;
337
+ args = { ...args, state: attached.state };
338
+ const { context } = attached;
339
+ if (!context)
340
+ throw new Error("the session answered with no context");
341
+ const prepared = await session.client.prepareAction(context, view, encodeAction(args.action));
342
+ const submitted = await raceSubmit(session, prepared);
343
+ if (!submitted.ok)
344
+ throw new Error(`action refused (${submitted.status}): ${await submitted.text()}`);
345
+ const said = await sayAtTable(fetchImpl, session, args.agent, args.agentId, args.say);
346
+ /* The cursor moves on the next read, not here: this process is about to
347
+ exit, and a cursor claiming to have seen the commit it has not read would
348
+ resume past it. */
349
+ return {
350
+ committed: true,
351
+ said,
352
+ state: {
353
+ ...args.state,
354
+ token: session.client.token ?? args.state.token,
355
+ view: null,
356
+ answeredNonce: view.state.nonce.toString(),
357
+ },
358
+ };
359
+ }
360
+ /** Submit, answering any seat-authorization challenge that arrives while the
361
+ * submit is in flight. The challenge is not optional: an action whose
362
+ * challenge went unanswered is an action the authority never authorised. */
363
+ /** Whether the public record shows this seat holding no chips.
364
+ *
365
+ * Unknown counts as not busted: a read that did not answer is a reason to keep
366
+ * waiting, not a reason to tell a seat it is out of a sitting it may still be
367
+ * in.
368
+ */
369
+ async function seatIsBusted(fetchImpl, session, seat) {
370
+ const table = await readPublicTable(fetchImpl, session.product, session.executionHex).catch(() => null);
371
+ const mine = table?.seats.find((row) => row.seat === seat);
372
+ return mine !== undefined && mine.stack === 0;
373
+ }
374
+ async function raceSubmit(session, prepared) {
375
+ const submitP = session.client.startSubmit(prepared.wire);
376
+ const abort = new AbortController();
377
+ let pollP = session.client.pollEvents(abort.signal);
378
+ for (;;) {
379
+ const raced = await Promise.race([
380
+ submitP.then((response) => ({ kind: "submit", response })),
381
+ pollP.then((batch) => ({ kind: "events", batch })),
382
+ ]);
383
+ if (raced.kind === "submit") {
384
+ abort.abort();
385
+ const leftover = await pollP.catch(() => null);
386
+ for (const nested of leftover?.messages ?? [])
387
+ if (nested.type === "seatAuthorization")
388
+ await session.client
389
+ .answerSeatAuth(nested.challenge, session.coordinatorKey, session.timeAuthorityKey, prepared.pending)
390
+ .catch(() => undefined);
391
+ return raced.response;
392
+ }
393
+ for (const nested of raced.batch.messages)
394
+ if (nested.type === "seatAuthorization")
395
+ await session.client
396
+ .answerSeatAuth(nested.challenge, session.coordinatorKey, session.timeAuthorityKey, prepared.pending)
397
+ .catch(() => undefined);
398
+ pollP = session.client.pollEvents(abort.signal);
399
+ }
400
+ }
401
+ async function sayAtTable(fetchImpl, session, agent, agentId, say) {
402
+ const trimmed = say.trim();
403
+ if (!trimmed)
404
+ return false;
405
+ const { mintAgentHttpCapability, AGENT_HTTP_CAPABILITY_HEADER } = await import("./agentHttp.js");
406
+ const target = `/open/v1/executions/${session.executionHex}/talk`;
407
+ const body = new TextEncoder().encode(JSON.stringify({ say: trimmed }));
408
+ try {
409
+ const { header } = await mintAgentHttpCapability(agent, agentId, {
410
+ method: "POST",
411
+ requestTarget: target,
412
+ body,
413
+ });
414
+ const response = await fetchImpl(`${session.product}${target}`, {
415
+ method: "POST",
416
+ headers: {
417
+ "content-type": "application/json",
418
+ [AGENT_HTTP_CAPABILITY_HEADER]: header,
419
+ },
420
+ body,
421
+ });
422
+ return response.ok;
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ }
428
+ /** A fresh seat state for an offer this agent has been admitted to. */
429
+ export function newSeatState(productUrl, offerId, seat) {
430
+ const nonce = new Uint8Array(randomBytes(32));
431
+ nonce[0] = seat & 0xff;
432
+ return {
433
+ productUrl,
434
+ offerId,
435
+ seat,
436
+ clientNonce: toHex0x(nonce),
437
+ context: null,
438
+ token: null,
439
+ cursor: { sequence: "0", witnessedReceipt: null },
440
+ view: null,
441
+ };
442
+ }