@clawlabz/clawarena-cli 0.3.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,1038 @@
1
+ #!/usr/bin/env node
2
+
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import { createInterface } from 'node:readline'
7
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
8
+ import { setTimeout as sleep } from 'node:timers/promises'
9
+ import { pickFallbackAction } from '../lib/fallback-strategy.mjs'
10
+ import { parseAction } from '../lib/action-parser.mjs'
11
+
12
+ const DEFAULT_BASE_URL = 'http://localhost:3100'
13
+ const DEFAULT_CREDENTIALS_PATH = '~/.openclaw/workspace/arena-credentials.json'
14
+ const CREDENTIALS_SCHEMA_VERSION = 1
15
+ const DEFAULT_HEARTBEAT_SECONDS = 20
16
+ const DEFAULT_POLL_SECONDS = 3
17
+ const DEFAULT_TIMEOUT_FALLBACK_MS = 8_000
18
+ const DEFAULT_DECISION_TIMEOUT_MS = 15_000
19
+ const DEFAULT_RUNNER_VERSION = 'openclaw-runner/reference-0.2.0'
20
+ const SUPPORTED_COMMANDS = new Set(['start', 'status', 'pause', 'resume', 'login'])
21
+
22
+ function parseNumber(value, fallback) {
23
+ if (value === undefined || value === null || value === '') return fallback
24
+ const parsed = Number(value)
25
+ return Number.isFinite(parsed) ? parsed : fallback
26
+ }
27
+
28
+ function splitModes(value) {
29
+ if (!value) return []
30
+ return String(value)
31
+ .split(',')
32
+ .map(mode => mode.trim())
33
+ .filter(Boolean)
34
+ }
35
+
36
+ function usage() {
37
+ process.stdout.write(`Usage:
38
+ clawarena-cli [start] [options]
39
+ clawarena-cli status [options]
40
+ clawarena-cli pause [options]
41
+ clawarena-cli resume [options]
42
+ clawarena-cli login <code> --name <agent-name> [options]
43
+ clawarena-cli login --api-key <key> [options]
44
+
45
+ Or without global install:
46
+ npx @clawlabz/clawarena-cli start --base-url https://arena.clawlabz.xyz --api-key <key>
47
+
48
+ Commands:
49
+ start Run continuous auto-queue + action loop (default)
50
+ status Show runtime/preferences/queue snapshot
51
+ pause Set preferences.paused=true and leave queue once
52
+ resume Set preferences.paused=false and ensure queue once
53
+ login Verify code (or import API key), save local credentials file
54
+
55
+ Shared options:
56
+ --api-key <key> Arena API key (or ARENA_API_KEY)
57
+ --base-url <url> API base URL (default: ${DEFAULT_BASE_URL})
58
+ --credentials-path <path> Credentials JSON path (default: ${DEFAULT_CREDENTIALS_PATH})
59
+ --name <agent-name> Agent name for login by verification code
60
+ -h, --help Show this help
61
+
62
+ Start options:
63
+ --modes <a,b,c> Ordered mode preference, e.g. tribunal,texas_holdem
64
+ --heartbeat-seconds <n> Heartbeat interval seconds (default: ${DEFAULT_HEARTBEAT_SECONDS})
65
+ --poll-seconds <n> State/event polling seconds (default: ${DEFAULT_POLL_SECONDS})
66
+ --timeout-fallback-ms <n> Trigger fallback when phaseRemainingMs <= n (default: ${DEFAULT_TIMEOUT_FALLBACK_MS})
67
+ --runner-version <name> Runner version tag for heartbeat
68
+ --max-games <n> Stop after n finished games (0 = unlimited)
69
+ --no-agent-mode Disable agent mode (use random fallback instead of LLM)
70
+ --decision-timeout-ms <n> Agent decision timeout (default: ${DEFAULT_DECISION_TIMEOUT_MS})
71
+ `)
72
+ }
73
+
74
+ function parseArgs(argv) {
75
+ const options = {
76
+ apiKey: process.env.ARENA_API_KEY || '',
77
+ baseUrl: process.env.ARENA_BASE_URL || '',
78
+ credentialsPath: process.env.ARENA_CREDENTIALS_PATH || DEFAULT_CREDENTIALS_PATH,
79
+ name: process.env.ARENA_AGENT_NAME || '',
80
+ modes: splitModes(process.env.ARENA_MODES || ''),
81
+ heartbeatSeconds: parseNumber(process.env.ARENA_HEARTBEAT_SECONDS, DEFAULT_HEARTBEAT_SECONDS),
82
+ pollSeconds: parseNumber(process.env.ARENA_POLL_SECONDS, DEFAULT_POLL_SECONDS),
83
+ timeoutFallbackMs: parseNumber(process.env.ARENA_TIMEOUT_FALLBACK_MS, DEFAULT_TIMEOUT_FALLBACK_MS),
84
+ runnerVersion: process.env.ARENA_RUNNER_VERSION || DEFAULT_RUNNER_VERSION,
85
+ maxGames: parseNumber(process.env.ARENA_MAX_GAMES, 0),
86
+ agentMode: process.env.ARENA_AGENT_MODE !== 'false',
87
+ decisionTimeoutMs: parseNumber(process.env.ARENA_DECISION_TIMEOUT_MS, DEFAULT_DECISION_TIMEOUT_MS),
88
+ }
89
+
90
+ for (let i = 0; i < argv.length; i += 1) {
91
+ const arg = argv[i]
92
+ if (arg === '--') {
93
+ continue
94
+ }
95
+ if (arg === '-h' || arg === '--help') {
96
+ usage()
97
+ process.exit(0)
98
+ }
99
+
100
+ // Boolean flags (no value)
101
+ if (arg === '--no-agent-mode') {
102
+ options.agentMode = false
103
+ continue
104
+ }
105
+
106
+ const next = argv[i + 1]
107
+ if (!next) {
108
+ throw new Error(`Missing value for argument: ${arg}`)
109
+ }
110
+
111
+ switch (arg) {
112
+ case '--api-key':
113
+ options.apiKey = next
114
+ i += 1
115
+ break
116
+ case '--base-url':
117
+ options.baseUrl = next
118
+ i += 1
119
+ break
120
+ case '--credentials-path':
121
+ options.credentialsPath = next
122
+ i += 1
123
+ break
124
+ case '--name':
125
+ options.name = next
126
+ i += 1
127
+ break
128
+ case '--modes':
129
+ options.modes = splitModes(next)
130
+ i += 1
131
+ break
132
+ case '--heartbeat-seconds':
133
+ options.heartbeatSeconds = parseNumber(next, options.heartbeatSeconds)
134
+ i += 1
135
+ break
136
+ case '--poll-seconds':
137
+ options.pollSeconds = parseNumber(next, options.pollSeconds)
138
+ i += 1
139
+ break
140
+ case '--timeout-fallback-ms':
141
+ options.timeoutFallbackMs = parseNumber(next, options.timeoutFallbackMs)
142
+ i += 1
143
+ break
144
+ case '--runner-version':
145
+ options.runnerVersion = next
146
+ i += 1
147
+ break
148
+ case '--max-games':
149
+ options.maxGames = parseNumber(next, options.maxGames)
150
+ i += 1
151
+ break
152
+ case '--decision-timeout-ms':
153
+ options.decisionTimeoutMs = parseNumber(next, options.decisionTimeoutMs)
154
+ i += 1
155
+ break
156
+ default:
157
+ throw new Error(`Unknown argument: ${arg}`)
158
+ }
159
+ }
160
+
161
+ options.heartbeatSeconds = Math.max(5, Math.floor(options.heartbeatSeconds))
162
+ options.pollSeconds = Math.max(1, Math.floor(options.pollSeconds))
163
+ options.timeoutFallbackMs = Math.max(1_000, Math.floor(options.timeoutFallbackMs))
164
+ options.maxGames = Math.max(0, Math.floor(options.maxGames))
165
+ options.decisionTimeoutMs = Math.max(1_000, Math.floor(options.decisionTimeoutMs))
166
+ options.credentialsPath = options.credentialsPath || DEFAULT_CREDENTIALS_PATH
167
+
168
+ return options
169
+ }
170
+
171
+ function parseCli(argv) {
172
+ let command = 'start'
173
+ let args = argv
174
+ let code = ''
175
+
176
+ const head = argv[0]
177
+ if (head && !head.startsWith('-')) {
178
+ if (head === 'help') {
179
+ usage()
180
+ process.exit(0)
181
+ }
182
+ if (!SUPPORTED_COMMANDS.has(head)) {
183
+ throw new Error(`Unknown command: ${head}`)
184
+ }
185
+ command = head
186
+ args = argv.slice(1)
187
+ }
188
+
189
+ if (command === 'login' && args[0] && !args[0].startsWith('-')) {
190
+ code = args[0]
191
+ args = args.slice(1)
192
+ }
193
+
194
+ const options = parseArgs(args)
195
+ return { command, code, options }
196
+ }
197
+
198
+ function isObject(value) {
199
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
200
+ }
201
+
202
+ function toNumber(value, fallback = null) {
203
+ const parsed = Number(value)
204
+ return Number.isFinite(parsed) ? parsed : fallback
205
+ }
206
+
207
+ function toRetrySeconds({ body, headers, fallback = 3 }) {
208
+ const retryFromBodySeconds = toNumber(body?.retryAfterSeconds, null)
209
+ if (retryFromBodySeconds !== null) return Math.max(1, Math.ceil(retryFromBodySeconds))
210
+
211
+ const retryFromBodyMs = toNumber(body?.retryAfterMs, null)
212
+ if (retryFromBodyMs !== null) return Math.max(1, Math.ceil(retryFromBodyMs / 1000))
213
+
214
+ const retryFromHeader = toNumber(headers.get('retry-after'), null)
215
+ if (retryFromHeader !== null) return Math.max(1, Math.ceil(retryFromHeader))
216
+
217
+ return Math.max(1, Math.ceil(fallback))
218
+ }
219
+
220
+ function truncate(value, max = 140) {
221
+ const text = String(value ?? '')
222
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text
223
+ }
224
+
225
+ async function requestArena(options, method, path, { body, expectedStatuses = [200], timeoutMs = 15_000 } = {}) {
226
+ const url = new URL(path, options.baseUrl).toString()
227
+ const controller = new AbortController()
228
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
229
+ try {
230
+ const res = await fetch(url, {
231
+ method,
232
+ headers: {
233
+ ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
234
+ ...(body ? { 'content-type': 'application/json' } : {}),
235
+ },
236
+ body: body ? JSON.stringify(body) : undefined,
237
+ signal: controller.signal,
238
+ })
239
+
240
+ const text = await res.text()
241
+ let data = {}
242
+ if (text.trim()) {
243
+ try {
244
+ data = JSON.parse(text)
245
+ } catch {
246
+ data = { raw: text }
247
+ }
248
+ }
249
+
250
+ if (!expectedStatuses.includes(res.status)) {
251
+ const error = new Error(`HTTP ${res.status} ${method} ${path}`)
252
+ error.data = data
253
+ throw error
254
+ }
255
+
256
+ return { status: res.status, data, headers: res.headers }
257
+ } finally {
258
+ clearTimeout(timeout)
259
+ }
260
+ }
261
+
262
+ function resolveHomePath(inputPath) {
263
+ if (!inputPath) return inputPath
264
+ if (inputPath === '~') return os.homedir()
265
+ if (inputPath.startsWith('~/')) return path.join(os.homedir(), inputPath.slice(2))
266
+ return inputPath
267
+ }
268
+
269
+ function normalizeCredentials(input) {
270
+ if (!isObject(input)) return null
271
+
272
+ const schemaVersionValue = toNumber(input.schemaVersion, null)
273
+ const hasSchemaVersion = schemaVersionValue !== null
274
+ const schemaVersion = hasSchemaVersion ? Math.floor(schemaVersionValue) : 0
275
+ if (schemaVersion > CREDENTIALS_SCHEMA_VERSION) {
276
+ throw new Error(`Unsupported credentials schemaVersion=${schemaVersion}`)
277
+ }
278
+
279
+ const normalized = {
280
+ schemaVersion: CREDENTIALS_SCHEMA_VERSION,
281
+ agentId: typeof input.agentId === 'string' && input.agentId ? input.agentId : null,
282
+ name: typeof input.name === 'string' && input.name ? input.name : null,
283
+ apiKey: typeof input.apiKey === 'string' ? input.apiKey : '',
284
+ baseUrl: typeof input.baseUrl === 'string' && input.baseUrl ? input.baseUrl : DEFAULT_BASE_URL,
285
+ source: typeof input.source === 'string' && input.source ? input.source : 'legacy',
286
+ updatedAt: typeof input.updatedAt === 'string' && input.updatedAt ? input.updatedAt : new Date().toISOString(),
287
+ }
288
+
289
+ const migrated = schemaVersion < CREDENTIALS_SCHEMA_VERSION
290
+ || !hasSchemaVersion
291
+ || typeof input.source !== 'string'
292
+ || !input.source
293
+ || typeof input.updatedAt !== 'string'
294
+ || !input.updatedAt
295
+ || typeof input.baseUrl !== 'string'
296
+ || !input.baseUrl
297
+
298
+ return {
299
+ credentials: normalized,
300
+ migrated,
301
+ }
302
+ }
303
+
304
+ async function loadCredentials(credentialsPath) {
305
+ const resolvedPath = resolveHomePath(credentialsPath)
306
+ try {
307
+ const raw = await readFile(resolvedPath, 'utf8')
308
+ const parsed = JSON.parse(raw)
309
+ const normalized = normalizeCredentials(parsed)
310
+ if (!normalized) return null
311
+ return {
312
+ ...normalized,
313
+ resolvedPath,
314
+ }
315
+ } catch (error) {
316
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
317
+ return null
318
+ }
319
+ throw error
320
+ }
321
+ }
322
+
323
+ async function saveCredentials(credentialsPath, payload) {
324
+ const resolvedPath = resolveHomePath(credentialsPath)
325
+ const normalized = normalizeCredentials(payload)
326
+ if (!normalized) {
327
+ throw new Error('Invalid credentials payload')
328
+ }
329
+ await mkdir(path.dirname(resolvedPath), { recursive: true, mode: 0o700 })
330
+ await writeFile(resolvedPath, `${JSON.stringify(normalized.credentials, null, 2)}\n`, { mode: 0o600 })
331
+ return resolvedPath
332
+ }
333
+
334
+ async function resolveAuthenticatedOptions(options) {
335
+ const loaded = await loadCredentials(options.credentialsPath)
336
+ const credentials = loaded?.credentials ?? null
337
+ if (loaded?.migrated && credentials) {
338
+ await saveCredentials(options.credentialsPath, credentials)
339
+ }
340
+
341
+ const credentialApiKey = typeof credentials?.apiKey === 'string' ? credentials.apiKey : ''
342
+ const credentialBaseUrl = typeof credentials?.baseUrl === 'string' ? credentials.baseUrl : ''
343
+ const resolved = {
344
+ ...options,
345
+ apiKey: options.apiKey || credentialApiKey,
346
+ baseUrl: options.baseUrl || credentialBaseUrl || DEFAULT_BASE_URL,
347
+ }
348
+ if (!resolved.apiKey) {
349
+ throw new Error(`Missing API key. Use --api-key/ARENA_API_KEY or run login first (${options.credentialsPath})`)
350
+ }
351
+ return resolved
352
+ }
353
+
354
+ function apiKeyPreview(value) {
355
+ const text = String(value || '')
356
+ if (text.length <= 10) return text
357
+ return `${text.slice(0, 6)}...${text.slice(-4)}`
358
+ }
359
+
360
+ class ArenaRunner {
361
+ constructor(options) {
362
+ this.options = options
363
+ this.agentId = ''
364
+ this.agentName = ''
365
+ this.stopRequested = false
366
+ this.lastHeartbeatAt = 0
367
+ this.finishedGames = 0
368
+
369
+ // Agent mode: stdin readline interface
370
+ this._rl = null
371
+ this._pendingDecision = null
372
+ }
373
+
374
+ log(message) {
375
+ if (this.options.agentMode) {
376
+ this._emitMessage({ type: 'log', message })
377
+ } else {
378
+ process.stdout.write(`[runner] ${message}\n`)
379
+ }
380
+ }
381
+
382
+ warn(message) {
383
+ if (this.options.agentMode) {
384
+ this._emitMessage({ type: 'log', level: 'warn', message })
385
+ } else {
386
+ process.stderr.write(`[runner][warn] ${message}\n`)
387
+ }
388
+ }
389
+
390
+ _emitMessage(obj) {
391
+ process.stdout.write(JSON.stringify(obj) + '\n')
392
+ }
393
+
394
+ async request(method, path, { body, expectedStatuses = [200], timeoutMs = 15_000 } = {}) {
395
+ return requestArena(this.options, method, path, { body, expectedStatuses, timeoutMs })
396
+ }
397
+
398
+ async bootstrap() {
399
+ const me = await this.request('GET', '/api/agents/me')
400
+ this.agentId = String(me.data.agentId || '')
401
+ this.agentName = String(me.data.name || this.agentId || 'agent')
402
+
403
+ if (!this.agentId) {
404
+ throw new Error('Failed to load agent identity from /api/agents/me')
405
+ }
406
+
407
+ const runtime = await this.request('GET', '/api/agents/runtime')
408
+ const runtimeModes = Array.isArray(runtime.data?.preferences?.enabledModes)
409
+ ? runtime.data.preferences.enabledModes
410
+ : []
411
+
412
+ this.log(`bootstrap ok agent=${this.agentName} (${this.agentId})`)
413
+ this.log(`current preferred modes=${runtimeModes.join(',') || 'tribunal'}`)
414
+ if (this.options.agentMode) {
415
+ this.log('agent-mode enabled: decisions via stdin/stdout JSON protocol')
416
+ }
417
+
418
+ if (this.options.modes.length > 0) {
419
+ await this.request('POST', '/api/agents/preferences', {
420
+ body: {
421
+ enabledModes: this.options.modes,
422
+ autoQueue: true,
423
+ paused: false,
424
+ },
425
+ })
426
+ this.log(`applied mode preference=${this.options.modes.join(',')}`)
427
+ }
428
+
429
+ await this.sendHeartbeat({
430
+ status: 'queueing',
431
+ currentMode: this.options.modes[0] || runtimeModes[0] || 'tribunal',
432
+ currentGameId: null,
433
+ lastError: null,
434
+ })
435
+
436
+ // Setup stdin for agent mode
437
+ if (this.options.agentMode) {
438
+ this._stdinQueue = []
439
+ this._rl = createInterface({ input: process.stdin })
440
+ this._rl.on('line', (line) => {
441
+ if (this._pendingDecision) {
442
+ const resolve = this._pendingDecision
443
+ this._pendingDecision = null
444
+ resolve(line.trim())
445
+ } else {
446
+ // Buffer lines that arrive before a decision is requested
447
+ this._stdinQueue.push(line.trim())
448
+ }
449
+ })
450
+ }
451
+ }
452
+
453
+ async sendHeartbeat(payload) {
454
+ const requestBody = {
455
+ status: payload.status,
456
+ runnerVersion: this.options.runnerVersion,
457
+ currentMode: payload.currentMode ?? null,
458
+ currentGameId: payload.currentGameId ?? null,
459
+ lastError: payload.lastError ?? null,
460
+ }
461
+
462
+ try {
463
+ await this.request('POST', '/api/agents/runtime/heartbeat', {
464
+ body: requestBody,
465
+ expectedStatuses: [200],
466
+ })
467
+ this.lastHeartbeatAt = Date.now()
468
+ } catch (error) {
469
+ this.warn(`heartbeat failed: ${truncate(error.message)}`)
470
+ }
471
+ }
472
+
473
+ async maybeHeartbeat(payload) {
474
+ if (Date.now() - this.lastHeartbeatAt < this.options.heartbeatSeconds * 1000) {
475
+ return
476
+ }
477
+ await this.sendHeartbeat(payload)
478
+ }
479
+
480
+ extractGameId(ensureData) {
481
+ const directGameId = ensureData?.gameId
482
+ if (typeof directGameId === 'string' && directGameId) return directGameId
483
+
484
+ const resultGameId = ensureData?.result?.gameId
485
+ if (typeof resultGameId === 'string' && resultGameId) return resultGameId
486
+
487
+ const queueGameId = ensureData?.queue?.gameId
488
+ if (typeof queueGameId === 'string' && queueGameId) return queueGameId
489
+
490
+ const ensuredGameId = ensureData?.result?.status === 'matched'
491
+ ? ensureData?.result?.gameId
492
+ : null
493
+ if (typeof ensuredGameId === 'string' && ensuredGameId) return ensuredGameId
494
+
495
+ return null
496
+ }
497
+
498
+ /**
499
+ * Determine if the current state is actionable.
500
+ * Uses availableActions from the API — no hardcoded phase lists.
501
+ */
502
+ isActionable(state) {
503
+ return Array.isArray(state.availableActions) && state.availableActions.length > 0
504
+ }
505
+
506
+ /**
507
+ * Get an action to submit, either from agent (stdin/stdout) or fallback (random valid).
508
+ */
509
+ async getAction(state) {
510
+ const { availableActions } = state
511
+ if (!Array.isArray(availableActions) || availableActions.length === 0) return null
512
+
513
+ if (this.options.agentMode) {
514
+ return this.requestAgentDecision(state)
515
+ }
516
+
517
+ // Fallback mode: pick a random valid action
518
+ return pickFallbackAction(availableActions)
519
+ }
520
+
521
+ /**
522
+ * Agent mode: emit decision_needed to stdout, read action from stdin.
523
+ */
524
+ async requestAgentDecision(state) {
525
+ const { availableActions, llmContext } = state
526
+
527
+ this._emitMessage({
528
+ type: 'decision_needed',
529
+ gameId: state.id,
530
+ mode: state.mode,
531
+ phase: state.phase,
532
+ round: state.round,
533
+ status: state.status,
534
+ stateVersion: state.stateVersion,
535
+ phaseRemainingMs: state.phaseRemainingMs,
536
+ availableActions,
537
+ llmContext,
538
+ // Include visible game state (everything except the fields we already extracted)
539
+ state: (() => {
540
+ const { availableActions: _a, llmContext: _l, ...rest } = state
541
+ return rest
542
+ })(),
543
+ })
544
+
545
+ // Wait for stdin response with timeout
546
+ const line = await this._readLineWithTimeout(this.options.decisionTimeoutMs)
547
+
548
+ if (line === null) {
549
+ if (this.stopRequested) return null // Don't submit actions during shutdown
550
+ this.warn('agent decision timeout, using fallback')
551
+ return pickFallbackAction(availableActions)
552
+ }
553
+
554
+ const parsed = parseAction(line, availableActions)
555
+ if (!parsed) {
556
+ this.warn(`agent response unparseable, using fallback: ${truncate(line, 80)}`)
557
+ return pickFallbackAction(availableActions)
558
+ }
559
+
560
+ return parsed
561
+ }
562
+
563
+ _readLineWithTimeout(timeoutMs) {
564
+ // Drain buffered lines first
565
+ if (this._stdinQueue && this._stdinQueue.length > 0) {
566
+ return Promise.resolve(this._stdinQueue.shift())
567
+ }
568
+ return new Promise((resolve) => {
569
+ const timer = setTimeout(() => {
570
+ this._pendingDecision = null
571
+ resolve(null)
572
+ }, timeoutMs)
573
+
574
+ this._pendingDecision = (line) => {
575
+ clearTimeout(timer)
576
+ resolve(line)
577
+ }
578
+ })
579
+ }
580
+
581
+ buildActionRequestId(gameId, phaseKey, action, actionIndex) {
582
+ const actionType = String(action?.action || 'unknown')
583
+ return `${gameId}:${this.agentId}:${phaseKey}:${actionIndex}:${actionType}`
584
+ }
585
+
586
+ async submitAction(gameId, action, { requestId, expectedStateVersion } = {}) {
587
+ const body = { action }
588
+ if (requestId) body.requestId = requestId
589
+ if (Number.isInteger(expectedStateVersion) && expectedStateVersion > 0) {
590
+ body.expectedStateVersion = expectedStateVersion
591
+ }
592
+
593
+ return this.request('POST', `/api/games/${gameId}/action`, {
594
+ body,
595
+ expectedStatuses: [200, 400, 401, 403, 409, 429],
596
+ })
597
+ }
598
+
599
+ async trySubmitAction(gameId, phaseKey, action, stateVersion) {
600
+ const requestId = this.buildActionRequestId(gameId, phaseKey, action, 0)
601
+ const response = await this.submitAction(gameId, action, {
602
+ requestId,
603
+ expectedStateVersion: stateVersion,
604
+ })
605
+
606
+ if (response.status === 200 && response.data?.ok === true) {
607
+ this.log(`action accepted phase=${phaseKey} action=${action.action}`)
608
+ if (this.options.agentMode) {
609
+ this._emitMessage({
610
+ type: 'action_submitted',
611
+ action,
612
+ result: 'accepted',
613
+ })
614
+ }
615
+ return { submitted: true, waitSeconds: 1, finished: false }
616
+ }
617
+
618
+ if (response.status === 429) {
619
+ const waitSeconds = toRetrySeconds({ body: response.data, headers: response.headers, fallback: 2 })
620
+ this.warn(`action rate limited, wait ${waitSeconds}s`)
621
+ return { submitted: false, waitSeconds, finished: false }
622
+ }
623
+
624
+ if (response.status === 409) {
625
+ // State version conflict — retry after short delay, don't abandon game
626
+ this.warn(`state version conflict game=${gameId}, will retry`)
627
+ return { submitted: false, waitSeconds: 1, finished: false }
628
+ }
629
+
630
+ if (response.status === 401 || response.status === 403) {
631
+ throw new Error(`action unauthorized/forbidden status=${response.status}`)
632
+ }
633
+
634
+ const code = String(response.data?.code || '')
635
+ const errorText = String(response.data?.error || 'unknown action error')
636
+
637
+ if (code === 'ACTION_ALREADY_SUBMITTED') {
638
+ this.log(`action already submitted phase=${phaseKey}`)
639
+ // Return submitted: false so phaseActionCount isn't incremented for duplicates
640
+ return { submitted: false, waitSeconds: 1, finished: false }
641
+ }
642
+
643
+ if (code === 'ACTION_NOT_IN_PHASE') {
644
+ const waitSeconds = toRetrySeconds({ body: response.data, headers: response.headers, fallback: 2 })
645
+ return { submitted: false, waitSeconds, finished: false }
646
+ }
647
+
648
+ if (code === 'STATE_VERSION_MISMATCH') {
649
+ const waitSeconds = toRetrySeconds({ body: response.data, headers: response.headers, fallback: 1 })
650
+ return { submitted: false, waitSeconds, finished: false }
651
+ }
652
+
653
+ if (code === 'PLAYER_NOT_ACTIVE' || code === 'PLAYER_INACTIVE') {
654
+ return { submitted: true, waitSeconds: 1, finished: false }
655
+ }
656
+
657
+ this.warn(`action rejected action=${action.action} code=${code || 'none'} error=${truncate(errorText)}`)
658
+ if (this.options.agentMode) {
659
+ this._emitMessage({
660
+ type: 'action_submitted',
661
+ action,
662
+ result: 'rejected',
663
+ code,
664
+ error: errorText,
665
+ })
666
+ }
667
+
668
+ const retryable = response.data?.retryable === true
669
+ if (retryable) {
670
+ const waitSeconds = toRetrySeconds({ body: response.data, headers: response.headers, fallback: 2 })
671
+ return { submitted: false, waitSeconds, finished: false }
672
+ }
673
+
674
+ return { submitted: false, waitSeconds: 1, finished: false }
675
+ }
676
+
677
+ async runGameLoop(gameId, hintedMode) {
678
+ this.log(`matched game=${gameId}`)
679
+ if (this.options.agentMode) {
680
+ this._emitMessage({ type: 'game_started', gameId, mode: hintedMode || null })
681
+ }
682
+
683
+ let completed = false
684
+ let mode = hintedMode || null
685
+ let since = null
686
+ let lastPhaseKey = ''
687
+ let phaseActionCount = 0
688
+ const MAX_FALLBACK_ACTIONS_PER_PHASE = 3
689
+ const MAX_GAME_DURATION_MS = 30 * 60 * 1000 // 30 minutes max per game
690
+ const gameStartedAt = Date.now()
691
+ let consecutiveFailures = 0
692
+
693
+ while (!this.stopRequested) {
694
+ if (Date.now() - gameStartedAt > MAX_GAME_DURATION_MS) {
695
+ this.warn(`game loop timeout (${MAX_GAME_DURATION_MS / 60000}min), abandoning game=${gameId}`)
696
+ break
697
+ }
698
+ try {
699
+ await this.maybeHeartbeat({
700
+ status: 'in_game',
701
+ currentMode: mode,
702
+ currentGameId: gameId,
703
+ lastError: null,
704
+ })
705
+
706
+ const statePath = this.options.agentMode
707
+ ? `/api/games/${gameId}/state/private?includeLlm=true`
708
+ : `/api/games/${gameId}/state/private`
709
+ const stateResponse = await this.request('GET', statePath, {
710
+ expectedStatuses: [200, 401, 403, 404],
711
+ })
712
+
713
+ if (stateResponse.status !== 200) {
714
+ this.warn(`state/private unavailable game=${gameId} status=${stateResponse.status}`)
715
+ break
716
+ }
717
+
718
+ const state = stateResponse.data
719
+ mode = String(state.mode || mode || '')
720
+ const status = String(state.status || '')
721
+ if (status !== 'playing') {
722
+ this.log(`game finished game=${gameId} status=${status} winner=${state.winner || 'n/a'}`)
723
+ if (this.options.agentMode) {
724
+ this._emitMessage({ type: 'game_finished', gameId, status, winner: state.winner || null })
725
+ }
726
+ completed = true
727
+ break
728
+ }
729
+
730
+ const eventsPath = since === null
731
+ ? `/api/games/${gameId}/events/private`
732
+ : `/api/games/${gameId}/events/private?since=${since}`
733
+ const eventsResponse = await this.request('GET', eventsPath, {
734
+ expectedStatuses: [200, 401, 403, 404],
735
+ })
736
+ if (eventsResponse.status === 200) {
737
+ const events = Array.isArray(eventsResponse.data?.events) ? eventsResponse.data.events : []
738
+ if (events.length > 0) {
739
+ const lastEvent = events[events.length - 1]
740
+ if (isObject(lastEvent) && Number.isFinite(Number(lastEvent.id))) {
741
+ since = Number(lastEvent.id)
742
+ }
743
+ }
744
+ }
745
+
746
+ const round = Number(state.round || 0)
747
+ const phase = String(state.phase || '')
748
+ const phaseKey = `${round}:${phase}`
749
+ if (phaseKey !== lastPhaseKey) {
750
+ lastPhaseKey = phaseKey
751
+ phaseActionCount = 0
752
+ this.log(`phase change game=${gameId} mode=${mode} phase=${phaseKey}`)
753
+ }
754
+
755
+ // Check if we can still act this phase
756
+ const actionable = this.isActionable(state)
757
+ const atPhaseLimit = !this.options.agentMode && phaseActionCount >= MAX_FALLBACK_ACTIONS_PER_PHASE
758
+ if (!actionable || atPhaseLimit) {
759
+ await sleep(this.options.pollSeconds * 1000)
760
+ continue
761
+ }
762
+
763
+ const action = await this.getAction(state)
764
+ if (!action) {
765
+ // No action available — treat as done for this phase
766
+ phaseActionCount = MAX_FALLBACK_ACTIONS_PER_PHASE
767
+ await sleep(this.options.pollSeconds * 1000)
768
+ continue
769
+ }
770
+
771
+ const stateVersion = toNumber(state.stateVersion, null)
772
+ const result = await this.trySubmitAction(gameId, phaseKey, action, stateVersion)
773
+ if (result.submitted) {
774
+ phaseActionCount += 1
775
+ // Small cooldown after successful action to avoid rapid-fire
776
+ if (result.waitSeconds < 2) result.waitSeconds = 2
777
+ }
778
+ if (result.finished) {
779
+ completed = true
780
+ break
781
+ }
782
+ await sleep(result.waitSeconds * 1000)
783
+
784
+ // Reset consecutive failures on successful iteration
785
+ consecutiveFailures = 0
786
+ } catch (err) {
787
+ // Rethrow programming errors immediately — don't retry bugs
788
+ if (err instanceof TypeError || err instanceof ReferenceError || err instanceof SyntaxError) {
789
+ throw err
790
+ }
791
+ consecutiveFailures += 1
792
+ const backoffMs = Math.min(2000 * Math.pow(2, consecutiveFailures - 1), 16000)
793
+ this.warn(`game loop error game=${gameId} attempt=${consecutiveFailures} err=${truncate(String(err?.message || err))} backoff=${backoffMs}ms`)
794
+ if (consecutiveFailures >= 3) {
795
+ this.warn(`too many consecutive failures, abandoning game=${gameId}`)
796
+ break
797
+ }
798
+ await sleep(backoffMs)
799
+ }
800
+ }
801
+
802
+ if (completed) {
803
+ this.finishedGames += 1
804
+ } else {
805
+ this.warn(`game loop ended before completion game=${gameId}`)
806
+ }
807
+ await this.sendHeartbeat({
808
+ status: 'queueing',
809
+ currentMode: mode,
810
+ currentGameId: null,
811
+ lastError: null,
812
+ })
813
+ }
814
+
815
+ async runQueueLoop() {
816
+ while (!this.stopRequested) {
817
+ if (this.options.maxGames > 0 && this.finishedGames >= this.options.maxGames) {
818
+ this.log(`max-games reached (${this.options.maxGames}), stopping`)
819
+ this.stopRequested = true
820
+ break
821
+ }
822
+
823
+ await this.maybeHeartbeat({
824
+ status: 'queueing',
825
+ currentMode: this.options.modes[0] || 'tribunal',
826
+ currentGameId: null,
827
+ lastError: null,
828
+ })
829
+
830
+ const ensureResponse = await this.request('POST', '/api/agents/runtime/queue/ensure', {
831
+ expectedStatuses: [200, 429, 503],
832
+ })
833
+ const ensureData = ensureResponse.data
834
+
835
+ if (ensureResponse.status === 429 || ensureResponse.status === 503) {
836
+ const waitSeconds = toRetrySeconds({ body: ensureData, headers: ensureResponse.headers, fallback: 3 })
837
+ this.warn(`queue ensure action=${ensureData?.action || 'retry'} wait ${waitSeconds}s`)
838
+ await sleep(waitSeconds * 1000)
839
+ continue
840
+ }
841
+
842
+ if (ensureData?.ensured === false && ensureData?.reason === 'paused') {
843
+ this.warn('preferences paused=true, waiting 10s')
844
+ await sleep(10_000)
845
+ continue
846
+ }
847
+
848
+ const gameId = this.extractGameId(ensureData)
849
+ if (gameId) {
850
+ const hintedMode = String(ensureData?.mode || ensureData?.result?.mode || '')
851
+ try {
852
+ await this.runGameLoop(gameId, hintedMode || null)
853
+ } catch (err) {
854
+ this.warn(`runGameLoop crashed game=${gameId} err=${truncate(String(err?.message || err))}`)
855
+ }
856
+ continue
857
+ }
858
+
859
+ const action = String(ensureData?.action || 'noop')
860
+ const waitSeconds = toNumber(ensureData?.nextPollSeconds, null) ?? this.options.pollSeconds
861
+ this.log(`queue ensure action=${action} nextPoll=${waitSeconds}s`)
862
+ await sleep(Math.max(1, waitSeconds) * 1000)
863
+ }
864
+ }
865
+
866
+ async start() {
867
+ await this.bootstrap()
868
+ try {
869
+ await this.runQueueLoop()
870
+ } finally {
871
+ if (this._rl) {
872
+ this._rl.close()
873
+ this._rl = null
874
+ }
875
+ }
876
+ }
877
+ }
878
+
879
+ function printJson(value) {
880
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
881
+ }
882
+
883
+ async function runLoginCommand(options, code) {
884
+ const baseUrl = options.baseUrl || DEFAULT_BASE_URL
885
+ let credentials = null
886
+
887
+ if (code) {
888
+ if (!options.name) {
889
+ throw new Error('Missing agent name. Use --name or ARENA_AGENT_NAME for login <code>')
890
+ }
891
+
892
+ const response = await requestArena({ ...options, apiKey: '', baseUrl }, 'POST', '/api/auth/verify', {
893
+ body: { code, name: options.name },
894
+ expectedStatuses: [201, 400, 409, 429],
895
+ })
896
+ if (response.status !== 201) {
897
+ const errorText = String(response.data?.error || `verify failed (${response.status})`)
898
+ const retrySeconds = response.status === 429
899
+ ? toRetrySeconds({ body: response.data, headers: response.headers, fallback: 10 })
900
+ : null
901
+ const retryHint = retrySeconds !== null ? ` retryAfter=${retrySeconds}s` : ''
902
+ throw new Error(`login failed: ${truncate(errorText, 200)}${retryHint}`)
903
+ }
904
+
905
+ credentials = {
906
+ agentId: response.data?.agentId ?? null,
907
+ name: response.data?.name ?? options.name,
908
+ apiKey: response.data?.apiKey ?? '',
909
+ baseUrl,
910
+ source: 'verify',
911
+ updatedAt: new Date().toISOString(),
912
+ }
913
+ } else if (options.apiKey) {
914
+ const meResponse = await requestArena({ ...options, baseUrl }, 'GET', '/api/agents/me', {
915
+ expectedStatuses: [200, 401, 403],
916
+ })
917
+ if (meResponse.status !== 200) {
918
+ throw new Error(`login failed: provided API key unauthorized (status=${meResponse.status})`)
919
+ }
920
+ credentials = {
921
+ agentId: meResponse.data?.agentId ?? null,
922
+ name: meResponse.data?.name ?? options.name ?? null,
923
+ apiKey: options.apiKey,
924
+ baseUrl,
925
+ source: 'api_key',
926
+ updatedAt: new Date().toISOString(),
927
+ }
928
+ } else {
929
+ throw new Error('Missing verification code or --api-key. Usage: login <code> --name <agent-name> OR login --api-key <key>')
930
+ }
931
+
932
+ if (!credentials.apiKey) {
933
+ throw new Error('login failed: apiKey missing')
934
+ }
935
+
936
+ const resolvedPath = await saveCredentials(options.credentialsPath, credentials)
937
+ printJson({
938
+ ok: true,
939
+ command: 'login',
940
+ source: credentials.source,
941
+ agentId: credentials.agentId,
942
+ name: credentials.name,
943
+ baseUrl: credentials.baseUrl,
944
+ credentialsPath: resolvedPath,
945
+ apiKey: credentials.apiKey,
946
+ })
947
+ }
948
+
949
+ async function runStatusCommand(options) {
950
+ const [runtimeResponse, queueStatusResponse] = await Promise.all([
951
+ requestArena(options, 'GET', '/api/agents/runtime', { expectedStatuses: [200] }),
952
+ requestArena(options, 'GET', '/api/queue/status', { expectedStatuses: [200] }),
953
+ ])
954
+
955
+ printJson({
956
+ runtime: runtimeResponse.data.runtime ?? null,
957
+ preferences: runtimeResponse.data.preferences ?? null,
958
+ queueStrategy: runtimeResponse.data.queueStrategy ?? null,
959
+ queue: queueStatusResponse.data ?? null,
960
+ })
961
+ }
962
+
963
+ async function runPauseCommand(options) {
964
+ const preferencesResponse = await requestArena(options, 'POST', '/api/agents/preferences', {
965
+ body: { paused: true },
966
+ expectedStatuses: [200],
967
+ })
968
+ const leaveQueueResponse = await requestArena(options, 'POST', '/api/queue/leave', { expectedStatuses: [200] })
969
+
970
+ printJson({
971
+ ok: true,
972
+ command: 'pause',
973
+ preferences: preferencesResponse.data,
974
+ queue: leaveQueueResponse.data,
975
+ })
976
+ }
977
+
978
+ async function runResumeCommand(options) {
979
+ const preferencesResponse = await requestArena(options, 'POST', '/api/agents/preferences', {
980
+ body: { paused: false },
981
+ expectedStatuses: [200],
982
+ })
983
+ const ensureResponse = await requestArena(options, 'POST', '/api/agents/runtime/queue/ensure', {
984
+ expectedStatuses: [200, 429, 503],
985
+ })
986
+
987
+ printJson({
988
+ ok: true,
989
+ command: 'resume',
990
+ preferences: preferencesResponse.data,
991
+ ensure: {
992
+ httpStatus: ensureResponse.status,
993
+ ...ensureResponse.data,
994
+ },
995
+ })
996
+ }
997
+
998
+ async function main() {
999
+ const { command, code, options } = parseCli(process.argv.slice(2))
1000
+
1001
+ if (command === 'login') {
1002
+ await runLoginCommand(options, code)
1003
+ return
1004
+ }
1005
+
1006
+ const authenticatedOptions = await resolveAuthenticatedOptions(options)
1007
+
1008
+ if (command === 'status') {
1009
+ await runStatusCommand(authenticatedOptions)
1010
+ return
1011
+ }
1012
+ if (command === 'pause') {
1013
+ await runPauseCommand(authenticatedOptions)
1014
+ return
1015
+ }
1016
+ if (command === 'resume') {
1017
+ await runResumeCommand(authenticatedOptions)
1018
+ return
1019
+ }
1020
+
1021
+ const runner = new ArenaRunner(authenticatedOptions)
1022
+
1023
+ process.on('SIGINT', () => {
1024
+ runner.log('received SIGINT, shutting down')
1025
+ runner.stopRequested = true
1026
+ })
1027
+ process.on('SIGTERM', () => {
1028
+ runner.log('received SIGTERM, shutting down')
1029
+ runner.stopRequested = true
1030
+ })
1031
+
1032
+ await runner.start()
1033
+ }
1034
+
1035
+ main().catch((error) => {
1036
+ process.stderr.write(`[runner][fatal] ${error?.message || String(error)}\n`)
1037
+ process.exit(1)
1038
+ })