@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.
package/dist/session.js CHANGED
@@ -3,8 +3,8 @@ import { blake2b256 } from "./crypto.js";
3
3
  import { equalBytes, fromHex, textBytes, toHex0x } from "./bytes.js";
4
4
  import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
5
5
  import { signRaw } from "./keypair.js";
6
- import { decodeAuthorityMessage, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
7
- import { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, resumeSigningBytes, } from "./sessionWire.js";
6
+ import { MAX_TRANSPORT_FRAME_BYTES, decodeAuthorityMessage, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
7
+ import { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, requireSessionVersion, resumeSigningBytes, SESSION_VERSION, UnsupportedSessionVersionError, } from "./sessionWire.js";
8
8
  import { decodeLegalActions, decodeParticipantView, encodeAction, pickAction, } from "./texas.js";
9
9
  import { authorizeSeatChallenge } from "./seatAuth.js";
10
10
  const ACTION_IDENTITY_DOMAIN = textBytes("dopa_open::client::action_identity_v1");
@@ -26,8 +26,41 @@ export function playReportLines(report) {
26
26
  lines.push(`rejoins ${report.rejoins}`);
27
27
  if (report.said > 0 || report.saidRefused > 0)
28
28
  lines.push(`said ${report.said} refused ${report.saidRefused}`);
29
+ lines.push(`eliminated ${report.eliminated}`);
29
30
  return lines;
30
31
  }
32
+ /** How often a seat with nothing to decide reads the public table.
33
+ *
34
+ * A seat that is out of the hand, or out of the sitting, is sent views with no
35
+ * legal actions for as long as the others play, and the table used to be read
36
+ * only when there was a decision to make -- so a busted seat read nothing and
37
+ * printed nothing for the rest of the sitting. Slow, because the read is the
38
+ * same one the anonymous cap counts. */
39
+ export const IDLE_TABLE_READ_MS = 5_000;
40
+ /** Watches the public table for this seat losing its last chip.
41
+ *
42
+ * Out means no chips and not all in: a seat all in with nothing behind is
43
+ * still in the hand, and only once the hand has settled against it does its
44
+ * stack read zero with no bet out. `observe` answers true exactly once, the
45
+ * first time it sees that, so the line it drives is printed once however many
46
+ * hands the others go on to play. */
47
+ export function eliminationWatch(seat) {
48
+ let out = false;
49
+ return {
50
+ get eliminated() {
51
+ return out;
52
+ },
53
+ observe(table) {
54
+ if (out || !table)
55
+ return false;
56
+ const mine = table.seats.find((entry) => entry.seat === seat);
57
+ if (!mine || mine.stack !== 0 || mine.allIn)
58
+ return false;
59
+ out = true;
60
+ return true;
61
+ },
62
+ };
63
+ }
31
64
  /* A session-protocol refusal, with the tag kept on it.
32
65
  *
33
66
  * `postWire` and the message loop used to throw a plain Error whose text named
@@ -50,6 +83,30 @@ export class SessionRefusal extends Error {
50
83
  const UNKNOWN_SESSION = 6;
51
84
  const INVALID_RESUME_CURSOR = 7;
52
85
  const SESSION_SUPERSEDED = 10;
86
+ /** The shared binding's discovery read bound (`MAX_DISCOVERY_RESPONSE_BYTES`).
87
+ * A document larger than this is not a discovery document this client reads. */
88
+ const MAX_DISCOVERY_RESPONSE_BYTES = 4096;
89
+ /** How long a seat keeps asking for discovery from an origin that is not
90
+ * answering: long enough to outlast a deployment restarting behind it, short
91
+ * enough that a seat whose authority is truly gone still stops. */
92
+ export const DISCOVERY_RETRY_BOUND_MS = 90_000;
93
+ const DISCOVERY_RETRY_PAUSE_MS = 1_000;
94
+ /** Statuses an origin gives while it is coming back, rather than a verdict on
95
+ * whether it can host the session. */
96
+ function discoveryMayAnswerLater(status) {
97
+ return (status === 404 ||
98
+ status === 408 ||
99
+ status === 425 ||
100
+ status === 429 ||
101
+ status >= 500);
102
+ }
103
+ class SessionDiscoveryRefused extends Error {
104
+ status;
105
+ constructor(status) {
106
+ super(`session discovery failed (${status})`);
107
+ this.status = status;
108
+ }
109
+ }
53
110
  /** Whether this turn is still open at `nowMs`.
54
111
  *
55
112
  * A predicate rather than a comparison in the loop, for the same reason
@@ -93,7 +150,10 @@ export async function chooseSeatAction(legal, view, strategy, decide, context =
93
150
  legal,
94
151
  view,
95
152
  seat: own.receivingSeat,
96
- hole: own.holeCards,
153
+ hole: own.holeCards
154
+ ? [own.holeCards[0].label, own.holeCards[1].label]
155
+ : null,
156
+ holeCards: own.holeCards,
97
157
  executionId: context.executionId,
98
158
  table,
99
159
  seats: table?.seats ?? [],
@@ -145,9 +205,91 @@ export class SessionClient {
145
205
  this.clientNonce = clientNonce;
146
206
  this.fetchImpl = fetchImpl;
147
207
  }
208
+ /** How long discovery keeps asking an authority that is not answering.
209
+ *
210
+ * Public so a test can shorten it; nothing else should need to. */
211
+ discoveryRetry = {
212
+ boundMs: DISCOVERY_RETRY_BOUND_MS,
213
+ pauseMs: DISCOVERY_RETRY_PAUSE_MS,
214
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
215
+ };
148
216
  url(path) {
149
217
  return `${this.baseUrl.replace(/\/$/, "")}${path}`;
150
218
  }
219
+ /** Requires the authority's published discovery to host the current session
220
+ * contract, before any challenge, signature, or session request.
221
+ *
222
+ * Discovery is a compatibility predicate over published facts, never an
223
+ * authenticity claim: the join handshake is still the admission decision.
224
+ * The body is read under the same bound the shared binding enforces, so a
225
+ * hostile pre-join answer cannot stream into this process. A document that
226
+ * names no compatible contract is final -- an authority that cannot host
227
+ * the session does not become able to by asking again.
228
+ *
229
+ * An origin that did not answer with a document is another matter, and is
230
+ * asked again within `discoveryRetry`. A seat reconnects at whatever moment
231
+ * its stream dropped, and a deployment restarting behind that origin
232
+ * answers 404, 502 or nothing for a few seconds. Taken as final, one such
233
+ * answer ended a seat mid-sitting while its table played on without it, and
234
+ * the clock folded every turn it had left. */
235
+ async requireCompatibleDiscovery() {
236
+ const response = await this.answeredDiscovery();
237
+ const document = JSON.parse(await this.readBoundedDiscovery(response));
238
+ const wires = document.wire_versions;
239
+ const sessions = document.session_versions;
240
+ const ceiling = document.max_transport_frame_bytes;
241
+ if (!Array.isArray(wires) ||
242
+ !Array.isArray(sessions) ||
243
+ !wires.includes(1) ||
244
+ !sessions.includes(SESSION_VERSION) ||
245
+ typeof ceiling !== "number" ||
246
+ !Number.isFinite(ceiling) ||
247
+ ceiling < MAX_TRANSPORT_FRAME_BYTES)
248
+ throw new Error("authority discovery does not support participant session V2");
249
+ }
250
+ async answeredDiscovery() {
251
+ const { boundMs, pauseMs, sleep } = this.discoveryRetry;
252
+ const until = Date.now() + boundMs;
253
+ for (;;) {
254
+ let refusal;
255
+ try {
256
+ const response = await this.fetchImpl(this.url("/v1/session/discovery"));
257
+ if (response.ok)
258
+ return response;
259
+ if (!discoveryMayAnswerLater(response.status))
260
+ throw new SessionDiscoveryRefused(response.status);
261
+ refusal = `session discovery failed (${response.status})`;
262
+ await response.body?.cancel();
263
+ }
264
+ catch (error) {
265
+ if (error instanceof SessionDiscoveryRefused)
266
+ throw error;
267
+ refusal = `session discovery failed (${error instanceof Error ? error.message : String(error)})`;
268
+ }
269
+ if (Date.now() + pauseMs > until)
270
+ throw new Error(`${refusal}; still refused after ${boundMs} ms`);
271
+ await sleep(pauseMs);
272
+ }
273
+ }
274
+ async readBoundedDiscovery(response) {
275
+ const reader = response.body?.getReader();
276
+ if (!reader)
277
+ return "";
278
+ const chunks = [];
279
+ let total = 0;
280
+ for (;;) {
281
+ const { done, value } = await reader.read();
282
+ if (done)
283
+ break;
284
+ total += value.length;
285
+ if (total > MAX_DISCOVERY_RESPONSE_BYTES) {
286
+ await reader.cancel();
287
+ throw new Error("session discovery exceeds 4096 bytes");
288
+ }
289
+ chunks.push(value);
290
+ }
291
+ return Buffer.concat(chunks).toString("utf8");
292
+ }
151
293
  async challenge() {
152
294
  const response = await this.fetchImpl(this.url("/v1/session/challenge"), {
153
295
  method: "POST",
@@ -165,14 +307,15 @@ export class SessionClient {
165
307
  throw new Error(`challenge must be 74 bytes, got ${bytes.length}`);
166
308
  return new Uint8Array(bytes);
167
309
  }
168
- async postWire(path, wire, withToken) {
310
+ async postWire(path, wire, withToken, tokenOverride) {
169
311
  const headers = {
170
312
  "content-type": "application/json",
171
313
  };
172
314
  if (withToken) {
173
- if (!this.token)
315
+ const token = tokenOverride ?? this.token;
316
+ if (!token)
174
317
  throw new Error("session token missing");
175
- headers["session-token"] = this.token;
318
+ headers["session-token"] = token;
176
319
  }
177
320
  let lastError = "";
178
321
  let lastRefusal = null;
@@ -198,7 +341,11 @@ export class SessionClient {
198
341
  }
199
342
  }
200
343
  }
201
- catch {
344
+ catch (error) {
345
+ /* A selected version this build cannot speak is not a transport
346
+ flake: rethrow it rather than retrying the raw status. */
347
+ if (error instanceof UnsupportedSessionVersionError)
348
+ throw error;
202
349
  /* keep raw body */
203
350
  }
204
351
  if (!retryable)
@@ -212,10 +359,11 @@ export class SessionClient {
212
359
  : new Error(lastError);
213
360
  }
214
361
  async join() {
362
+ await this.requireCompatibleDiscovery();
215
363
  const challenge = await this.challenge();
216
364
  const request = {
217
365
  wireVersion: 1,
218
- supportedSessionVersions: [1],
366
+ supportedSessionVersions: [SESSION_VERSION],
219
367
  executionId: this.executionId,
220
368
  executionManifestDigest: this.executionManifestDigest,
221
369
  participantId: this.participantId,
@@ -236,6 +384,7 @@ export class SessionClient {
236
384
  throw new Error("join did not return a session token");
237
385
  this.token = body.token;
238
386
  }
387
+ this.eventsAfter = 0n;
239
388
  const batch = await this.pollEvents();
240
389
  if (!batch.messages.some((message) => message.type === "sessionJoined"))
241
390
  throw new Error("join did not deliver SessionJoined");
@@ -302,12 +451,17 @@ export class SessionClient {
302
451
  startSubmit(wire) {
303
452
  return this.postWire("/v1/session/actions", wire, true);
304
453
  }
305
- async acknowledge(context, cursor) {
306
- const response = await this.postWire("/v1/session/acknowledgements", encodeAckFrame(context, cursor), true);
454
+ async acknowledge(context, cursor, originToken) {
455
+ const response = await this.postWire("/v1/session/acknowledgements", encodeAckFrame(context, cursor), true, originToken);
307
456
  if (!response.ok)
308
457
  throw new Error(`ack failed (${response.status}): ${await response.text()}`);
309
458
  }
310
459
  async resume(context, cursor) {
460
+ /* A retained context from another session version is not resumable: its
461
+ cursor and token belong to a contract this build does not speak. This
462
+ runs first so no challenge, signature, or request is spent on it. */
463
+ requireSessionVersion(context.sessionVersion);
464
+ await this.requireCompatibleDiscovery();
311
465
  const challenge = await this.challenge();
312
466
  const request = {
313
467
  context,
@@ -323,6 +477,13 @@ export class SessionClient {
323
477
  const headerToken = response.headers.get("session-token");
324
478
  if (headerToken)
325
479
  this.token = headerToken;
480
+ /* A resumed binding delivers from a buffer of its own, indexed from zero;
481
+ `after` is an index into that buffer, not the stream position the last
482
+ binding reached. Kept, it asked a fresh buffer for everything past the
483
+ old index, so every poll came back empty while the authority's queue for
484
+ this seat filled and closed: a seat that resumed once, late in a
485
+ sitting, never saw another turn. */
486
+ this.eventsAfter = 0n;
326
487
  }
327
488
  async answerSeatAuth(challenge, coordinatorKey, timeAuthorityKey, pending) {
328
489
  const preimage = authorizeSeatChallenge({
@@ -340,10 +501,40 @@ export class SessionClient {
340
501
  throw new Error(`seat-authorization failed (${response.status}): ${await response.text()}`);
341
502
  }
342
503
  }
343
- export async function playSeat(args) {
504
+ /** A GET asked again while the origin answers as a restarting one does -- no
505
+ * answer, 408, 429 or a 5xx -- until `bound` has passed. Any other answer is
506
+ * returned as it came, so a refusal stays the caller's to read. */
507
+ export async function fetchWhileRestarting(fetchImpl, url, retry = {
508
+ boundMs: DISCOVERY_RETRY_BOUND_MS,
509
+ pauseMs: DISCOVERY_RETRY_PAUSE_MS,
510
+ }) {
511
+ const sleep = retry.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
512
+ const until = Date.now() + retry.boundMs;
513
+ for (;;) {
514
+ const lastChance = Date.now() + retry.pauseMs > until;
515
+ try {
516
+ const response = await fetchImpl(url);
517
+ const restarting = response.status === 408 ||
518
+ response.status === 429 ||
519
+ response.status >= 500;
520
+ if (!restarting || lastChance)
521
+ return response;
522
+ await response.body?.cancel();
523
+ }
524
+ catch (error) {
525
+ if (lastChance)
526
+ throw error;
527
+ }
528
+ await sleep(retry.pauseMs);
529
+ }
530
+ }
531
+ export async function openSeatSession(args) {
344
532
  const fetchImpl = args.fetchImpl ?? fetch;
345
533
  const product = args.productUrl.replace(/\/$/, "");
346
- const offer = await fetchImpl(`${product}/open/v1/playground/matches/${args.offerId}`);
534
+ /* Read again while the product is coming back: a seat reopens its session
535
+ after every dropped stream, and the product answering 502 for the seconds
536
+ it restarts ended the seat as surely as a refusal would have. */
537
+ const offer = await fetchWhileRestarting(fetchImpl, `${product}/open/v1/playground/matches/${args.offerId}`, args.restartRetry);
347
538
  if (!offer.ok)
348
539
  throw new Error(`offer read failed (${offer.status}): ${await offer.text()}`);
349
540
  const record = (await offer.json());
@@ -374,9 +565,29 @@ export async function playSeat(args) {
374
565
  throw new Error("the admission names no time-authority key; pass timeAuthorityKey");
375
566
  const executionHex = record.admission.execution_id.replace(/^0x/i, "");
376
567
  const executionId = `0x${executionHex}`;
377
- const nonce = new Uint8Array(randomBytes(32));
378
- nonce[0] = args.seat & 0xff;
568
+ const nonce = args.clientNonce ?? new Uint8Array(randomBytes(32));
569
+ if (!args.clientNonce)
570
+ nonce[0] = args.seat & 0xff;
379
571
  const client = new SessionClient(record.admission.session_base_url, args.agent, args.seat, args.agentId, fromHex(record.admission.execution_id), fromHex(record.admission.manifest_digest), nonce, fetchImpl);
572
+ return {
573
+ client,
574
+ product,
575
+ executionHex,
576
+ coordinatorKey,
577
+ timeAuthorityKey,
578
+ sessionBaseUrl: record.admission.session_base_url,
579
+ executionId,
580
+ };
581
+ }
582
+ export async function playSeat(args) {
583
+ const fetchImpl = args.fetchImpl ?? fetch;
584
+ const { client, product, executionHex, coordinatorKey, timeAuthorityKey, executionId, } = await openSeatSession(args);
585
+ const record = {
586
+ admission: {
587
+ execution_id: executionId,
588
+ session_base_url: client.baseUrl,
589
+ },
590
+ };
380
591
  const strategy = args.strategy ?? "fold-heavy";
381
592
  const decide = args.decide;
382
593
  const acted = new Set();
@@ -386,10 +597,16 @@ export async function playSeat(args) {
386
597
  let said = 0;
387
598
  let saidRefused = 0;
388
599
  let lastHandSeen = null;
600
+ const elimination = eliminationWatch(args.seat);
601
+ let lastIdleTableReadMs = 0;
389
602
  /* Who the other seats are, read once per agent for the whole sitting: a
390
603
  name does not change mid-match, and a read per turn would be a read per
391
604
  turn per seat. */
392
605
  const names = new Map();
606
+ /* The seat's own key on the table read. Without it a seat in a private room
607
+ is just another stranger at the door, and the decision below would be made
608
+ against a null table while the agent was sitting at it. */
609
+ const tableCapability = agentReadCapability(args.agent, args.agentId);
393
610
  let disconnectExerciseDone = false;
394
611
  let pending = null;
395
612
  let lastContext = null;
@@ -398,6 +615,10 @@ export async function playSeat(args) {
398
615
  let joined = await client.join();
399
616
  if (joined.context)
400
617
  lastContext = joined.context;
618
+ const joinedToken = client.token;
619
+ if (!joinedToken)
620
+ throw new Error("join did not return a session token");
621
+ let originToken = joinedToken;
401
622
  const handle = async (message) => {
402
623
  if (message.type === "error") {
403
624
  if (message.retryable)
@@ -413,10 +634,15 @@ export async function playSeat(args) {
413
634
  if (message.type === "actionPending" ||
414
635
  message.type === "actionAcknowledged")
415
636
  return null;
637
+ if (message.type === "predictionGateReleased") {
638
+ await client.acknowledge(message.context, message.cursor, originToken);
639
+ lastCursor = message.cursor;
640
+ return null;
641
+ }
416
642
  if ("context" in message)
417
643
  lastContext = message.context;
418
644
  if (message.type === "sessionTerminal") {
419
- await client.acknowledge(message.context, message.cursor);
645
+ await client.acknowledge(message.context, message.cursor, originToken);
420
646
  lastCursor = message.cursor;
421
647
  return {
422
648
  outcome: "terminal",
@@ -425,6 +651,7 @@ export async function playSeat(args) {
425
651
  rejoins,
426
652
  said,
427
653
  saidRefused,
654
+ eliminated: elimination.eliminated,
428
655
  terminalNonce: message.finalState.nonce.toString(),
429
656
  terminalCommitment: toHex0x(message.finalState.commitment),
430
657
  executionId: record.admission.execution_id,
@@ -456,6 +683,25 @@ export async function playSeat(args) {
456
683
  view = message.view;
457
684
  cursor = message.cursor;
458
685
  }
686
+ if (view &&
687
+ view.legalActions.length === 0 &&
688
+ !elimination.eliminated &&
689
+ Date.now() - lastIdleTableReadMs >= IDLE_TABLE_READ_MS) {
690
+ /* Nothing to decide: somebody else's turn, a hand this seat folded, or
691
+ a sitting this seat is out of. Read now and then, so the hand lines go
692
+ on and a seat that has lost its last chip says so once. */
693
+ lastIdleTableReadMs = Date.now();
694
+ const idle = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
695
+ if (idle && idle.handNumber !== lastHandSeen) {
696
+ lastHandSeen = idle.handNumber;
697
+ args.onHand?.({
698
+ number: idle.handNumber,
699
+ stack: idle.seats.find((entry) => entry.seat === args.seat)?.stack ?? null,
700
+ });
701
+ }
702
+ if (elimination.observe(idle))
703
+ args.onEliminated?.();
704
+ }
459
705
  if (view && cursor !== null && lastContext) {
460
706
  const nonceKey = view.state.nonce.toString();
461
707
  const now = BigInt(Date.now());
@@ -467,7 +713,7 @@ export async function playSeat(args) {
467
713
  played a seat wrote this poll itself; the SDK owns the transport, so
468
714
  it owns this read too. Per turn, not per event: the table only
469
715
  matters at the moment there is a decision to make. */
470
- const table = await readPublicTable(fetchImpl, product, executionHex, names);
716
+ const table = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
471
717
  /* And what has been said in this hand, which the skill promised the
472
718
  decision would see. Best-effort like the table: a seat that cannot
473
719
  read the talk still has its own cards and the legal set. */
@@ -579,13 +825,15 @@ export async function playSeat(args) {
579
825
  }
580
826
  }
581
827
  }
582
- await client.acknowledge(lastContext, cursor);
828
+ await client.acknowledge(lastContext, cursor, originToken);
583
829
  lastCursor = cursor;
584
830
  if (!disconnectExerciseDone &&
585
831
  args.disconnectAfterActions !== undefined &&
586
832
  committedActions >= args.disconnectAfterActions) {
587
833
  disconnectExerciseDone = true;
588
834
  await client.resume(lastContext, lastCursor);
835
+ originToken = client.token ?? originToken;
836
+ inbox.length = 0;
589
837
  reconnects += 1;
590
838
  }
591
839
  }
@@ -613,14 +861,21 @@ export async function playSeat(args) {
613
861
  rejoins,
614
862
  said,
615
863
  saidRefused,
864
+ eliminated: elimination.eliminated,
616
865
  executionId: record.admission.execution_id,
617
866
  sessionBaseUrl: record.admission.session_base_url,
618
867
  };
619
868
  await client.resume(lastContext, lastCursor);
869
+ originToken = client.token ?? originToken;
620
870
  reconnects += 1;
621
871
  }
622
872
  }
623
873
  catch (error) {
874
+ /* A version this build cannot speak is never answered by resuming or
875
+ rejoining: the same refusal would come back, and a rejoin could even
876
+ look successful while the seat cannot read the gated stream. */
877
+ if (error instanceof UnsupportedSessionVersionError)
878
+ throw error;
624
879
  if (!lastContext || reconnects >= 5)
625
880
  throw error;
626
881
  /* Three refusals, three answers. A gone session (`UnknownSession`, or a
@@ -639,11 +894,16 @@ export async function playSeat(args) {
639
894
  rejoins,
640
895
  said,
641
896
  saidRefused,
897
+ eliminated: elimination.eliminated,
642
898
  executionId: record.admission.execution_id,
643
899
  sessionBaseUrl: record.admission.session_base_url,
644
900
  };
901
+ /* Whatever the old binding delivered and this loop had not handled yet
902
+ is delivered again from the cursor by the new one. */
903
+ inbox.length = 0;
645
904
  if (answer === "rejoin") {
646
905
  joined = await client.join();
906
+ originToken = client.token ?? originToken;
647
907
  if (joined.context)
648
908
  lastContext = joined.context;
649
909
  lastCursor = { sequence: 0n, witnessedReceipt: null };
@@ -653,6 +913,7 @@ export async function playSeat(args) {
653
913
  continue;
654
914
  }
655
915
  await client.resume(lastContext, lastCursor);
916
+ originToken = client.token ?? originToken;
656
917
  reconnects += 1;
657
918
  }
658
919
  }
@@ -684,17 +945,96 @@ export function normaliseCardCode(code) {
684
945
  const suit = trimmed.slice(-1).toLowerCase();
685
946
  return `${rank}${suit}`;
686
947
  }
687
- /** The public table for one execution, as the spectator snapshot shows it.
948
+ /** What the terminal disclosure says one seat takes off the table, read off
949
+ * the spectator snapshot's status.
688
950
  *
689
- * Best-effort: a read that does not answer is null, never a throw -- the
690
- * seat's own view is what the turn depends on, and a decision is better made
691
- * without the board than not made at all.
951
+ * Read rather than assumed: consent checks the seat's chips against it before
952
+ * it signs, and a number a client invented would be refused there. The
953
+ * disclosure is published a moment after the seat sees the terminal, so this
954
+ * waits for it, bounded well inside the consent window.
692
955
  *
693
- * `names` is filled as agents are met and read from after that, so who is
694
- * in a seat costs one custody read per agent per sitting. */
695
- export async function readPublicTable(fetchImpl, product, executionHex, names = new Map()) {
956
+ * Signed as the seat where a capability is given. A private room's snapshot
957
+ * answers only its seats and owning wallets, so an anonymous read of it never
958
+ * finds an entitlement at all, and a room whose seats read that way never
959
+ * settles. A playground or tournament sitting ignores the capability. */
960
+ export async function readDisclosedEntitlement(fetchImpl, product, executionId, seat, capability, options = {}) {
961
+ const attempts = options.attempts ?? 15;
962
+ const pauseMs = options.pauseMs ?? 1_000;
963
+ const target = `/open/v1/spectator/executions/${executionId}`;
964
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
965
+ try {
966
+ const header = capability ? await capability("GET", target) : null;
967
+ const response = await fetchImpl(`${product.replace(/\/$/, "")}${target}`, header === null
968
+ ? undefined
969
+ : { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
970
+ if (response.ok) {
971
+ const body = (await response.json());
972
+ const chips = body.status?.entitlements?.find((entitlement) => entitlement.seat === seat)?.chips;
973
+ if (chips !== undefined)
974
+ return BigInt(chips);
975
+ }
976
+ }
977
+ catch {
978
+ /* A read that did not answer is another attempt, not an end. */
979
+ }
980
+ await new Promise((resolve) => setTimeout(resolve, pauseMs));
981
+ }
982
+ return undefined;
983
+ }
984
+ /** Read a sitting's status off the public history surface.
985
+ *
986
+ * Its own read rather than part of `readPublicTable`, because that answers
987
+ * what is on the table right now and returns nothing at all once the table is
988
+ * gone -- which is precisely the moment this question is being asked. */
989
+ export async function readSittingStatus(fetchImpl, product, executionHex) {
990
+ try {
991
+ const response = await fetchImpl(`${product}/open/v1/history/matches/${executionHex}`);
992
+ if (!response.ok)
993
+ return { state: "unknown" };
994
+ const wire = (await response.json());
995
+ switch (wire.status) {
996
+ case "completed":
997
+ case "settling":
998
+ case "failed":
999
+ return { state: "over", detail: wire.status };
1000
+ case "live":
1001
+ return { state: "live" };
1002
+ /* `unavailable` is the product saying it does not know, and a status
1003
+ this build has never heard of is the same thing. Neither is a reason
1004
+ to tell a seat its sitting ended. */
1005
+ default:
1006
+ return { state: "unknown" };
1007
+ }
1008
+ }
1009
+ catch {
1010
+ return { state: "unknown" };
1011
+ }
1012
+ }
1013
+ /** The capability `readPublicTable` asks for, minted on this agent's own key.
1014
+ *
1015
+ * A private room's live view is owner-session-only unless the reader holds a
1016
+ * seat at that room, and the seat says so with the same signature every other
1017
+ * Product API call carries. A playground sitting ignores one, so a seat can
1018
+ * send it without having to know which kind of table it sat down at. */
1019
+ export function agentReadCapability(agent, agentId) {
1020
+ return async (method, requestTarget) => {
1021
+ const { header } = await mintAgentHttpCapability(agent, agentId, {
1022
+ method,
1023
+ requestTarget,
1024
+ body: new Uint8Array(),
1025
+ });
1026
+ return header;
1027
+ };
1028
+ }
1029
+ export async function readPublicTable(fetchImpl, product, executionHex, names = new Map(), capability) {
1030
+ /* Bound to the target the server reconstructs from the request, which is the
1031
+ origin-form path and not the absolute URL the fetch is given. */
1032
+ const target = `/open/v1/spectator/executions/${executionHex}`;
696
1033
  try {
697
- const response = await fetchImpl(`${product}/open/v1/spectator/executions/${executionHex}`);
1034
+ const header = capability ? await capability("GET", target) : null;
1035
+ const response = await fetchImpl(`${product}${target}`, header === null
1036
+ ? undefined
1037
+ : { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
698
1038
  if (!response.ok)
699
1039
  return null;
700
1040
  const wire = (await response.json());
@@ -10,6 +10,7 @@ export declare const SESSION_TERMINAL_TAG = 12;
10
10
  export declare const SESSION_ERROR_TAG = 13;
11
11
  export declare const SEAT_AUTHORIZATION_CHALLENGE_TAG = 15;
12
12
  export declare const SEAT_AUTHORIZATION_RESPONSE_TAG = 16;
13
+ export declare const PREDICTION_GATE_RELEASED_TAG = 17;
13
14
  export declare const MAX_TRANSPORT_FRAME_BYTES: number;
14
15
  export interface WireEnvelope {
15
16
  message: string;
@@ -107,6 +108,11 @@ export type AuthorityMessage = {
107
108
  finalState: StateRef;
108
109
  cursor: ResumeCursor;
109
110
  finalReceipt: ReceiptRef | null;
111
+ } | {
112
+ type: "predictionGateReleased";
113
+ context: SessionContext;
114
+ sequence: bigint;
115
+ cursor: ResumeCursor;
110
116
  } | {
111
117
  type: "error";
112
118
  retryable: boolean;