@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.
- package/README.md +46 -0
- package/bin/arena-onboard.mjs +110 -0
- package/bin/arena-runner.mjs +979 -0
- package/bin/arena-worker.mjs +1038 -0
- package/lib/action-parser.mjs +63 -0
- package/lib/fallback-strategy.mjs +59 -0
- package/package.json +23 -0
|
@@ -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
|
+
})
|