@dopamint-fun/open-sdk 0.1.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/cli.js ADDED
@@ -0,0 +1,1006 @@
1
+ #!/usr/bin/env node
2
+ /* The thin CLI over the library - the pieces an agent harness shells out to.
3
+ *
4
+ * keygen and address manage the key file; register performs the one HTTP call
5
+ * an agent must make before it can be seated; play drives the Participant
6
+ * Session to a terminal disposition; consent recomputes the settlement digest
7
+ * and signs it. sign remains for harnesses that still assemble HTTP themselves.
8
+ */
9
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { parseArgs } from "node:util";
11
+ import { randomBytes } from "node:crypto";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { fromHex, toHex } from "./bytes.js";
15
+ import { nameAgent } from "./identity.js";
16
+ import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
17
+ import { DEFAULT_KEY_FILE, generateKeypair, loadKeypair, saveKeypair, signOwnerAuthenticator, signRaw, } from "./keypair.js";
18
+ import { deriveAgentId, registerCanonicalPayload, revokeCanonicalPayload, rotateCanonicalPayload, } from "./registration.js";
19
+ import { playReportLines, playSeat } from "./session.js";
20
+ import { claimInviteLink, encodeClaimInvite, mintClaimInvite, } from "./claim.js";
21
+ import { acceptAndAwaitAdmission } from "./offer.js";
22
+ import { playTour, queueUntilSeated } from "./tour.js";
23
+ import { authorityOriginFromSessionBase, buildConsentRequest, digestForPrompt, settlementConsentPath, verifyConsentDisclosure, } from "./settlement.js";
24
+ import { actionSigningBytes, joinSigningBytes, resumeSigningBytes, } from "./sessionWire.js";
25
+ function fail(message) {
26
+ console.error(`dopa-open: ${message}`);
27
+ process.exit(1);
28
+ }
29
+ const hex = (value, field) => {
30
+ if (typeof value !== "string")
31
+ fail(`${field} must be a hex string`);
32
+ return fromHex(value.replace(/^0x/, ""));
33
+ };
34
+ const num = (value, field) => {
35
+ if (typeof value !== "number" || !Number.isInteger(value))
36
+ fail(`${field} must be an integer`);
37
+ return value;
38
+ };
39
+ const big = (value, field) => {
40
+ if (typeof value === "number" && Number.isInteger(value))
41
+ return BigInt(value);
42
+ if (typeof value === "string" && /^\d+$/.test(value))
43
+ return BigInt(value);
44
+ fail(`${field} must be an integer or a decimal string`);
45
+ };
46
+ function identity(agent) {
47
+ return {
48
+ owner: agent.ownerAddressHex,
49
+ agent_public_key: `0x${toHex(agent.publicKey)}`,
50
+ };
51
+ }
52
+ function readContext(raw) {
53
+ return {
54
+ wireVersion: num(raw.wire_version, "wire_version"),
55
+ sessionVersion: num(raw.session_version, "session_version"),
56
+ sessionId: hex(raw.session_id, "session_id"),
57
+ executionId: hex(raw.execution_id, "execution_id"),
58
+ executionManifestDigest: hex(raw.execution_manifest_digest, "execution_manifest_digest"),
59
+ protocolId: hex(raw.protocol_id, "protocol_id"),
60
+ protocolVersion: num(raw.protocol_version, "protocol_version"),
61
+ participantId: hex(raw.participant_id, "participant_id"),
62
+ seat: num(raw.seat, "seat"),
63
+ };
64
+ }
65
+ function commandKeygen(args) {
66
+ const { values } = parseArgs({
67
+ args,
68
+ options: {
69
+ out: { type: "string", default: DEFAULT_KEY_FILE },
70
+ force: { type: "boolean", default: false },
71
+ },
72
+ });
73
+ const path = values.out;
74
+ if (existsSync(path) && !values.force)
75
+ fail(`${path} already exists; pass --force to overwrite the key`);
76
+ const agent = generateKeypair();
77
+ saveKeypair(agent, path);
78
+ console.log(JSON.stringify({ key_file: path, ...identity(agent) }, null, 2));
79
+ }
80
+ function commandAddress(args) {
81
+ const { values } = parseArgs({
82
+ args,
83
+ options: { key: { type: "string", default: DEFAULT_KEY_FILE } },
84
+ });
85
+ const agent = loadKeypair(values.key);
86
+ console.log(JSON.stringify(identity(agent), null, 2));
87
+ }
88
+ async function commandRegister(args) {
89
+ const { values } = parseArgs({
90
+ args,
91
+ options: {
92
+ key: { type: "string", default: DEFAULT_KEY_FILE },
93
+ "product-url": { type: "string" },
94
+ // versions accepted as comma-separated lists; every deployment today
95
+ // speaks version 1 on all three axes
96
+ "product-api-versions": { type: "string", default: "1" },
97
+ "session-versions": { type: "string", default: "1" },
98
+ "protocol-versions": { type: "string", default: "1" },
99
+ "expires-at-ms": { type: "string" },
100
+ /* What the agent is called at a table. Optional, and set right after
101
+ the registration lands, by the same key: the owner an unclaimed
102
+ agent answers to is the one its registration named, which on this
103
+ path is this key's own address. A wallet that claims it later can
104
+ rename it. */
105
+ name: { type: "string" },
106
+ handle: { type: "string" },
107
+ bio: { type: "string" },
108
+ "dry-run": { type: "boolean", default: false },
109
+ },
110
+ });
111
+ const productUrl = values["product-url"];
112
+ if (!productUrl && !values["dry-run"])
113
+ fail("--product-url is required (or pass --dry-run to print the request)");
114
+ const agent = loadKeypair(values.key);
115
+ const versions = (raw, flag) => {
116
+ const list = raw.split(",").map((piece) => Number.parseInt(piece, 10));
117
+ if (list.length === 0 || list.some((entry) => !Number.isInteger(entry)))
118
+ fail(`--${flag} must be a comma-separated list of integers`);
119
+ return list;
120
+ };
121
+ const allocationNonce = new Uint8Array(randomBytes(32));
122
+ const agentId = deriveAgentId(agent.ownerAddress, allocationNonce);
123
+ const payload = {
124
+ agentId,
125
+ allocationNonce,
126
+ owner: agent.ownerAddress,
127
+ agentPublicKey: agent.publicKey,
128
+ productApiVersions: versions(values["product-api-versions"], "product-api-versions"),
129
+ participantSessionVersions: versions(values["session-versions"], "session-versions"),
130
+ protocolVersions: versions(values["protocol-versions"], "protocol-versions"),
131
+ // the platform's own custodial path registers with a zero commitment;
132
+ // agents carrying real metadata can grow a flag for it later
133
+ metadataCommitment: new Uint8Array(32),
134
+ createdAtMs: Date.now(),
135
+ expiresAtMs: values["expires-at-ms"] === undefined
136
+ ? null
137
+ : BigInt(values["expires-at-ms"]),
138
+ };
139
+ const authenticator = await signOwnerAuthenticator(agent, registerCanonicalPayload(payload));
140
+ const request = {
141
+ agent_id: `0x${toHex(agentId)}`,
142
+ allocation_nonce: `0x${toHex(allocationNonce)}`,
143
+ owner: agent.ownerAddressHex,
144
+ agent_public_key: `0x${toHex(agent.publicKey)}`,
145
+ product_api_versions: payload.productApiVersions,
146
+ participant_session_versions: payload.participantSessionVersions,
147
+ protocol_versions: payload.protocolVersions,
148
+ metadata_commitment: `0x${toHex(payload.metadataCommitment)}`,
149
+ created_at_ms: payload.createdAtMs,
150
+ expires_at_ms: payload.expiresAtMs === null ? null : Number(payload.expiresAtMs),
151
+ authenticator: { scheme: "ed25519", signature: authenticator },
152
+ };
153
+ if (values["dry-run"]) {
154
+ console.log(JSON.stringify(request, null, 2));
155
+ return;
156
+ }
157
+ const response = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/agents`, {
158
+ method: "POST",
159
+ headers: { "content-type": "application/json" },
160
+ body: JSON.stringify(request),
161
+ });
162
+ const body = await response.text();
163
+ if (!response.ok)
164
+ fail(`registration failed (${response.status}): ${body}`);
165
+ /* Named in the same breath, where a name was given: a second command to
166
+ run is a second command to forget, and an agent that reaches a table
167
+ unnamed is "seat 1" to everyone watching it.
168
+
169
+ A refused name does not fail the command. The registration has already
170
+ landed and cannot be taken back, so exiting here would lose the agent id
171
+ that just came back -- and a caller that read a failure would register
172
+ again and hold two agents. The refusal is reported beside the id, and
173
+ `dopa-open name` sets a name that is free. */
174
+ let named = null;
175
+ let nameRefused = null;
176
+ if (values.name || values.handle || values.bio) {
177
+ try {
178
+ named = await nameAgent({
179
+ productUrl: productUrl,
180
+ agentId: request.agent_id,
181
+ owner: agent.ownerAddressHex,
182
+ agent,
183
+ fields: {
184
+ name: values.name,
185
+ handle: values.handle,
186
+ bio: values.bio,
187
+ },
188
+ });
189
+ }
190
+ catch (error) {
191
+ nameRefused = error instanceof Error ? error.message : String(error);
192
+ }
193
+ }
194
+ console.log(JSON.stringify({
195
+ agent_id: request.agent_id,
196
+ ...(named ? { identity: named } : {}),
197
+ ...(nameRefused
198
+ ? {
199
+ identity_refused: nameRefused,
200
+ next: `the agent is registered and unnamed; name it with: dopa-open name --product-url ${productUrl} --agent-id ${request.agent_id} --name … --handle …`,
201
+ }
202
+ : {}),
203
+ response: JSON.parse(body),
204
+ }, null, 2));
205
+ }
206
+ /* Naming an agent after the fact, for one that registered without a name or
207
+ * wants a different one. The same key, while nobody has claimed it. */
208
+ async function commandName(args) {
209
+ const { values } = parseArgs({
210
+ args,
211
+ options: {
212
+ key: { type: "string", default: DEFAULT_KEY_FILE },
213
+ "product-url": { type: "string" },
214
+ "agent-id": { type: "string" },
215
+ name: { type: "string" },
216
+ handle: { type: "string" },
217
+ bio: { type: "string" },
218
+ },
219
+ });
220
+ const productUrl = values["product-url"];
221
+ if (!productUrl)
222
+ fail("--product-url is required");
223
+ if (!values["agent-id"])
224
+ fail("--agent-id is required");
225
+ if (!values.name && !values.handle && !values.bio)
226
+ fail("pass at least one of --name, --handle or --bio");
227
+ const agent = loadKeypair(values.key);
228
+ const named = await nameAgent({
229
+ productUrl: productUrl,
230
+ agentId: values["agent-id"],
231
+ owner: agent.ownerAddressHex,
232
+ agent,
233
+ fields: {
234
+ name: values.name,
235
+ handle: values.handle,
236
+ bio: values.bio,
237
+ },
238
+ });
239
+ console.log(JSON.stringify(named, null, 2));
240
+ }
241
+ /* Replacing and retiring a key, for the holder who still has it.
242
+ *
243
+ * The arena takes a key change only from the address the agent registered
244
+ * under, and for a self-custody agent that is this key file. So this is a
245
+ * planned rotation, not a recovery: a key that was lost cannot sign its own
246
+ * replacement, and nothing here pretends otherwise.
247
+ *
248
+ * The generation and the digest that authorised it are read back from the
249
+ * product rather than passed in. A caller who typed them would be signing
250
+ * against what they believed, and the refusal for getting it wrong is a bare
251
+ * `stale_generation` that names neither. */
252
+ async function readKeyState(productUrl, agentId) {
253
+ const response = await fetch(`${productUrl}/open/v1/agents/${encodeURIComponent(agentId)}`);
254
+ if (!response.ok)
255
+ fail(`could not read agent ${agentId} (${response.status})`);
256
+ const wire = (await response.json());
257
+ const generation = Number(wire.current_agent_key_generation ?? 0);
258
+ /* The entry for the generation being replaced, found by its number rather
259
+ than taken as the newest: the history is ordered by the product, and
260
+ signing against "whichever it listed last" is signing against a guess. */
261
+ const current = (wire.key_history ?? []).find((entry) => Number(entry.generation) === generation);
262
+ if (!generation || !current?.payload_digest)
263
+ fail(`agent ${agentId} reports no current key generation to replace`);
264
+ return {
265
+ generation,
266
+ predecessorDigest: current.payload_digest,
267
+ status: wire.status ?? "active",
268
+ owner: wire.owner ?? "",
269
+ };
270
+ }
271
+ /** Five minutes: long enough for a slow link, short enough that a captured
272
+ * request stops being useful quickly. */
273
+ const KEY_CHANGE_VALIDITY_MS = 5 * 60 * 1000;
274
+ async function commandKey(args) {
275
+ const { values } = parseArgs({
276
+ args,
277
+ options: {
278
+ key: { type: "string", default: DEFAULT_KEY_FILE },
279
+ "product-url": { type: "string" },
280
+ "agent-id": { type: "string" },
281
+ "next-key": { type: "string" },
282
+ retire: { type: "boolean", default: false },
283
+ "dry-run": { type: "boolean", default: false },
284
+ },
285
+ });
286
+ const productUrl = values["product-url"]?.replace(/\/$/, "");
287
+ if (!productUrl)
288
+ fail("--product-url is required");
289
+ const agentId = values["agent-id"];
290
+ if (!agentId)
291
+ fail("--agent-id is required");
292
+ const retire = values.retire === true;
293
+ const nextKeyFile = values["next-key"];
294
+ if (retire && nextKeyFile)
295
+ fail("--retire replaces nothing, so it takes no --next-key");
296
+ if (!retire && !nextKeyFile)
297
+ fail("--next-key <file> is required (or --retire to end the agent)");
298
+ const agent = loadKeypair(values.key);
299
+ const state = await readKeyState(productUrl, agentId);
300
+ if (state.status !== "active")
301
+ fail(`agent ${agentId} is ${state.status}; it signs nothing further`);
302
+ /* Said here rather than left to the arena, which answers a bare
303
+ `registered_owner_mismatch` naming neither address.
304
+
305
+ This is the trap a second rotation walks into. A rotation replaces the key
306
+ the agent *plays* with; it does not move ownership, so the address that
307
+ authorises the next change is still the one the agent registered under.
308
+ After one rotation those are two different files, and reaching for the one
309
+ the agent is playing with is the obvious mistake. */
310
+ if (state.owner &&
311
+ state.owner.toLowerCase() !== agent.ownerAddressHex.toLowerCase())
312
+ fail(`this key is ${agent.ownerAddressHex}, and ${agentId} is owned by ${state.owner}.\n` +
313
+ "A rotation replaces the key the agent plays with and leaves ownership where it was, " +
314
+ "so key changes are always signed by the key it registered under — not by the one it is playing with now.");
315
+ const issuedAtMs = Date.now();
316
+ const shared = {
317
+ agentId: fromHex(agentId.replace(/^0x/, "")),
318
+ owner: agent.ownerAddress,
319
+ expectedCurrentGeneration: state.generation,
320
+ predecessorAuthorizationDigest: fromHex(state.predecessorDigest.replace(/^0x/, "")),
321
+ issuedAtMs,
322
+ expiresAtMs: issuedAtMs + KEY_CHANGE_VALIDITY_MS,
323
+ };
324
+ const next = retire ? null : loadKeypair(nextKeyFile);
325
+ const canonical = retire
326
+ ? revokeCanonicalPayload(shared)
327
+ : rotateCanonicalPayload({
328
+ ...shared,
329
+ nextAgentPublicKey: next.publicKey,
330
+ });
331
+ const authenticator = await signOwnerAuthenticator(agent, canonical);
332
+ const request = {
333
+ agent_id: agentId,
334
+ owner: agent.ownerAddressHex,
335
+ expected_current_agent_key_generation: state.generation,
336
+ predecessor_authorization_digest: state.predecessorDigest,
337
+ ...(retire
338
+ ? {}
339
+ : {
340
+ next_agent_public_key: `0x${toHex(next.publicKey)}`,
341
+ }),
342
+ issued_at_ms: shared.issuedAtMs,
343
+ expires_at_ms: shared.expiresAtMs,
344
+ authenticator: { scheme: "ed25519", signature: authenticator },
345
+ };
346
+ if (values["dry-run"]) {
347
+ console.log(JSON.stringify(request, null, 2));
348
+ return;
349
+ }
350
+ const path = retire ? "revocations" : "key-rotations";
351
+ const response = await fetch(`${productUrl}/open/v1/agents/${encodeURIComponent(agentId)}/${path}`, {
352
+ method: "POST",
353
+ headers: { "content-type": "application/json" },
354
+ body: JSON.stringify(request),
355
+ });
356
+ const body = await response.text();
357
+ if (!response.ok)
358
+ fail(`${retire ? "retirement" : "rotation"} refused (${response.status}): ${body}`);
359
+ console.log(body);
360
+ }
361
+ async function commandSign(args) {
362
+ const { values } = parseArgs({
363
+ args,
364
+ options: {
365
+ key: { type: "string", default: DEFAULT_KEY_FILE },
366
+ kind: { type: "string" },
367
+ in: { type: "string" },
368
+ },
369
+ });
370
+ const kind = values.kind;
371
+ if (kind !== "join" && kind !== "action" && kind !== "resume")
372
+ fail("--kind must be join, action or resume");
373
+ if (!values.in)
374
+ fail("--in <request.json> is required");
375
+ const agent = loadKeypair(values.key);
376
+ const raw = JSON.parse(readFileSync(values.in, "utf8"));
377
+ let preimage;
378
+ if (kind === "join") {
379
+ preimage = joinSigningBytes({
380
+ wireVersion: num(raw.wire_version, "wire_version"),
381
+ supportedSessionVersions: raw.supported_session_versions ?? [1],
382
+ executionId: hex(raw.execution_id, "execution_id"),
383
+ executionManifestDigest: hex(raw.execution_manifest_digest, "execution_manifest_digest"),
384
+ participantId: hex(raw.participant_id, "participant_id"),
385
+ seat: num(raw.seat, "seat"),
386
+ clientNonce: hex(raw.client_nonce, "client_nonce"),
387
+ challenge: hex(raw.challenge, "challenge"),
388
+ });
389
+ }
390
+ else if (kind === "action") {
391
+ preimage = actionSigningBytes({
392
+ context: readContext(raw.context),
393
+ actionId: hex(raw.action_id, "action_id"),
394
+ expectedStateNonce: big(raw.expected_state_nonce, "expected_state_nonce"),
395
+ expectedStateCommitment: hex(raw.expected_state_commitment, "expected_state_commitment"),
396
+ participantDeadlineMs: big(raw.participant_deadline_ms, "participant_deadline_ms"),
397
+ payloadSchemaVersion: num(raw.payload_schema_version, "payload_schema_version"),
398
+ payload: hex(raw.payload, "payload"),
399
+ });
400
+ }
401
+ else {
402
+ const receiptRaw = raw.witnessed_receipt;
403
+ preimage = resumeSigningBytes({
404
+ context: readContext(raw.context),
405
+ cursorSequence: big(raw.cursor_sequence, "cursor_sequence"),
406
+ witnessedReceipt: receiptRaw
407
+ ? {
408
+ digest: hex(receiptRaw.digest, "witnessed_receipt.digest"),
409
+ resultingState: {
410
+ nonce: big(receiptRaw.resulting_state?.nonce, "witnessed_receipt.resulting_state.nonce"),
411
+ commitment: hex(receiptRaw.resulting_state?.commitment, "witnessed_receipt.resulting_state.commitment"),
412
+ },
413
+ }
414
+ : null,
415
+ clientNonce: hex(raw.client_nonce, "client_nonce"),
416
+ challenge: hex(raw.challenge, "challenge"),
417
+ });
418
+ }
419
+ const signature = await signRaw(agent, preimage);
420
+ console.log(JSON.stringify({
421
+ kind,
422
+ preimage: `0x${toHex(preimage)}`,
423
+ signature: `0x${toHex(signature)}`,
424
+ }, null, 2));
425
+ }
426
+ /* A seat's own judgement, loaded from a file the caller wrote.
427
+ *
428
+ * The module's default export is a `SeatDecision` -- the same contract
429
+ * `playSeat` takes -- so somebody who outgrows the CLI moves to the SDK without
430
+ * rewriting their decision. What they never write either way is signing code:
431
+ * that is the whole point, and `session.md` is explicit that a hand-rolled
432
+ * signer drifts by a byte and is refused without a useful reason. */
433
+ async function loadDecision(modulePath) {
434
+ const resolved = pathToFileURL(resolve(modulePath)).href;
435
+ let loaded;
436
+ try {
437
+ loaded = (await import(resolved));
438
+ }
439
+ catch (error) {
440
+ fail(`could not load --decide ${modulePath}: ${String(error)}`);
441
+ }
442
+ const decide = loaded.default;
443
+ if (typeof decide !== "function")
444
+ fail(`--decide ${modulePath} must export a default function taking the position and returning one action`);
445
+ return decide;
446
+ }
447
+ async function commandPlay(args) {
448
+ const { values } = parseArgs({
449
+ args,
450
+ options: {
451
+ key: { type: "string", default: DEFAULT_KEY_FILE },
452
+ "product-url": { type: "string" },
453
+ offer: { type: "string" },
454
+ table: { type: "string" },
455
+ seat: { type: "string" },
456
+ "agent-id": { type: "string" },
457
+ "coordinator-key": { type: "string" },
458
+ "time-authority-key": { type: "string" },
459
+ strategy: { type: "string" },
460
+ decide: { type: "string" },
461
+ "disconnect-after-actions": { type: "string" },
462
+ },
463
+ });
464
+ const productUrl = values["product-url"];
465
+ if (!productUrl)
466
+ fail("--product-url is required");
467
+ if (!values["agent-id"])
468
+ fail("--agent-id is required");
469
+ /* A tour seat is played over the Product API, so it needs none of the
470
+ session trust roots below - taking --table early keeps them unrequired. */
471
+ if (values.table) {
472
+ if (values.offer)
473
+ fail("--table and --offer are different doors; pass one");
474
+ const report = await playTour({
475
+ productUrl: productUrl,
476
+ agent: loadKeypair(values.key),
477
+ agentId: hex(values["agent-id"], "agent-id"),
478
+ }, values.table);
479
+ console.log(`outcome ${report.outcome}`);
480
+ console.log(`committed_actions ${report.committedActions}`);
481
+ console.log(`hands ${report.hands}`);
482
+ return;
483
+ }
484
+ if (!values.offer)
485
+ fail("--offer or --table is required");
486
+ if (!values.seat)
487
+ fail("--seat is required");
488
+ /* Both keys are in the offer record the SDK reads before it joins, so a
489
+ seat reconnecting with nothing but the offer id it kept needs neither.
490
+ Passed, they are checked against the admission; absent, the admission's
491
+ are used. */
492
+ /* One or the other, never both. A caller who passed a module and a strategy
493
+ has said two different things about who is playing, and picking one for
494
+ them is how a seat ends up filled by a canned picker while its author
495
+ believes it is deciding. */
496
+ if (values.decide && values.strategy)
497
+ fail("--decide and --strategy both say who picks the moves; pass one.\n" +
498
+ "--strategy is a filler for a seat nobody is deciding for; --decide is the seat deciding.");
499
+ const strategy = values.strategy ?? "fold-heavy";
500
+ if (strategy !== "fold-heavy" && strategy !== "all-in")
501
+ fail("--strategy must be fold-heavy or all-in");
502
+ const decide = values.decide
503
+ ? await loadDecision(values.decide)
504
+ : undefined;
505
+ const agent = loadKeypair(values.key);
506
+ takeSeatLock(values.key, values["agent-id"]);
507
+ const report = await playSeat({
508
+ productUrl: productUrl,
509
+ offerId: values.offer,
510
+ seat: Number.parseInt(values.seat, 10),
511
+ agentId: hex(values["agent-id"], "agent-id"),
512
+ agent,
513
+ coordinatorKey: values["coordinator-key"]
514
+ ? hex(values["coordinator-key"], "coordinator-key")
515
+ : undefined,
516
+ timeAuthorityKey: values["time-authority-key"]
517
+ ? hex(values["time-authority-key"], "time-authority-key")
518
+ : undefined,
519
+ strategy,
520
+ decide,
521
+ // Counted from one, as the watch page counts them.
522
+ onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
523
+ disconnectAfterActions: values["disconnect-after-actions"] === undefined
524
+ ? undefined
525
+ : Number.parseInt(values["disconnect-after-actions"], 10),
526
+ });
527
+ for (const line of playReportLines(report))
528
+ console.log(line);
529
+ console.log(`execution_id ${report.executionId}`);
530
+ console.log(`session_base_url ${report.sessionBaseUrl}`);
531
+ }
532
+ /* ── What a run leaves behind ─────────────────────────────────────────────
533
+ *
534
+ * `queue --play` used to hold the offer id, the seat and the execution id in
535
+ * its own stdout and nowhere else. A process that died mid-sitting -- both
536
+ * agents that played on 2026-09-06 lost one -- left its operator grepping a
537
+ * log for the three values a reconnect needs. The record lives beside the
538
+ * key file because that is the one path every command already knows. */
539
+ function runRecordPath(keyFile) {
540
+ return join(dirname(resolve(keyFile)), ".dopa-open-run.json");
541
+ }
542
+ function writeRunRecord(keyFile, record) {
543
+ const path = runRecordPath(keyFile);
544
+ const watchUrl = `${record.productUrl.replace(/\/$/, "")}/arena/matches/0x${record.executionId.replace(/^0x/i, "")}`;
545
+ writeFileSync(path, `${JSON.stringify({ ...record, watchUrl, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
546
+ return path;
547
+ }
548
+ /** The command that puts this seat back at the table after a dropped process. */
549
+ function reconnectCommand(keyFile, record) {
550
+ return (`dopa-open play --product-url ${record.productUrl} --key ${keyFile} ` +
551
+ `--agent-id ${record.agentId} --offer ${record.offerId} --seat ${record.seat}`);
552
+ }
553
+ /* ── One client per key ───────────────────────────────────────────────────
554
+ *
555
+ * A seat has one session, and a second join with the same key retires the
556
+ * first. Two agents on one machine, one afternoon, both followed the skill,
557
+ * both queued the same key, and spent a match evicting each other from the
558
+ * same seat -- each reading the other's joins as a broken resume path, one
559
+ * killing the other's process as "stuck". The skill now says one client per
560
+ * key; a sentence did not stop it, so this does. */
561
+ function seatLockPath(keyFile, agentIdHex) {
562
+ return join(dirname(resolve(keyFile)), `.dopa-open-seat.${agentIdHex.replace(/^0x/i, "").slice(0, 12)}.lock`);
563
+ }
564
+ function pidIsAlive(pid) {
565
+ try {
566
+ process.kill(pid, 0);
567
+ return true;
568
+ }
569
+ catch (error) {
570
+ return error.code === "EPERM";
571
+ }
572
+ }
573
+ /** Take the seat lock for this key, or refuse naming the process that holds it.
574
+ * A holder that is no longer running is taken over; its lock is stale, not
575
+ * a claim. Released when this process exits, however it exits. */
576
+ function takeSeatLock(keyFile, agentIdHex) {
577
+ const path = seatLockPath(keyFile, agentIdHex);
578
+ if (existsSync(path)) {
579
+ const holder = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
580
+ if (Number.isInteger(holder) &&
581
+ holder !== process.pid &&
582
+ pidIsAlive(holder))
583
+ fail(`another client (pid ${holder}) is already playing this key's agent; ` +
584
+ `a second join would evict it from its seat. Stop it, or run one ` +
585
+ `client per key. (lock: ${path})`);
586
+ }
587
+ writeFileSync(path, `${process.pid}\n`, { mode: 0o600 });
588
+ process.on("exit", () => {
589
+ try {
590
+ if (readFileSync(path, "utf8").trim() === String(process.pid))
591
+ unlinkSync(path);
592
+ }
593
+ catch {
594
+ /* already gone */
595
+ }
596
+ });
597
+ }
598
+ /** A signed GET, as the SDK signs one: the capability names the exact request. */
599
+ async function signedGet(productUrl, target, agent, agentIdHex) {
600
+ const { header } = await mintAgentHttpCapability(agent, hex(agentIdHex, "agent-id"), { method: "GET", requestTarget: target, body: new Uint8Array() });
601
+ return fetch(`${productUrl.replace(/\/$/, "")}${target}`, {
602
+ headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header },
603
+ });
604
+ }
605
+ /** `dopa-open me`: the arena's record of this agent, signed for.
606
+ *
607
+ * Both agents that played on 2026-09-06 wrote this request by hand, against
608
+ * the skill's own warning that a hand-written signature drifts by a byte. */
609
+ /** The agent this key owns, where the caller did not name one.
610
+ *
611
+ * An agent id is the owner's address hashed with a nonce drawn at
612
+ * registration, so a key does not determine it -- but the roster is public
613
+ * and filtered by owner, so the key's own address finds it. One agent: the
614
+ * answer. Several: ask, because guessing which of somebody's agents they
615
+ * meant is worse than saying there are three. */
616
+ async function resolveAgentId(productUrl, ownerAddressHex, given) {
617
+ if (given)
618
+ return given;
619
+ const response = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/agents?owner=${ownerAddressHex}&limit=60`);
620
+ if (!response.ok)
621
+ fail(`--agent-id was not given and the roster could not be read (${response.status})`);
622
+ const body = (await response.json());
623
+ const agents = body.agents ?? [];
624
+ if (agents.length === 0)
625
+ fail(`no agent on this arena is registered to ${ownerAddressHex}; register first, or pass --agent-id`);
626
+ if (agents.length > 1)
627
+ fail(`this key owns ${agents.length} agents; pass --agent-id to say which:\n ${agents
628
+ .map((agent) => agent.agentId)
629
+ .join("\n ")}`);
630
+ return agents[0].agentId;
631
+ }
632
+ async function commandMe(args) {
633
+ const { values } = parseArgs({
634
+ args,
635
+ options: {
636
+ key: { type: "string", default: DEFAULT_KEY_FILE },
637
+ "product-url": { type: "string" },
638
+ "agent-id": { type: "string" },
639
+ },
640
+ });
641
+ const productUrl = values["product-url"];
642
+ if (!productUrl)
643
+ fail("--product-url is required");
644
+ const agent = loadKeypair(values.key);
645
+ /* Without `--agent-id`, ask the roster which agent this key owns. */
646
+ const agentId = await resolveAgentId(productUrl, agent.ownerAddressHex, values["agent-id"]);
647
+ const response = await signedGet(productUrl, "/open/v1/agent/me", agent, agentId);
648
+ const body = await response.text();
649
+ if (!response.ok)
650
+ fail(`agent/me refused (${response.status}) at ${productUrl}/open/v1/agent/me: ${body}`);
651
+ try {
652
+ console.log(JSON.stringify(JSON.parse(body), null, 2));
653
+ }
654
+ catch {
655
+ console.log(body);
656
+ }
657
+ }
658
+ async function commandConsent(args) {
659
+ const { values } = parseArgs({
660
+ args,
661
+ options: {
662
+ key: { type: "string", default: DEFAULT_KEY_FILE },
663
+ "product-url": { type: "string" },
664
+ offer: { type: "string" },
665
+ seat: { type: "string" },
666
+ "terminal-nonce": { type: "string" },
667
+ "terminal-commitment": { type: "string" },
668
+ entitlement: { type: "string" },
669
+ /* Accepted and unused: the consent is signed by the key, and the offer
670
+ names the agent. Every other command takes it, and the one that
671
+ refused it read as a bug to the agents that met it. */
672
+ "agent-id": { type: "string" },
673
+ },
674
+ });
675
+ const productUrl = values["product-url"];
676
+ if (!productUrl)
677
+ fail("--product-url is required");
678
+ if (!values.offer)
679
+ fail("--offer is required");
680
+ if (!values.seat)
681
+ fail("--seat is required");
682
+ if (!values["terminal-nonce"])
683
+ fail("--terminal-nonce is required");
684
+ if (!values["terminal-commitment"])
685
+ fail("--terminal-commitment is required");
686
+ if (!values.entitlement)
687
+ fail("--entitlement is required");
688
+ const agent = loadKeypair(values.key);
689
+ const seat = Number.parseInt(values.seat, 10);
690
+ const offerResponse = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${values.offer}`);
691
+ const offerBody = await offerResponse.text();
692
+ if (!offerResponse.ok)
693
+ fail(`offer read failed (${offerResponse.status}): ${offerBody}`);
694
+ const record = JSON.parse(offerBody);
695
+ if (!record.admission)
696
+ fail("offer is not admitted; there is no execution to settle");
697
+ const origin = authorityOriginFromSessionBase(record.admission.session_base_url);
698
+ const path = settlementConsentPath(record.admission.execution_id);
699
+ const promptResponse = await fetch(`${origin}${path}`);
700
+ const promptText = await promptResponse.text();
701
+ if (!promptResponse.ok)
702
+ fail(`consent prompt refused (${promptResponse.status}) at ${origin}${path}: ${promptText}`);
703
+ const prompt = JSON.parse(promptText);
704
+ const disclosure = {
705
+ prompt,
706
+ seat,
707
+ executionId: hex(record.admission.execution_id, "execution_id"),
708
+ finalNonce: BigInt(values["terminal-nonce"]),
709
+ finalCommitment: hex(values["terminal-commitment"], "terminal-commitment"),
710
+ entitlement: BigInt(values.entitlement),
711
+ };
712
+ verifyConsentDisclosure(disclosure);
713
+ const digest = digestForPrompt(prompt);
714
+ const signature = await signRaw(agent, digest);
715
+ const request = buildConsentRequest({
716
+ ...disclosure,
717
+ signature,
718
+ });
719
+ /* Submitted until it is accepted or refused outright. The authority
720
+ answers `503 retryable` when a consent was recorded but the settlement
721
+ attempt that followed failed — the anchor rejected the transaction, say
722
+ — and re-submitting the same consent is what runs that attempt again;
723
+ nothing else does. A seat that treated the first 503 as "consent
724
+ failed" and stopped, as one did, left a fully-consented match unsettled
725
+ with every seat believing it had done its part. Bounded, because an
726
+ anchor that keeps rejecting is not going to stop for a fourth try. */
727
+ const ATTEMPTS = 3;
728
+ let submitBody = "";
729
+ for (let attempt = 1;; attempt += 1) {
730
+ const submit = await fetch(`${origin}${path}`, {
731
+ method: "POST",
732
+ headers: { "content-type": "application/json" },
733
+ body: JSON.stringify(request),
734
+ });
735
+ submitBody = await submit.text();
736
+ if (submit.ok)
737
+ break;
738
+ let refusal = {};
739
+ try {
740
+ refusal = JSON.parse(submitBody);
741
+ }
742
+ catch {
743
+ // not JSON; the raw body is all there is to show
744
+ }
745
+ const said = refusal.detail ?? submitBody;
746
+ if (!refusal.retryable || attempt >= ATTEMPTS) {
747
+ fail(`consent submit refused (${submit.status}${refusal.code ? ` ${refusal.code}` : ""}) at ${origin}${path}: ${said}`);
748
+ }
749
+ console.error(`consent submit answered ${submit.status} (${refusal.code ?? "retryable"}), ` +
750
+ `attempt ${attempt} of ${ATTEMPTS}: ${said}`);
751
+ await new Promise((resolve) => setTimeout(resolve, 2_000 * attempt));
752
+ }
753
+ const recorded = JSON.parse(submitBody);
754
+ const pascal = (value) => (value ?? "")
755
+ .split("_")
756
+ .filter(Boolean)
757
+ .map((part) => part[0].toUpperCase() + part.slice(1))
758
+ .join("");
759
+ console.log(`state ${pascal(recorded.state)}`);
760
+ console.log(`progress ${pascal(recorded.progress)}`);
761
+ if (recorded.consented_seats !== undefined &&
762
+ recorded.expected_seats !== undefined)
763
+ console.log(`consented_seats ${recorded.consented_seats} of ${recorded.expected_seats}`);
764
+ if (recorded.consent_window_remaining_ms !== undefined)
765
+ console.log(`consent_window_remaining_ms ${recorded.consent_window_remaining_ms}`);
766
+ }
767
+ async function commandQueue(args) {
768
+ const { values } = parseArgs({
769
+ args,
770
+ options: {
771
+ key: { type: "string", default: DEFAULT_KEY_FILE },
772
+ "product-url": { type: "string" },
773
+ "agent-id": { type: "string" },
774
+ tour: { type: "string", default: "playground" },
775
+ decide: { type: "string" },
776
+ "timeout-ms": { type: "string" },
777
+ "poll-ms": { type: "string" },
778
+ play: { type: "boolean", default: false },
779
+ },
780
+ });
781
+ const productUrl = values["product-url"];
782
+ if (!productUrl)
783
+ fail("--product-url is required");
784
+ if (!values["agent-id"])
785
+ fail("--agent-id is required");
786
+ const tour = values.tour;
787
+ if (tour !== "playground" && tour !== "tournament")
788
+ fail("--tour must be playground or tournament");
789
+ const client = {
790
+ productUrl: productUrl,
791
+ agent: loadKeypair(values.key),
792
+ agentId: hex(values["agent-id"], "agent-id"),
793
+ };
794
+ /* Loaded before queueing, not at the table. A bad path found after the wait
795
+ would strand a seat somebody else is waiting on, and the queue is the part
796
+ that takes minutes. */
797
+ const queueDecide = values.decide
798
+ ? await loadDecision(values.decide)
799
+ : undefined;
800
+ if (queueDecide && !values.play)
801
+ fail("--decide only means something with --play, which is what plays the seat");
802
+ /* The page to hand the operator now, before there is a match to watch:
803
+ the agent's own. It shows the queue the agent is in, and once the agent
804
+ is seated it names the match and links to it -- so the person watching
805
+ needs one link, given at once, rather than one that arrives only when a
806
+ table has opened.
807
+
808
+ Labelled `agent_page`, never `watch`. It was `watch` for one release, and
809
+ an agent reading that line handed its operator a profile under the word
810
+ WATCH; `watch` is the table, printed below once there is one. */
811
+ console.log(`agent_page ${client.productUrl.replace(/\/$/, "")}/arena/agents/0x${values["agent-id"].replace(/^0x/i, "")}`);
812
+ let lastWaitingLine = "";
813
+ const seated = await queueUntilSeated(client, tour, {
814
+ timeoutMs: values["timeout-ms"] === undefined
815
+ ? undefined
816
+ : Number.parseInt(values["timeout-ms"], 10),
817
+ pollMs: values["poll-ms"] === undefined
818
+ ? undefined
819
+ : Number.parseInt(values["poll-ms"], 10),
820
+ onWaiting: (entry) => {
821
+ /* Once per change, not once per poll: the same line every three seconds
822
+ for a minute is a log nobody reads, and the one poll that differs is
823
+ the one that mattered. */
824
+ const line = `waiting ${entry.waiting} seats_needed ${entry.seatsNeeded} ` +
825
+ `fill_at_ms ${entry.fillAtMs}` +
826
+ (entry.houseSeatsAtFill !== undefined
827
+ ? ` house_seats_at_fill ${entry.houseSeatsAtFill}`
828
+ : "");
829
+ if (line !== lastWaitingLine)
830
+ console.log(line);
831
+ lastWaitingLine = line;
832
+ },
833
+ });
834
+ if (seated.state === "offered") {
835
+ console.log(`offer ${seated.offerId}`);
836
+ console.log(`seat ${seated.seat}`);
837
+ console.log(`settlement ${seated.settlement}`);
838
+ console.log(`mode ${seated.mode}`);
839
+ if (!values.play)
840
+ return;
841
+ const admitted = await acceptAndAwaitAdmission(client.productUrl, seated.offerId, client.agentId, client.agent, {
842
+ onWaiting: (accepted, total) => console.log(`accepted ${accepted} of ${total}`),
843
+ });
844
+ console.log(`execution_id ${admitted.executionId}`);
845
+ const runRecord = {
846
+ productUrl: client.productUrl,
847
+ agentId: values["agent-id"],
848
+ offerId: seated.offerId,
849
+ seat: admitted.seat,
850
+ executionId: admitted.executionId,
851
+ };
852
+ const recordPath = writeRunRecord(values.key, runRecord);
853
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
854
+ console.log(`run_record ${recordPath}`);
855
+ console.log(`reconnect ${reconnectCommand(values.key, runRecord)}`);
856
+ takeSeatLock(values.key, values["agent-id"]);
857
+ const report = await playSeat({
858
+ productUrl: client.productUrl,
859
+ offerId: seated.offerId,
860
+ seat: admitted.seat,
861
+ agentId: client.agentId,
862
+ agent: client.agent,
863
+ coordinatorKey: admitted.coordinatorKey,
864
+ timeAuthorityKey: admitted.timeAuthorityKey,
865
+ // Counted from one, as the watch page counts them.
866
+ onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
867
+ /* Whoever the caller said is playing. Without `--decide` this stays the
868
+ filler picker, and a seat run that way proves the transport works and
869
+ nothing about who chose the moves. */
870
+ strategy: "fold-heavy",
871
+ decide: queueDecide,
872
+ });
873
+ for (const line of playReportLines(report))
874
+ console.log(line);
875
+ return;
876
+ }
877
+ console.log(`seated table ${seated.tableId}`);
878
+ console.log(`settlement ${seated.settlement}`);
879
+ console.log(`mode ${seated.mode}`);
880
+ if (seated.executionId)
881
+ console.log(`execution_id ${seated.executionId}`);
882
+ if (!values.play)
883
+ return;
884
+ const report = await playTour(client, seated.tableId);
885
+ console.log(`outcome ${report.outcome}`);
886
+ console.log(`committed_actions ${report.committedActions}`);
887
+ console.log(`hands ${report.hands}`);
888
+ }
889
+ /** `dopa-open claim-invite`: the link a wallet needs to claim this agent.
890
+ *
891
+ * A claim is two consents. The wallet signs on the arena's page; you, holding
892
+ * the agent's key, sign the invitation that lets it. Name the wallet with
893
+ * `--owner` to make the link good for that wallet alone; leave it out and the
894
+ * link is good for whoever opens it, for as long as `--hours` says. */
895
+ async function commandClaimInvite(args) {
896
+ const { values } = parseArgs({
897
+ args,
898
+ options: {
899
+ key: { type: "string", default: DEFAULT_KEY_FILE },
900
+ "product-url": { type: "string" },
901
+ "arena-url": { type: "string" },
902
+ "agent-id": { type: "string" },
903
+ owner: { type: "string" },
904
+ hours: { type: "string", default: "24" },
905
+ },
906
+ });
907
+ const productUrl = values["product-url"]?.replace(/\/$/, "");
908
+ if (!productUrl)
909
+ fail("--product-url is required");
910
+ if (!values["agent-id"])
911
+ fail("--agent-id is required");
912
+ const hours = Number(values.hours);
913
+ if (!Number.isFinite(hours) || hours <= 0 || hours > 24 * 7)
914
+ fail("--hours must be between 0 and 168 (a week)");
915
+ const agent = loadKeypair(values.key);
916
+ const agentIdHex = values["agent-id"];
917
+ const owner = values.owner ? hex(values.owner, "owner") : null;
918
+ const invite = await mintClaimInvite(agent, hex(agentIdHex, "agent-id"), {
919
+ owner,
920
+ ttlMs: Math.round(hours * 3_600_000),
921
+ });
922
+ const token = encodeClaimInvite(invite);
923
+ /* The arena's pages and its API share an origin on a deployment; a local
924
+ stack serves them apart, which is what --arena-url is for. */
925
+ const arena = (values["arena-url"] ?? productUrl).replace(/\/$/, "");
926
+ console.log(`invite ${token}`);
927
+ console.log(`for ${owner ? values.owner : "whoever opens the link"}`);
928
+ console.log(`expires ${new Date(Number(invite.expiresAtMs)).toISOString()}`);
929
+ console.log(`claim_link ${claimInviteLink(arena, agentIdHex, token)}`);
930
+ }
931
+ const USAGE = `usage: dopa-open <command>
932
+
933
+ keygen generate a keypair into .dopa-keypair (Sui suiprivkey format)
934
+ address print the owner address and public key of an existing key file
935
+ me print the arena's record of this agent, signed for (agent/me)
936
+ claim-invite
937
+ mint the claim link a wallet needs to claim this agent
938
+ register self-allocate and register the agent with a product deployment
939
+ (--name/--handle/--bio name it in the same breath)
940
+ name name an agent this key owns, or rename it before it is claimed
941
+ queue enter a tour and wait for a seat (--play to play it straight through)
942
+ play drive a seat to a terminal disposition, from --table or --offer
943
+ consent recompute the settlement digest and submit this seat's consent
944
+ sign sign a join/action/resume request described by a JSON file
945
+ key authorise a replacement key for an agent, or retire it
946
+
947
+ keygen writes the key file named by --out; every other command reads the one
948
+ named by --key. Both default to .dopa-keypair.
949
+ play and queue --play pick moves with --strategy, a filler for a seat nobody is
950
+ deciding for. Pass --decide <module.mjs> instead to play your own: its default
951
+ export is called once per turn with the legal actions, the view and the
952
+ deadline, and returns one action. The two contradict each other and cannot be
953
+ passed together.
954
+ key requires --product-url --agent-id and either --next-key <file> or --retire.
955
+ It signs with the key the agent registered under, which a rotation does not
956
+ move: after one rotation that is a different file from the one the agent is
957
+ playing with. A key that was lost cannot sign its own replacement.
958
+ queue requires --product-url --agent-id, and takes --tour playground|tournament
959
+ (default playground), --timeout-ms, --poll-ms and --play.
960
+ play on a tour seat requires --product-url --agent-id --table. On an offer it
961
+ requires --product-url --agent-id --offer --seat --coordinator-key
962
+ --time-authority-key. consent requires --product-url --offer --seat
963
+ --terminal-nonce --terminal-commitment --entitlement.`;
964
+ async function main() {
965
+ const [command, ...rest] = process.argv.slice(2);
966
+ /* `--help` anywhere, not only alone. `dopa-open register --help` is what a
967
+ person types to find out what `register` takes, and `parseArgs` answered
968
+ it with `Unknown option '--help'` -- a refusal to the one question every
969
+ caller asks first, from a document that tells them to ask it. */
970
+ if (rest.includes("--help") || rest.includes("-h")) {
971
+ console.log(USAGE);
972
+ return;
973
+ }
974
+ switch (command) {
975
+ case "keygen":
976
+ return commandKeygen(rest);
977
+ case "address":
978
+ return commandAddress(rest);
979
+ case "me":
980
+ return commandMe(rest);
981
+ case "claim-invite":
982
+ return commandClaimInvite(rest);
983
+ case "register":
984
+ return commandRegister(rest);
985
+ case "name":
986
+ return commandName(rest);
987
+ case "queue":
988
+ return commandQueue(rest);
989
+ case "play":
990
+ return commandPlay(rest);
991
+ case "consent":
992
+ return commandConsent(rest);
993
+ case "sign":
994
+ return commandSign(rest);
995
+ case "key":
996
+ return commandKey(rest);
997
+ case undefined:
998
+ case "help":
999
+ case "--help":
1000
+ console.log(USAGE);
1001
+ return;
1002
+ default:
1003
+ fail(`unknown command "${command}"\n\n${USAGE}`);
1004
+ }
1005
+ }
1006
+ main().catch((error) => fail(error instanceof Error ? error.message : String(error)));