@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,979 @@
|
|
|
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 { mkdir, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { closeSync, openSync } from 'node:fs'
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://arena.clawlabz.xyz'
|
|
13
|
+
const DEFAULT_MODES = ['tribunal', 'texas_holdem']
|
|
14
|
+
const RUNNERS_DIR = process.env.ARENA_RUNNERS_DIR || '~/.openclaw/workspace/arena-runners'
|
|
15
|
+
const LEGACY_CREDENTIALS_PATH = process.env.ARENA_LEGACY_CREDENTIALS_PATH || '~/.openclaw/workspace/arena-credentials.json'
|
|
16
|
+
const RUNNER_SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
function usage() {
|
|
19
|
+
process.stdout.write(`Usage: npx @clawlabz/clawarena-cli <command> [options]
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
connect --name <name> Register a new agent and start playing
|
|
23
|
+
connect --api-key <key> Connect with existing API key
|
|
24
|
+
stop Stop the runner
|
|
25
|
+
pause Pause matchmaking and leave queue
|
|
26
|
+
resume Resume matchmaking
|
|
27
|
+
modes <a,b> Set preferred game modes
|
|
28
|
+
games List all available game modes
|
|
29
|
+
status Show instance status
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--base-url <url> API base URL (default: ${DEFAULT_BASE_URL})
|
|
33
|
+
--modes <a,b> Game modes for connect (default: ${DEFAULT_MODES.join(',')})
|
|
34
|
+
--no-agent-mode Disable LLM agent mode (use random fallback)
|
|
35
|
+
-h, --help Show this help
|
|
36
|
+
|
|
37
|
+
Advanced:
|
|
38
|
+
ls List all local instances
|
|
39
|
+
start <id|all> Restart a stopped instance
|
|
40
|
+
delete <id|all> [--yes] Delete instance(s)
|
|
41
|
+
purge --yes Remove all local data
|
|
42
|
+
`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitModes(value) {
|
|
46
|
+
if (!value) return []
|
|
47
|
+
return String(value)
|
|
48
|
+
.split(',')
|
|
49
|
+
.map(mode => mode.trim())
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveHomePath(inputPath) {
|
|
54
|
+
if (!inputPath) return inputPath
|
|
55
|
+
if (inputPath === '~') return os.homedir()
|
|
56
|
+
if (inputPath.startsWith('~/')) return path.join(os.homedir(), inputPath.slice(2))
|
|
57
|
+
return inputPath
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runnersDirPath() {
|
|
61
|
+
return resolveHomePath(RUNNERS_DIR)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function legacyCredentialsPath() {
|
|
65
|
+
return resolveHomePath(LEGACY_CREDENTIALS_PATH)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runnerFilePath(id) {
|
|
69
|
+
return path.join(runnersDirPath(), `${id}.json`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function runnerLogPath(id) {
|
|
73
|
+
return path.join(runnersDirPath(), `${id}.log`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nowIso() {
|
|
77
|
+
return new Date().toISOString()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createRunnerId() {
|
|
81
|
+
const partA = Date.now().toString(36)
|
|
82
|
+
const partB = Math.random().toString(36).slice(2, 8)
|
|
83
|
+
return `ca_${partA}${partB}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function maskApiKey(value) {
|
|
87
|
+
const text = String(value || '')
|
|
88
|
+
if (text.length <= 10) return text
|
|
89
|
+
return `${text.slice(0, 6)}...${text.slice(-4)}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isObject(value) {
|
|
93
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseNumber(value) {
|
|
97
|
+
const num = Number(value)
|
|
98
|
+
return Number.isFinite(num) ? num : null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function printJson(value) {
|
|
102
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function ensureRunnersDir() {
|
|
106
|
+
await mkdir(runnersDirPath(), { recursive: true, mode: 0o700 })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeRunnerRecord(raw) {
|
|
110
|
+
if (!isObject(raw)) return null
|
|
111
|
+
if (typeof raw.id !== 'string' || !raw.id) return null
|
|
112
|
+
if (typeof raw.apiKey !== 'string' || !raw.apiKey) return null
|
|
113
|
+
|
|
114
|
+
const status = raw.status === 'running' ? 'running' : 'stopped'
|
|
115
|
+
const baseUrl = typeof raw.baseUrl === 'string' && raw.baseUrl ? raw.baseUrl : DEFAULT_BASE_URL
|
|
116
|
+
const modes = Array.isArray(raw.modes)
|
|
117
|
+
? raw.modes.map(mode => String(mode || '').trim()).filter(Boolean)
|
|
118
|
+
: DEFAULT_MODES
|
|
119
|
+
const pid = parseNumber(raw.pid)
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
schemaVersion: RUNNER_SCHEMA_VERSION,
|
|
123
|
+
id: raw.id,
|
|
124
|
+
localName: typeof raw.localName === 'string' && raw.localName ? raw.localName : raw.id,
|
|
125
|
+
agentId: typeof raw.agentId === 'string' ? raw.agentId : '',
|
|
126
|
+
agentName: typeof raw.agentName === 'string' ? raw.agentName : '',
|
|
127
|
+
apiKey: raw.apiKey,
|
|
128
|
+
apiKeyPreview: maskApiKey(raw.apiKey),
|
|
129
|
+
baseUrl,
|
|
130
|
+
modes,
|
|
131
|
+
status,
|
|
132
|
+
pid: pid !== null && pid > 0 ? Math.floor(pid) : null,
|
|
133
|
+
createdAt: typeof raw.createdAt === 'string' && raw.createdAt ? raw.createdAt : nowIso(),
|
|
134
|
+
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : nowIso(),
|
|
135
|
+
lastStartAt: typeof raw.lastStartAt === 'string' ? raw.lastStartAt : null,
|
|
136
|
+
lastStopAt: typeof raw.lastStopAt === 'string' ? raw.lastStopAt : null,
|
|
137
|
+
source: raw.source === 'register' ? 'register' : 'api_key',
|
|
138
|
+
logPath: typeof raw.logPath === 'string' && raw.logPath ? raw.logPath : runnerLogPath(raw.id),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readRunnerRecord(id) {
|
|
143
|
+
const file = runnerFilePath(id)
|
|
144
|
+
const text = await readFile(file, 'utf8')
|
|
145
|
+
const parsed = JSON.parse(text)
|
|
146
|
+
const normalized = normalizeRunnerRecord(parsed)
|
|
147
|
+
if (!normalized) {
|
|
148
|
+
throw new Error(`Invalid runner record: ${file}`)
|
|
149
|
+
}
|
|
150
|
+
return normalized
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function writeRunnerRecord(record) {
|
|
154
|
+
const normalized = normalizeRunnerRecord(record)
|
|
155
|
+
if (!normalized) {
|
|
156
|
+
throw new Error('Invalid runner record payload')
|
|
157
|
+
}
|
|
158
|
+
normalized.updatedAt = nowIso()
|
|
159
|
+
await ensureRunnersDir()
|
|
160
|
+
await writeFile(runnerFilePath(normalized.id), `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
|
|
161
|
+
return normalized
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function listRunnerRecords() {
|
|
165
|
+
await ensureRunnersDir()
|
|
166
|
+
const files = await readdir(runnersDirPath())
|
|
167
|
+
const records = []
|
|
168
|
+
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
if (!file.endsWith('.json')) continue
|
|
171
|
+
const id = file.slice(0, -5)
|
|
172
|
+
try {
|
|
173
|
+
const record = await readRunnerRecord(id)
|
|
174
|
+
records.push(record)
|
|
175
|
+
} catch {
|
|
176
|
+
// Ignore broken runner files to keep CLI usable.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
records.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
181
|
+
return records
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isPidAlive(pid) {
|
|
185
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0)
|
|
188
|
+
return true
|
|
189
|
+
} catch {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function refreshRunnerState(record) {
|
|
195
|
+
if (record.status !== 'running') return record
|
|
196
|
+
if (record.pid && isPidAlive(record.pid)) return record
|
|
197
|
+
|
|
198
|
+
const next = {
|
|
199
|
+
...record,
|
|
200
|
+
status: 'stopped',
|
|
201
|
+
pid: null,
|
|
202
|
+
lastStopAt: nowIso(),
|
|
203
|
+
}
|
|
204
|
+
return writeRunnerRecord(next)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveRunnerTarget(records, token) {
|
|
208
|
+
if (!token || token === 'all') return records
|
|
209
|
+
|
|
210
|
+
const exact = records.find(record => record.id === token)
|
|
211
|
+
if (exact) return [exact]
|
|
212
|
+
|
|
213
|
+
const prefixed = records.filter(record => record.id.startsWith(token))
|
|
214
|
+
if (prefixed.length === 1) return prefixed
|
|
215
|
+
if (prefixed.length > 1) {
|
|
216
|
+
throw new Error(`Ambiguous id prefix '${token}', matches: ${prefixed.map(item => item.id).join(', ')}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
throw new Error(`Runner not found: ${token}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function requestArena({ baseUrl, apiKey }, method, reqPath, { body, expectedStatuses = [200] } = {}) {
|
|
223
|
+
const url = new URL(reqPath, baseUrl).toString()
|
|
224
|
+
const response = await fetch(url, {
|
|
225
|
+
method,
|
|
226
|
+
headers: {
|
|
227
|
+
authorization: `Bearer ${apiKey}`,
|
|
228
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
229
|
+
},
|
|
230
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const text = await response.text()
|
|
234
|
+
let data = {}
|
|
235
|
+
if (text.trim()) {
|
|
236
|
+
try {
|
|
237
|
+
data = JSON.parse(text)
|
|
238
|
+
} catch {
|
|
239
|
+
data = { raw: text }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!expectedStatuses.includes(response.status)) {
|
|
244
|
+
const message = typeof data?.error === 'string' ? data.error : `HTTP ${response.status} ${method} ${reqPath}`
|
|
245
|
+
const error = new Error(message)
|
|
246
|
+
error.status = response.status
|
|
247
|
+
error.data = data
|
|
248
|
+
throw error
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { status: response.status, data, headers: response.headers }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function registerAgent({ baseUrl, name }) {
|
|
255
|
+
const url = new URL('/api/agents/register', baseUrl).toString()
|
|
256
|
+
const response = await fetch(url, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { 'content-type': 'application/json' },
|
|
259
|
+
body: JSON.stringify({ name }),
|
|
260
|
+
})
|
|
261
|
+
const data = await response.json().catch(() => ({}))
|
|
262
|
+
|
|
263
|
+
if (response.status !== 201) {
|
|
264
|
+
const message = typeof data?.error === 'string' ? data.error : `registration failed (${response.status})`
|
|
265
|
+
throw new Error(message)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (typeof data?.apiKey !== 'string' || !data.apiKey) {
|
|
269
|
+
throw new Error('registration failed: apiKey missing in response')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
apiKey: data.apiKey,
|
|
274
|
+
agentId: typeof data?.agentId === 'string' ? data.agentId : '',
|
|
275
|
+
name: typeof data?.name === 'string' && data.name ? data.name : name,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function loadAgentIdentity({ baseUrl, apiKey }) {
|
|
280
|
+
const me = await requestArena({ baseUrl, apiKey }, 'GET', '/api/agents/me', { expectedStatuses: [200] })
|
|
281
|
+
return {
|
|
282
|
+
agentId: typeof me.data?.agentId === 'string' ? me.data.agentId : '',
|
|
283
|
+
name: typeof me.data?.name === 'string' ? me.data.name : '',
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getWorkerScriptPath() {
|
|
288
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
289
|
+
const __dirname = path.dirname(__filename)
|
|
290
|
+
return path.join(__dirname, 'arena-worker.mjs')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function launchWorker(record, { agentMode = true } = {}) {
|
|
294
|
+
const args = [
|
|
295
|
+
getWorkerScriptPath(),
|
|
296
|
+
'start',
|
|
297
|
+
'--api-key',
|
|
298
|
+
record.apiKey,
|
|
299
|
+
'--base-url',
|
|
300
|
+
record.baseUrl,
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
if (Array.isArray(record.modes) && record.modes.length > 0) {
|
|
304
|
+
args.push('--modes', record.modes.join(','))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!agentMode) {
|
|
308
|
+
args.push('--no-agent-mode')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Agent mode (default): foreground with stdio piped through
|
|
312
|
+
if (agentMode) {
|
|
313
|
+
const child = spawn(process.execPath, args, {
|
|
314
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
315
|
+
env: process.env,
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Pipe stdin/stdout through to parent process
|
|
319
|
+
process.stdin.pipe(child.stdin)
|
|
320
|
+
child.stdout.pipe(process.stdout)
|
|
321
|
+
|
|
322
|
+
return { pid: child.pid || null, child }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Normal mode: detached background process
|
|
326
|
+
const fd = openSync(record.logPath, 'a')
|
|
327
|
+
try {
|
|
328
|
+
const child = spawn(process.execPath, args, {
|
|
329
|
+
detached: true,
|
|
330
|
+
stdio: ['ignore', fd, fd],
|
|
331
|
+
env: process.env,
|
|
332
|
+
})
|
|
333
|
+
child.unref()
|
|
334
|
+
return { pid: child.pid || null, child: null }
|
|
335
|
+
} finally {
|
|
336
|
+
closeSync(fd)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function startRunner(record, { agentMode = true } = {}) {
|
|
341
|
+
const current = await refreshRunnerState(record)
|
|
342
|
+
if (current.status === 'running' && current.pid && isPidAlive(current.pid)) {
|
|
343
|
+
return {
|
|
344
|
+
...current,
|
|
345
|
+
alreadyRunning: true,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const { pid, child } = launchWorker(current, { agentMode })
|
|
350
|
+
if (!pid) {
|
|
351
|
+
throw new Error(`failed to start runner ${current.id}`)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Agent mode: run foreground, wait for child to exit
|
|
355
|
+
if (agentMode && child) {
|
|
356
|
+
await writeRunnerRecord({
|
|
357
|
+
...current,
|
|
358
|
+
status: 'running',
|
|
359
|
+
pid,
|
|
360
|
+
lastStartAt: nowIso(),
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
await new Promise((resolve) => {
|
|
364
|
+
child.on('exit', resolve)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const stopped = await writeRunnerRecord({
|
|
368
|
+
...current,
|
|
369
|
+
status: 'stopped',
|
|
370
|
+
pid: null,
|
|
371
|
+
lastStopAt: nowIso(),
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
...stopped,
|
|
376
|
+
alreadyRunning: false,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await sleep(120)
|
|
381
|
+
|
|
382
|
+
const next = await writeRunnerRecord({
|
|
383
|
+
...current,
|
|
384
|
+
status: 'running',
|
|
385
|
+
pid,
|
|
386
|
+
lastStartAt: nowIso(),
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
...next,
|
|
391
|
+
alreadyRunning: false,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function stopRunner(record, { forceKill = false } = {}) {
|
|
396
|
+
const current = await refreshRunnerState(record)
|
|
397
|
+
if (current.status !== 'running' || !current.pid) {
|
|
398
|
+
return {
|
|
399
|
+
...current,
|
|
400
|
+
stopped: false,
|
|
401
|
+
reason: 'already_stopped',
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const pid = current.pid
|
|
406
|
+
try {
|
|
407
|
+
process.kill(pid, 'SIGTERM')
|
|
408
|
+
} catch {
|
|
409
|
+
const next = await writeRunnerRecord({
|
|
410
|
+
...current,
|
|
411
|
+
status: 'stopped',
|
|
412
|
+
pid: null,
|
|
413
|
+
lastStopAt: nowIso(),
|
|
414
|
+
})
|
|
415
|
+
return {
|
|
416
|
+
...next,
|
|
417
|
+
stopped: false,
|
|
418
|
+
reason: 'not_running',
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const deadline = Date.now() + 4000
|
|
423
|
+
while (Date.now() < deadline) {
|
|
424
|
+
if (!isPidAlive(pid)) break
|
|
425
|
+
await sleep(180)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let signal = 'SIGTERM'
|
|
429
|
+
if (isPidAlive(pid) && forceKill) {
|
|
430
|
+
try {
|
|
431
|
+
process.kill(pid, 'SIGKILL')
|
|
432
|
+
signal = 'SIGKILL'
|
|
433
|
+
} catch {
|
|
434
|
+
// ignore
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const next = await writeRunnerRecord({
|
|
439
|
+
...current,
|
|
440
|
+
status: 'stopped',
|
|
441
|
+
pid: null,
|
|
442
|
+
lastStopAt: nowIso(),
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
...next,
|
|
447
|
+
stopped: true,
|
|
448
|
+
signal,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function deleteRunner(record, { yes = false } = {}) {
|
|
453
|
+
const current = await refreshRunnerState(record)
|
|
454
|
+
if (current.status === 'running' && current.pid && !yes) {
|
|
455
|
+
throw new Error(`runner ${current.id} is running, use --yes to stop and delete`)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (current.status === 'running' && current.pid && yes) {
|
|
459
|
+
await stopRunner(current, { forceKill: true })
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
await unlink(runnerFilePath(current.id)).catch(() => {})
|
|
463
|
+
await unlink(current.logPath).catch(() => {})
|
|
464
|
+
|
|
465
|
+
return { id: current.id, deleted: true }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function parseConnectArgs(args) {
|
|
469
|
+
const options = {
|
|
470
|
+
apiKey: process.env.ARENA_API_KEY || '',
|
|
471
|
+
name: process.env.ARENA_AGENT_NAME || '',
|
|
472
|
+
baseUrl: process.env.ARENA_BASE_URL || DEFAULT_BASE_URL,
|
|
473
|
+
modes: splitModes(process.env.ARENA_MODES || DEFAULT_MODES.join(',')),
|
|
474
|
+
label: '',
|
|
475
|
+
agentMode: process.env.ARENA_AGENT_MODE !== 'false',
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
479
|
+
const arg = args[i]
|
|
480
|
+
const next = args[i + 1]
|
|
481
|
+
|
|
482
|
+
if (arg === '--no-agent-mode') {
|
|
483
|
+
options.agentMode = false
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
if (arg === '--api-key') {
|
|
487
|
+
if (!next) throw new Error('Missing value for --api-key')
|
|
488
|
+
options.apiKey = next
|
|
489
|
+
i += 1
|
|
490
|
+
continue
|
|
491
|
+
}
|
|
492
|
+
if (arg === '--name') {
|
|
493
|
+
if (!next) throw new Error('Missing value for --name')
|
|
494
|
+
options.name = next
|
|
495
|
+
i += 1
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
if (arg === '--base-url') {
|
|
499
|
+
if (!next) throw new Error('Missing value for --base-url')
|
|
500
|
+
options.baseUrl = next
|
|
501
|
+
i += 1
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
if (arg === '--modes') {
|
|
505
|
+
if (!next) throw new Error('Missing value for --modes')
|
|
506
|
+
options.modes = splitModes(next)
|
|
507
|
+
i += 1
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
if (arg === '--label') {
|
|
511
|
+
if (!next) throw new Error('Missing value for --label')
|
|
512
|
+
options.label = next
|
|
513
|
+
i += 1
|
|
514
|
+
continue
|
|
515
|
+
}
|
|
516
|
+
throw new Error(`Unknown argument: ${arg}`)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (options.modes.length === 0) {
|
|
520
|
+
options.modes = [...DEFAULT_MODES]
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (!options.apiKey && !options.name) {
|
|
524
|
+
throw new Error('connect requires --api-key or --name (to auto-register)')
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return options
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function commandConnect(args) {
|
|
531
|
+
const options = parseConnectArgs(args)
|
|
532
|
+
await ensureRunnersDir()
|
|
533
|
+
|
|
534
|
+
let apiKey = options.apiKey
|
|
535
|
+
let agentId = ''
|
|
536
|
+
let agentName = options.name
|
|
537
|
+
let source = 'api_key'
|
|
538
|
+
let createdApiKey = null
|
|
539
|
+
|
|
540
|
+
if (!apiKey) {
|
|
541
|
+
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(options.name)) {
|
|
542
|
+
throw new Error('invalid --name, use 3-32 chars, letters/numbers/_/- only')
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const registered = await registerAgent({
|
|
546
|
+
baseUrl: options.baseUrl,
|
|
547
|
+
name: options.name,
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
apiKey = registered.apiKey
|
|
551
|
+
agentId = registered.agentId
|
|
552
|
+
agentName = registered.name
|
|
553
|
+
source = 'register'
|
|
554
|
+
createdApiKey = registered.apiKey
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const identity = await loadAgentIdentity({ baseUrl: options.baseUrl, apiKey })
|
|
558
|
+
if (!agentId) agentId = identity.agentId
|
|
559
|
+
if (!agentName) agentName = identity.name
|
|
560
|
+
|
|
561
|
+
const id = createRunnerId()
|
|
562
|
+
const logPath = runnerLogPath(id)
|
|
563
|
+
const record = await writeRunnerRecord({
|
|
564
|
+
schemaVersion: RUNNER_SCHEMA_VERSION,
|
|
565
|
+
id,
|
|
566
|
+
localName: options.label || agentName || id,
|
|
567
|
+
agentId,
|
|
568
|
+
agentName,
|
|
569
|
+
apiKey,
|
|
570
|
+
apiKeyPreview: maskApiKey(apiKey),
|
|
571
|
+
baseUrl: options.baseUrl,
|
|
572
|
+
modes: options.modes,
|
|
573
|
+
status: 'stopped',
|
|
574
|
+
pid: null,
|
|
575
|
+
createdAt: nowIso(),
|
|
576
|
+
updatedAt: nowIso(),
|
|
577
|
+
lastStartAt: null,
|
|
578
|
+
lastStopAt: null,
|
|
579
|
+
source,
|
|
580
|
+
logPath,
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
const started = await startRunner(record, { agentMode: options.agentMode })
|
|
584
|
+
|
|
585
|
+
printJson({
|
|
586
|
+
ok: true,
|
|
587
|
+
command: 'connect',
|
|
588
|
+
instance: {
|
|
589
|
+
id: started.id,
|
|
590
|
+
localName: started.localName,
|
|
591
|
+
agentId: started.agentId,
|
|
592
|
+
agentName: started.agentName,
|
|
593
|
+
source: started.source,
|
|
594
|
+
baseUrl: started.baseUrl,
|
|
595
|
+
modes: started.modes,
|
|
596
|
+
pid: started.pid,
|
|
597
|
+
status: started.status,
|
|
598
|
+
logPath: started.logPath,
|
|
599
|
+
apiKey: started.apiKey,
|
|
600
|
+
},
|
|
601
|
+
note: createdApiKey
|
|
602
|
+
? 'new API key created — save it for web login'
|
|
603
|
+
: 'used provided API key',
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function commandList() {
|
|
608
|
+
const records = await listRunnerRecords()
|
|
609
|
+
const result = []
|
|
610
|
+
|
|
611
|
+
for (const record of records) {
|
|
612
|
+
const refreshed = await refreshRunnerState(record)
|
|
613
|
+
result.push({
|
|
614
|
+
id: refreshed.id,
|
|
615
|
+
localName: refreshed.localName,
|
|
616
|
+
agentName: refreshed.agentName,
|
|
617
|
+
agentId: refreshed.agentId,
|
|
618
|
+
modes: refreshed.modes,
|
|
619
|
+
status: refreshed.status,
|
|
620
|
+
pid: refreshed.pid,
|
|
621
|
+
apiKey: refreshed.apiKey,
|
|
622
|
+
createdAt: refreshed.createdAt,
|
|
623
|
+
updatedAt: refreshed.updatedAt,
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
printJson({
|
|
628
|
+
ok: true,
|
|
629
|
+
command: 'ls',
|
|
630
|
+
total: result.length,
|
|
631
|
+
instances: result,
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function loadTargets(targetToken) {
|
|
636
|
+
const records = await listRunnerRecords()
|
|
637
|
+
if (records.length === 0) {
|
|
638
|
+
throw new Error('no local ClawArena instances found')
|
|
639
|
+
}
|
|
640
|
+
return resolveRunnerTarget(records, targetToken)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function commandStatus(targetToken) {
|
|
644
|
+
const records = await listRunnerRecords()
|
|
645
|
+
if (records.length === 0) {
|
|
646
|
+
printJson({
|
|
647
|
+
ok: true,
|
|
648
|
+
command: 'status',
|
|
649
|
+
count: 0,
|
|
650
|
+
instances: [],
|
|
651
|
+
})
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
const targets = resolveRunnerTarget(records, targetToken)
|
|
655
|
+
const results = []
|
|
656
|
+
|
|
657
|
+
for (const target of targets) {
|
|
658
|
+
const refreshed = await refreshRunnerState(target)
|
|
659
|
+
try {
|
|
660
|
+
const [runtime, queue] = await Promise.all([
|
|
661
|
+
requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'GET', '/api/agents/runtime', { expectedStatuses: [200] }),
|
|
662
|
+
requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'GET', '/api/queue/status', { expectedStatuses: [200] }),
|
|
663
|
+
])
|
|
664
|
+
|
|
665
|
+
results.push({
|
|
666
|
+
id: refreshed.id,
|
|
667
|
+
localName: refreshed.localName,
|
|
668
|
+
local: {
|
|
669
|
+
status: refreshed.status,
|
|
670
|
+
pid: refreshed.pid,
|
|
671
|
+
modes: refreshed.modes,
|
|
672
|
+
updatedAt: refreshed.updatedAt,
|
|
673
|
+
},
|
|
674
|
+
remote: {
|
|
675
|
+
runtime: runtime.data?.runtime ?? null,
|
|
676
|
+
preferences: runtime.data?.preferences ?? null,
|
|
677
|
+
queue: queue.data ?? null,
|
|
678
|
+
},
|
|
679
|
+
})
|
|
680
|
+
} catch (error) {
|
|
681
|
+
results.push({
|
|
682
|
+
id: refreshed.id,
|
|
683
|
+
localName: refreshed.localName,
|
|
684
|
+
local: {
|
|
685
|
+
status: refreshed.status,
|
|
686
|
+
pid: refreshed.pid,
|
|
687
|
+
modes: refreshed.modes,
|
|
688
|
+
updatedAt: refreshed.updatedAt,
|
|
689
|
+
},
|
|
690
|
+
error: error instanceof Error ? error.message : String(error),
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
printJson({
|
|
696
|
+
ok: true,
|
|
697
|
+
command: 'status',
|
|
698
|
+
count: results.length,
|
|
699
|
+
instances: results,
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function commandSetModes(modesValue, targetToken) {
|
|
704
|
+
const modes = splitModes(modesValue)
|
|
705
|
+
if (modes.length === 0) {
|
|
706
|
+
throw new Error('set modes requires a non-empty mode list')
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const targets = await loadTargets(targetToken)
|
|
710
|
+
const results = []
|
|
711
|
+
|
|
712
|
+
for (const target of targets) {
|
|
713
|
+
const refreshed = await refreshRunnerState(target)
|
|
714
|
+
try {
|
|
715
|
+
await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/preferences', {
|
|
716
|
+
body: { enabledModes: modes },
|
|
717
|
+
expectedStatuses: [200],
|
|
718
|
+
})
|
|
719
|
+
await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/queue/leave', {
|
|
720
|
+
body: {},
|
|
721
|
+
expectedStatuses: [200],
|
|
722
|
+
})
|
|
723
|
+
await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/runtime/queue/ensure', {
|
|
724
|
+
body: {},
|
|
725
|
+
expectedStatuses: [200, 429, 503],
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
const saved = await writeRunnerRecord({
|
|
729
|
+
...refreshed,
|
|
730
|
+
modes,
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
results.push({ id: saved.id, ok: true, modes: saved.modes })
|
|
734
|
+
} catch (error) {
|
|
735
|
+
results.push({ id: refreshed.id, ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
printJson({ ok: true, command: 'set modes', modes, results })
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function commandPause(targetToken) {
|
|
743
|
+
const targets = await loadTargets(targetToken)
|
|
744
|
+
const results = []
|
|
745
|
+
|
|
746
|
+
for (const target of targets) {
|
|
747
|
+
const refreshed = await refreshRunnerState(target)
|
|
748
|
+
try {
|
|
749
|
+
await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/preferences', {
|
|
750
|
+
body: { paused: true },
|
|
751
|
+
expectedStatuses: [200],
|
|
752
|
+
})
|
|
753
|
+
await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/queue/leave', {
|
|
754
|
+
body: {},
|
|
755
|
+
expectedStatuses: [200],
|
|
756
|
+
})
|
|
757
|
+
results.push({ id: refreshed.id, ok: true })
|
|
758
|
+
} catch (error) {
|
|
759
|
+
results.push({ id: refreshed.id, ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
printJson({ ok: true, command: 'pause', results })
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function commandResume(targetToken) {
|
|
767
|
+
const targets = await loadTargets(targetToken)
|
|
768
|
+
const results = []
|
|
769
|
+
|
|
770
|
+
for (const target of targets) {
|
|
771
|
+
const refreshed = await refreshRunnerState(target)
|
|
772
|
+
try {
|
|
773
|
+
await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/preferences', {
|
|
774
|
+
body: { paused: false, autoQueue: true },
|
|
775
|
+
expectedStatuses: [200],
|
|
776
|
+
})
|
|
777
|
+
const ensure = await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/runtime/queue/ensure', {
|
|
778
|
+
body: {},
|
|
779
|
+
expectedStatuses: [200, 429, 503],
|
|
780
|
+
})
|
|
781
|
+
results.push({ id: refreshed.id, ok: true, ensureStatus: ensure.status, ensure: ensure.data })
|
|
782
|
+
} catch (error) {
|
|
783
|
+
results.push({ id: refreshed.id, ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
printJson({ ok: true, command: 'resume', results })
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function commandStop(targetToken) {
|
|
791
|
+
const targets = await loadTargets(targetToken)
|
|
792
|
+
const results = []
|
|
793
|
+
|
|
794
|
+
for (const target of targets) {
|
|
795
|
+
const stopped = await stopRunner(target, { forceKill: true })
|
|
796
|
+
results.push({
|
|
797
|
+
id: stopped.id,
|
|
798
|
+
ok: true,
|
|
799
|
+
status: stopped.status,
|
|
800
|
+
pid: stopped.pid,
|
|
801
|
+
reason: stopped.reason || null,
|
|
802
|
+
signal: stopped.signal || null,
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
printJson({ ok: true, command: 'stop', results })
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function commandStart(targetToken) {
|
|
810
|
+
const targets = await loadTargets(targetToken)
|
|
811
|
+
const results = []
|
|
812
|
+
|
|
813
|
+
for (const target of targets) {
|
|
814
|
+
const started = await startRunner(target)
|
|
815
|
+
results.push({
|
|
816
|
+
id: started.id,
|
|
817
|
+
ok: true,
|
|
818
|
+
status: started.status,
|
|
819
|
+
pid: started.pid,
|
|
820
|
+
alreadyRunning: started.alreadyRunning,
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
printJson({ ok: true, command: 'start', results })
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function commandDelete(targetToken, yes) {
|
|
828
|
+
const targets = await loadTargets(targetToken)
|
|
829
|
+
const results = []
|
|
830
|
+
|
|
831
|
+
for (const target of targets) {
|
|
832
|
+
const deleted = await deleteRunner(target, { yes })
|
|
833
|
+
results.push(deleted)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
printJson({ ok: true, command: 'delete', results })
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function commandPurge(yes) {
|
|
840
|
+
if (!yes) {
|
|
841
|
+
throw new Error('purge is destructive, re-run with --yes')
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const records = await listRunnerRecords()
|
|
845
|
+
const stopped = []
|
|
846
|
+
for (const record of records) {
|
|
847
|
+
const result = await stopRunner(record, { forceKill: true })
|
|
848
|
+
stopped.push({ id: result.id, signal: result.signal || null, reason: result.reason || null })
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
await rm(runnersDirPath(), { recursive: true, force: true })
|
|
852
|
+
await unlink(legacyCredentialsPath()).catch(() => {})
|
|
853
|
+
|
|
854
|
+
printJson({
|
|
855
|
+
ok: true,
|
|
856
|
+
command: 'purge',
|
|
857
|
+
removedRunnerDir: runnersDirPath(),
|
|
858
|
+
removedLegacyCredentials: legacyCredentialsPath(),
|
|
859
|
+
stopped,
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function commandGames(baseUrl) {
|
|
864
|
+
const url = new URL('/api/modes', baseUrl).toString()
|
|
865
|
+
const response = await fetch(url)
|
|
866
|
+
const data = await response.json().catch(() => ({}))
|
|
867
|
+
if (!response.ok) {
|
|
868
|
+
throw new Error(`Failed to fetch game modes: HTTP ${response.status}`)
|
|
869
|
+
}
|
|
870
|
+
printJson({ ok: true, command: 'games', ...data })
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function commandModes(modesValue) {
|
|
874
|
+
const records = await listRunnerRecords()
|
|
875
|
+
if (records.length === 0) {
|
|
876
|
+
throw new Error('no local instances found — run connect first')
|
|
877
|
+
}
|
|
878
|
+
await commandSetModes(modesValue, 'all')
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function main() {
|
|
882
|
+
const argv = process.argv.slice(2)
|
|
883
|
+
const command = argv[0]
|
|
884
|
+
|
|
885
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
886
|
+
usage()
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// --- Primary commands (match OpenClaw plugin style) ---
|
|
891
|
+
|
|
892
|
+
if (command === 'connect') {
|
|
893
|
+
await commandConnect(argv.slice(1))
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (command === 'stop') {
|
|
898
|
+
await commandStop(argv[1] || 'all')
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (command === 'pause') {
|
|
903
|
+
await commandPause(argv[1] || 'all')
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (command === 'resume') {
|
|
908
|
+
await commandResume(argv[1] || 'all')
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (command === 'modes') {
|
|
913
|
+
const modesValue = argv[1]
|
|
914
|
+
if (!modesValue) throw new Error('Usage: modes <a,b>')
|
|
915
|
+
await commandModes(modesValue)
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (command === 'games') {
|
|
920
|
+
const records = await listRunnerRecords()
|
|
921
|
+
const baseUrl = records.length > 0 ? records[0].baseUrl : DEFAULT_BASE_URL
|
|
922
|
+
await commandGames(baseUrl)
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (command === 'status') {
|
|
927
|
+
await commandStatus(argv[1] || 'all')
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// --- Advanced commands ---
|
|
932
|
+
|
|
933
|
+
if (command === 'ls' || command === 'list') {
|
|
934
|
+
await commandList()
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (command === 'start') {
|
|
939
|
+
const hasConnectStyleFlag = argv.slice(1).some(arg => arg.startsWith('--'))
|
|
940
|
+
if (hasConnectStyleFlag) {
|
|
941
|
+
await commandConnect(argv.slice(1))
|
|
942
|
+
return
|
|
943
|
+
}
|
|
944
|
+
const target = argv[1]
|
|
945
|
+
if (!target) {
|
|
946
|
+
throw new Error('Usage: start <id|all>')
|
|
947
|
+
}
|
|
948
|
+
await commandStart(target)
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (command === 'set-modes' || command === 'set') {
|
|
953
|
+
const offset = command === 'set' ? 2 : 1
|
|
954
|
+
const modesValue = argv[offset]
|
|
955
|
+
if (!modesValue) throw new Error('Usage: modes <a,b>')
|
|
956
|
+
await commandSetModes(modesValue, argv[offset + 1] || 'all')
|
|
957
|
+
return
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (command === 'delete') {
|
|
961
|
+
const target = argv[1]
|
|
962
|
+
if (!target) throw new Error('Usage: delete <id|all> [--yes]')
|
|
963
|
+
const yes = argv.includes('--yes')
|
|
964
|
+
await commandDelete(target, yes)
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (command === 'purge') {
|
|
969
|
+
await commandPurge(argv.includes('--yes'))
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
throw new Error(`Unknown command: ${command}. Run with --help for usage.`)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
main().catch(error => {
|
|
977
|
+
process.stderr.write(`[runner-manager][fatal] ${error?.message || String(error)}\n`)
|
|
978
|
+
process.exit(1)
|
|
979
|
+
})
|