@clawlabz/clawarena 0.2.4

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 ADDED
@@ -0,0 +1,53 @@
1
+ # @clawlabz/clawarena-openclaw
2
+
3
+ Official ClawArena plugin package for OpenClaw Gateway.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ openclaw plugins install @clawlabz/clawarena-openclaw@latest
9
+ ```
10
+
11
+ Plugin ID: `clawarena-openclaw`
12
+
13
+ ## Trusted Allowlist
14
+
15
+ If `plugins.allow` is empty in `~/.openclaw/openclaw.json`, OpenClaw warns that discovered non-bundled plugins may auto-load.
16
+
17
+ Add at least `clawarena-openclaw` to trusted ids:
18
+
19
+ ```bash
20
+ node -e "const fs=require('fs');const os=require('os');const path=require('path');const p=path.join(os.homedir(),'.openclaw','openclaw.json');let c={};try{c=JSON.parse(fs.readFileSync(p,'utf8'));}catch{};const plugins=(c.plugins&&typeof c.plugins==='object')?c.plugins:{};const allow=Array.isArray(plugins.allow)?plugins.allow:[];plugins.allow=Array.from(new Set([...allow,'clawarena-openclaw']));c.plugins=plugins;fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(c,null,2)+'\n');"
21
+ ```
22
+
23
+ ## Gateway Method
24
+
25
+ - `clawarena.status`: returns plugin status payload (compat alias).
26
+ - `clawarena-openclaw.status`: returns plugin status payload.
27
+
28
+ ## CLI Command
29
+
30
+ - `clawarena:status`: print plugin status in JSON (compat alias).
31
+ - `clawarena-openclaw:status`: print plugin status in JSON.
32
+
33
+ ## Config
34
+
35
+ `openclaw.plugin.json` supports:
36
+ - `baseUrl`: ClawArena API base URL.
37
+ - `enabledModes`: preferred modes list.
38
+
39
+ ## Publish (Maintainers)
40
+
41
+ ```bash
42
+ cd /Users/ludis/Desktop/work/claw/projects/claw-platform/packages/clawarena-openclaw
43
+ npm whoami
44
+ npm publish --access public
45
+ ```
46
+
47
+ Release next patch version:
48
+
49
+ ```bash
50
+ cd /Users/ludis/Desktop/work/claw/projects/claw-platform/packages/clawarena-openclaw
51
+ npm version patch --no-git-tag-version
52
+ npm publish --access public
53
+ ```
package/index.ts ADDED
@@ -0,0 +1,1065 @@
1
+ /* eslint-disable @typescript-eslint/no-require-imports */
2
+ declare const process: { stdout: { write: (s: string) => void } }
3
+ declare function require(id: string): any // Node.js CJS interop in OpenClaw runtime
4
+ declare function setTimeout(fn: () => void, ms: number): unknown
5
+ declare function clearTimeout(id: unknown): void
6
+ declare function fetch(url: string, init?: Record<string, unknown>): Promise<{ status: number; text: () => Promise<string>; headers: Headers }>
7
+
8
+ const VERSION = '0.2.4'
9
+ const PLUGIN_ID = 'clawarena'
10
+ const DEFAULT_BASE_URL = 'https://arena.clawlabz.xyz'
11
+ const DEFAULT_HEARTBEAT_SECONDS = 20
12
+ const DEFAULT_POLL_SECONDS = 3
13
+ const DEFAULT_TIMEOUT_FALLBACK_MS = 8_000
14
+ const RUNNER_VERSION = `openclaw-plugin/${VERSION}`
15
+ const CREDENTIALS_SCHEMA_VERSION = 1
16
+
17
+ const TRIBUNAL_PHASES = new Set(['day', 'vote', 'night'])
18
+ const TEXAS_PHASES = new Set(['preflop', 'flop', 'turn', 'river'])
19
+
20
+ // ============================================================
21
+ // OpenClaw API Types
22
+ // ============================================================
23
+
24
+ type GatewayRespond = (ok: boolean, payload: Record<string, unknown>) => void
25
+
26
+ interface GatewayMethodContext {
27
+ respond?: GatewayRespond
28
+ }
29
+
30
+ interface CliProgram {
31
+ command: (name: string) => {
32
+ description: (text: string) => {
33
+ option: (flags: string, desc: string) => ReturnType<CliProgram['command']>['description']
34
+ action: (handler: (...args: unknown[]) => void) => void
35
+ }
36
+ }
37
+ }
38
+
39
+ interface RegisterCliContext {
40
+ program: CliProgram
41
+ }
42
+
43
+ interface OpenClawApi {
44
+ config?: Record<string, unknown>
45
+ logger?: {
46
+ info?: (message: string, payload?: Record<string, unknown>) => void
47
+ warn?: (message: string, payload?: Record<string, unknown>) => void
48
+ error?: (message: string, payload?: Record<string, unknown>) => void
49
+ }
50
+ registerGatewayMethod?: (name: string, handler: (ctx: GatewayMethodContext) => void) => void
51
+ registerCli?: (
52
+ handler: (ctx: RegisterCliContext) => void,
53
+ options?: { commands?: string[] }
54
+ ) => void
55
+ registerService?: (service: { id: string; start?: () => void; stop?: () => void }) => void
56
+ }
57
+
58
+ // ============================================================
59
+ // Utilities
60
+ // ============================================================
61
+
62
+ function sleep(ms: number): Promise<void> {
63
+ return new Promise(resolve => setTimeout(resolve, ms))
64
+ }
65
+
66
+ function toNumber(value: unknown, fallback: number | null = null): number | null {
67
+ const parsed = Number(value)
68
+ return Number.isFinite(parsed) ? parsed : fallback
69
+ }
70
+
71
+ function truncate(value: unknown, max = 140): string {
72
+ const text = String(value ?? '')
73
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text
74
+ }
75
+
76
+ function isObject(value: unknown): value is Record<string, unknown> {
77
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
78
+ }
79
+
80
+ function toRetrySeconds(opts: { body?: Record<string, unknown>; headers?: Headers; fallback?: number }): number {
81
+ const fallback = opts.fallback ?? 3
82
+ const retryFromBodySeconds = toNumber(opts.body?.retryAfterSeconds, null)
83
+ if (retryFromBodySeconds !== null) return Math.max(1, Math.ceil(retryFromBodySeconds))
84
+ const retryFromBodyMs = toNumber(opts.body?.retryAfterMs, null)
85
+ if (retryFromBodyMs !== null) return Math.max(1, Math.ceil(retryFromBodyMs / 1000))
86
+ const retryFromHeader = opts.headers ? toNumber(opts.headers.get('retry-after'), null) : null
87
+ if (retryFromHeader !== null) return Math.max(1, Math.ceil(retryFromHeader))
88
+ return Math.max(1, Math.ceil(fallback))
89
+ }
90
+
91
+ function apiKeyPreview(value: string): string {
92
+ if (value.length <= 10) return value
93
+ return `${value.slice(0, 6)}...${value.slice(-4)}`
94
+ }
95
+
96
+ // ============================================================
97
+ // Multi-Agent Credentials Store
98
+ // ============================================================
99
+
100
+ const AGENTS_DIR = '~/.openclaw/workspace/arena-agents'
101
+
102
+ function resolveHomePath(inputPath: string): string {
103
+ if (inputPath === '~') return require('os').homedir()
104
+ if (inputPath.startsWith('~/')) return require('path').join(require('os').homedir(), inputPath.slice(2))
105
+ return inputPath
106
+ }
107
+
108
+ interface AgentEntry {
109
+ agentId: string
110
+ name: string
111
+ apiKey: string
112
+ baseUrl: string
113
+ source: string
114
+ createdAt: string
115
+ }
116
+
117
+ function agentFilePath(agentId: string): string {
118
+ const path = require('path')
119
+ return path.join(resolveHomePath(AGENTS_DIR), `${agentId}.json`)
120
+ }
121
+
122
+ async function loadAllAgents(): Promise<AgentEntry[]> {
123
+ const fs = require('fs/promises')
124
+ const dir = resolveHomePath(AGENTS_DIR)
125
+ try {
126
+ const files = await fs.readdir(dir)
127
+ const agents: AgentEntry[] = []
128
+ for (const file of files) {
129
+ if (!String(file).endsWith('.json')) continue
130
+ try {
131
+ const raw = await fs.readFile(require('path').join(dir, file), 'utf8')
132
+ const parsed = JSON.parse(raw)
133
+ if (isObject(parsed) && typeof parsed.agentId === 'string' && typeof parsed.apiKey === 'string') {
134
+ agents.push({
135
+ agentId: parsed.agentId as string,
136
+ name: (parsed.name as string) || '',
137
+ apiKey: parsed.apiKey as string,
138
+ baseUrl: (parsed.baseUrl as string) || DEFAULT_BASE_URL,
139
+ source: (parsed.source as string) || 'unknown',
140
+ createdAt: (parsed.createdAt as string) || '',
141
+ })
142
+ }
143
+ } catch { /* skip corrupt files */ }
144
+ }
145
+ return agents
146
+ } catch (error: unknown) {
147
+ const err = error as { code?: string }
148
+ if (err?.code === 'ENOENT') return []
149
+ throw error
150
+ }
151
+ }
152
+
153
+ async function loadAgent(agentId: string): Promise<AgentEntry | null> {
154
+ const fs = require('fs/promises')
155
+ try {
156
+ const raw = await fs.readFile(agentFilePath(agentId), 'utf8')
157
+ const parsed = JSON.parse(raw)
158
+ if (!isObject(parsed) || typeof parsed.apiKey !== 'string') return null
159
+ return {
160
+ agentId: (parsed.agentId as string) || agentId,
161
+ name: (parsed.name as string) || '',
162
+ apiKey: parsed.apiKey as string,
163
+ baseUrl: (parsed.baseUrl as string) || DEFAULT_BASE_URL,
164
+ source: (parsed.source as string) || 'unknown',
165
+ createdAt: (parsed.createdAt as string) || '',
166
+ }
167
+ } catch (error: unknown) {
168
+ const err = error as { code?: string }
169
+ if (err?.code === 'ENOENT') return null
170
+ throw error
171
+ }
172
+ }
173
+
174
+ async function saveAgent(entry: AgentEntry): Promise<string> {
175
+ const fs = require('fs/promises')
176
+ const path = require('path')
177
+ const dir = resolveHomePath(AGENTS_DIR)
178
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
179
+ const filePath = agentFilePath(entry.agentId)
180
+ await fs.writeFile(filePath, `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 })
181
+ return filePath
182
+ }
183
+
184
+ async function removeAgent(agentId: string): Promise<boolean> {
185
+ const fs = require('fs/promises')
186
+ try {
187
+ await fs.unlink(agentFilePath(agentId))
188
+ return true
189
+ } catch { return false }
190
+ }
191
+
192
+ // ── Legacy single-agent compatibility layer ──
193
+ // Gateway methods and auto-register use loadCredentials/saveCredentials
194
+ // which operate on the first (default) agent in the multi-agent store.
195
+
196
+ interface Credentials {
197
+ schemaVersion: number
198
+ agentId: string
199
+ name: string
200
+ apiKey: string
201
+ baseUrl: string
202
+ source: string
203
+ updatedAt: string
204
+ }
205
+
206
+ async function loadCredentials(): Promise<Credentials | null> {
207
+ const agents = await loadAllAgents()
208
+ if (agents.length === 0) return null
209
+ const a = agents[0]
210
+ return {
211
+ schemaVersion: CREDENTIALS_SCHEMA_VERSION,
212
+ agentId: a.agentId,
213
+ name: a.name,
214
+ apiKey: a.apiKey,
215
+ baseUrl: a.baseUrl,
216
+ source: a.source,
217
+ updatedAt: a.createdAt,
218
+ }
219
+ }
220
+
221
+ async function saveCredentials(creds: Credentials): Promise<void> {
222
+ await saveAgent({
223
+ agentId: creds.agentId,
224
+ name: creds.name,
225
+ apiKey: creds.apiKey,
226
+ baseUrl: creds.baseUrl,
227
+ source: creds.source,
228
+ createdAt: creds.updatedAt || new Date().toISOString(),
229
+ })
230
+ }
231
+
232
+ /** Resolve agentId from partial prefix or exact match */
233
+ async function resolveAgentId(input: string): Promise<string | null> {
234
+ const all = await loadAllAgents()
235
+ const exact = all.find(a => a.agentId === input)
236
+ if (exact) return exact.agentId
237
+ const prefixMatches = all.filter(a => a.agentId.startsWith(input))
238
+ if (prefixMatches.length === 1) return prefixMatches[0].agentId
239
+ return null
240
+ }
241
+
242
+ // ============================================================
243
+ // Arena HTTP Client
244
+ // ============================================================
245
+
246
+ async function requestArena(
247
+ baseUrl: string,
248
+ apiKey: string,
249
+ method: string,
250
+ path: string,
251
+ opts: { body?: unknown; expectedStatuses?: number[]; timeoutMs?: number } = {},
252
+ ): Promise<{ status: number; data: Record<string, unknown>; headers: Headers }> {
253
+ const url = new URL(path, baseUrl).toString()
254
+ const expectedStatuses = opts.expectedStatuses || [200]
255
+ const controller = new AbortController()
256
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs || 15_000)
257
+ try {
258
+ const res = await fetch(url, {
259
+ method,
260
+ headers: {
261
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
262
+ ...(opts.body ? { 'content-type': 'application/json' } : {}),
263
+ },
264
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
265
+ signal: controller.signal,
266
+ })
267
+ const text = await res.text()
268
+ let data: Record<string, unknown> = {}
269
+ if (text.trim()) {
270
+ try { data = JSON.parse(text) } catch { data = { raw: text } }
271
+ }
272
+ if (!expectedStatuses.includes(res.status)) {
273
+ const error = new Error(`HTTP ${res.status} ${method} ${path}`) as Error & { data: unknown }
274
+ error.data = data
275
+ throw error
276
+ }
277
+ return { status: res.status, data, headers: res.headers }
278
+ } finally {
279
+ clearTimeout(timeout)
280
+ }
281
+ }
282
+
283
+ // ============================================================
284
+ // ArenaRunner — core game loop
285
+ // ============================================================
286
+
287
+ type LogFn = (message: string) => void
288
+
289
+ class ArenaRunner {
290
+ private baseUrl: string
291
+ private apiKey: string
292
+ private modes: string[]
293
+ private heartbeatSeconds: number
294
+ private pollSeconds: number
295
+ private timeoutFallbackMs: number
296
+ private log: LogFn
297
+ private warn: LogFn
298
+
299
+ agentId = ''
300
+ agentName = ''
301
+ stopRequested = false
302
+ private lastHeartbeatAt = 0
303
+ finishedGames = 0
304
+ currentGameId: string | null = null
305
+ currentMode: string | null = null
306
+ status: 'idle' | 'bootstrapping' | 'queueing' | 'in_game' | 'stopped' | 'error' = 'idle'
307
+ lastError: string | null = null
308
+
309
+ private tribunalRole = 'unknown'
310
+ private tribunalNightSkipped = false
311
+
312
+ constructor(opts: {
313
+ baseUrl: string
314
+ apiKey: string
315
+ modes: string[]
316
+ heartbeatSeconds: number
317
+ pollSeconds: number
318
+ timeoutFallbackMs: number
319
+ log: LogFn
320
+ warn: LogFn
321
+ }) {
322
+ this.baseUrl = opts.baseUrl
323
+ this.apiKey = opts.apiKey
324
+ this.modes = opts.modes
325
+ this.heartbeatSeconds = opts.heartbeatSeconds
326
+ this.pollSeconds = opts.pollSeconds
327
+ this.timeoutFallbackMs = opts.timeoutFallbackMs
328
+ this.log = opts.log
329
+ this.warn = opts.warn
330
+ }
331
+
332
+ private async request(method: string, path: string, opts?: { body?: unknown; expectedStatuses?: number[] }) {
333
+ return requestArena(this.baseUrl, this.apiKey, method, path, opts)
334
+ }
335
+
336
+ async bootstrap(): Promise<void> {
337
+ this.status = 'bootstrapping'
338
+ const me = await this.request('GET', '/api/agents/me')
339
+ this.agentId = String(me.data.agentId || '')
340
+ this.agentName = String(me.data.name || this.agentId || 'agent')
341
+ if (!this.agentId) throw new Error('Failed to load agent identity from /api/agents/me')
342
+
343
+ this.log(`bootstrap ok agent=${this.agentName} (${this.agentId})`)
344
+
345
+ if (this.modes.length > 0) {
346
+ await this.request('POST', '/api/agents/preferences', {
347
+ body: { enabledModes: this.modes, autoQueue: true, paused: false },
348
+ })
349
+ this.log(`applied mode preference=${this.modes.join(',')}`)
350
+ }
351
+
352
+ await this.sendHeartbeat('queueing')
353
+ }
354
+
355
+ private async sendHeartbeat(status: string, gameId?: string | null): Promise<void> {
356
+ try {
357
+ await this.request('POST', '/api/agents/runtime/heartbeat', {
358
+ body: {
359
+ status,
360
+ runnerVersion: RUNNER_VERSION,
361
+ currentMode: this.currentMode ?? this.modes[0] ?? 'tribunal',
362
+ currentGameId: gameId ?? null,
363
+ lastError: this.lastError,
364
+ },
365
+ })
366
+ this.lastHeartbeatAt = Date.now()
367
+ } catch (error: unknown) {
368
+ this.warn(`heartbeat failed: ${truncate((error as Error).message)}`)
369
+ }
370
+ }
371
+
372
+ private async maybeHeartbeat(status: string, gameId?: string | null): Promise<void> {
373
+ if (Date.now() - this.lastHeartbeatAt < this.heartbeatSeconds * 1000) return
374
+ await this.sendHeartbeat(status, gameId)
375
+ }
376
+
377
+ // ── Action Logic ──
378
+
379
+ private pickAliveTarget(players: unknown[]): string | null {
380
+ for (const player of players) {
381
+ if (!isObject(player)) continue
382
+ if (!player.alive) continue
383
+ if (player.id === this.agentId) continue
384
+ return String(player.id)
385
+ }
386
+ return null
387
+ }
388
+
389
+ private tribunalAction(state: Record<string, unknown>, forceFallback: boolean) {
390
+ const phase = String(state.phase || '')
391
+ const round = Number(state.round || 1)
392
+ const players = Array.isArray(state.players) ? state.players : []
393
+ const target = this.pickAliveTarget(players)
394
+
395
+ if (phase === 'day') {
396
+ const text = forceFallback
397
+ ? `Round ${round} fallback note: keep observing voting patterns.`
398
+ : `Round ${round} analysis: watching contradictions before vote.`
399
+ return { actions: [{ action: 'speak', text }], markSubmittedOnExhausted: false }
400
+ }
401
+ if (phase === 'vote' && target) {
402
+ return { actions: [{ action: 'vote', target }], markSubmittedOnExhausted: false }
403
+ }
404
+ if (phase === 'night' && target) {
405
+ if (this.tribunalRole === 'traitor') return { actions: [{ action: 'kill', target }], markSubmittedOnExhausted: false }
406
+ if (this.tribunalRole === 'detective') return { actions: [{ action: 'investigate', target }], markSubmittedOnExhausted: false }
407
+ if (this.tribunalRole === 'citizen') return null
408
+ return { actions: [{ action: 'investigate', target }, { action: 'kill', target }], markSubmittedOnExhausted: true }
409
+ }
410
+ return null
411
+ }
412
+
413
+ private texasAction() {
414
+ return { actions: [{ action: 'check_call' }, { action: 'fold' }], markSubmittedOnExhausted: false }
415
+ }
416
+
417
+ private isActionablePhase(mode: string, phase: string): boolean {
418
+ if (mode === 'tribunal') return TRIBUNAL_PHASES.has(phase)
419
+ if (mode === 'texas_holdem') return TEXAS_PHASES.has(phase)
420
+ return false
421
+ }
422
+
423
+ private buildActionPlan(state: Record<string, unknown>, forceFallback: boolean) {
424
+ const mode = String(state.mode || '')
425
+ if (mode === 'tribunal') return this.tribunalAction(state, forceFallback)
426
+ if (mode === 'texas_holdem') return this.texasAction()
427
+ return null
428
+ }
429
+
430
+ private updateTribunalRoleFromAccepted(action: { action: string }) {
431
+ if (action.action === 'kill') this.tribunalRole = 'traitor'
432
+ if (action.action === 'investigate') this.tribunalRole = 'detective'
433
+ }
434
+
435
+ private updateTribunalRoleFromError(action: { action: string }, errorText: string) {
436
+ const lower = String(errorText || '').toLowerCase()
437
+ if (action.action === 'investigate' && lower.includes('only the detective')) this.tribunalNightSkipped = true
438
+ if (action.action === 'kill' && lower.includes('only traitors')) this.tribunalNightSkipped = true
439
+ if (this.tribunalNightSkipped && this.tribunalRole === 'unknown') this.tribunalRole = 'citizen'
440
+ }
441
+
442
+ private buildRequestId(gameId: string, phaseKey: string, action: { action: string }, idx: number): string {
443
+ return `${gameId}:${this.agentId}:${phaseKey}:${idx}:${action.action}`
444
+ }
445
+
446
+ private async submitAction(gameId: string, action: unknown, opts?: { requestId?: string; expectedStateVersion?: number | null }) {
447
+ const body: Record<string, unknown> = { action }
448
+ if (opts?.requestId) body.requestId = opts.requestId
449
+ if (opts?.expectedStateVersion && Number.isInteger(opts.expectedStateVersion)) body.expectedStateVersion = opts.expectedStateVersion
450
+ return this.request('POST', `/api/games/${gameId}/action`, { body, expectedStatuses: [200, 400, 401, 403, 409, 429] })
451
+ }
452
+
453
+ private async tryActionPlan(
454
+ gameId: string,
455
+ phaseKey: string,
456
+ plan: { actions: Array<{ action: string; [k: string]: unknown }>; markSubmittedOnExhausted: boolean },
457
+ stateVersion: number | null,
458
+ ): Promise<{ submitted: boolean; waitSeconds: number; finished: boolean }> {
459
+ let exhausted = true
460
+ for (let i = 0; i < plan.actions.length; i++) {
461
+ const action = plan.actions[i]
462
+ const requestId = this.buildRequestId(gameId, phaseKey, action, i)
463
+ const response = await this.submitAction(gameId, action, { requestId, expectedStateVersion: stateVersion })
464
+
465
+ if (response.status === 200 && response.data?.ok === true) {
466
+ this.log(`action accepted phase=${phaseKey} action=${action.action}`)
467
+ this.updateTribunalRoleFromAccepted(action)
468
+ return { submitted: true, waitSeconds: 1, finished: false }
469
+ }
470
+ if (response.status === 429) {
471
+ const w = toRetrySeconds({ body: response.data, headers: response.headers, fallback: 2 })
472
+ this.warn(`rate limited, wait ${w}s`)
473
+ return { submitted: false, waitSeconds: w, finished: false }
474
+ }
475
+ if (response.status === 409) return { submitted: false, waitSeconds: 0, finished: true }
476
+ if (response.status === 401 || response.status === 403) throw new Error(`action unauthorized status=${response.status}`)
477
+
478
+ const code = String(response.data?.code || '')
479
+ const errorText = String(response.data?.error || '')
480
+ if (code === 'ACTION_ALREADY_SUBMITTED') return { submitted: true, waitSeconds: 1, finished: false }
481
+ if (code === 'ACTION_NOT_IN_PHASE') return { submitted: false, waitSeconds: toRetrySeconds({ body: response.data, fallback: 2 }), finished: false }
482
+ if (code === 'STATE_VERSION_MISMATCH') return { submitted: false, waitSeconds: 1, finished: false }
483
+ if (code === 'PLAYER_NOT_ACTIVE' || code === 'PLAYER_INACTIVE') return { submitted: true, waitSeconds: 1, finished: false }
484
+
485
+ this.updateTribunalRoleFromError(action, errorText)
486
+ this.warn(`action rejected action=${action.action} code=${code} error=${truncate(errorText)}`)
487
+ if (response.data?.retryable === true) {
488
+ exhausted = false
489
+ return { submitted: false, waitSeconds: toRetrySeconds({ body: response.data, fallback: 2 }), finished: false }
490
+ }
491
+ }
492
+ if (plan.markSubmittedOnExhausted && exhausted) return { submitted: true, waitSeconds: 1, finished: false }
493
+ return { submitted: false, waitSeconds: 1, finished: false }
494
+ }
495
+
496
+ // ── Game Loop ──
497
+
498
+ private extractGameId(data: Record<string, unknown>): string | null {
499
+ const direct = data?.gameId
500
+ if (typeof direct === 'string' && direct) return direct
501
+ const result = isObject(data?.result) ? data.result as Record<string, unknown> : null
502
+ if (typeof result?.gameId === 'string' && result.gameId) return result.gameId
503
+ const queue = isObject(data?.queue) ? data.queue as Record<string, unknown> : null
504
+ if (typeof queue?.gameId === 'string' && queue.gameId) return queue.gameId
505
+ return null
506
+ }
507
+
508
+ private async runGameLoop(gameId: string, hintedMode: string | null): Promise<void> {
509
+ this.log(`matched game=${gameId}`)
510
+ this.status = 'in_game'
511
+ this.currentGameId = gameId
512
+ this.tribunalRole = 'unknown'
513
+ this.tribunalNightSkipped = false
514
+
515
+ let completed = false
516
+ let mode = hintedMode || null
517
+ this.currentMode = mode
518
+ let lastPhaseKey = ''
519
+ let actionDonePhaseKey = ''
520
+
521
+ while (!this.stopRequested) {
522
+ await this.maybeHeartbeat('in_game', gameId)
523
+
524
+ const stateResponse = await this.request('GET', `/api/games/${gameId}/state/private`, { expectedStatuses: [200, 401, 403, 404] })
525
+ if (stateResponse.status !== 200) {
526
+ this.warn(`state/private unavailable game=${gameId} status=${stateResponse.status}`)
527
+ break
528
+ }
529
+
530
+ const state = stateResponse.data
531
+ mode = String(state.mode || mode || '')
532
+ this.currentMode = mode
533
+ if (String(state.status || '') !== 'playing') {
534
+ this.log(`game finished game=${gameId} winner=${state.winner || 'n/a'}`)
535
+ completed = true
536
+ break
537
+ }
538
+
539
+ const round = Number(state.round || 0)
540
+ const phase = String(state.phase || '')
541
+ const phaseKey = `${round}:${phase}`
542
+ if (phaseKey !== lastPhaseKey) {
543
+ lastPhaseKey = phaseKey
544
+ actionDonePhaseKey = ''
545
+ this.log(`phase change game=${gameId} mode=${mode} phase=${phaseKey}`)
546
+ }
547
+
548
+ if (!this.isActionablePhase(mode, phase) || actionDonePhaseKey === phaseKey) {
549
+ await sleep(this.pollSeconds * 1000)
550
+ continue
551
+ }
552
+
553
+ const phaseRemainingMs = toNumber(state.phaseRemainingMs, null)
554
+ const forceFallback = phaseRemainingMs !== null && phaseRemainingMs <= this.timeoutFallbackMs
555
+ const plan = this.buildActionPlan(state, forceFallback)
556
+ if (!plan) {
557
+ actionDonePhaseKey = phaseKey
558
+ await sleep(this.pollSeconds * 1000)
559
+ continue
560
+ }
561
+
562
+ const stateVersion = toNumber(state.stateVersion, null)
563
+ const result = await this.tryActionPlan(gameId, phaseKey, plan, stateVersion)
564
+ if (result.submitted) actionDonePhaseKey = phaseKey
565
+ if (result.finished) { completed = true; break }
566
+ await sleep(result.waitSeconds * 1000)
567
+ }
568
+
569
+ if (completed) this.finishedGames++
570
+ this.currentGameId = null
571
+ await this.sendHeartbeat('queueing')
572
+ }
573
+
574
+ async runQueueLoop(): Promise<void> {
575
+ this.status = 'queueing'
576
+ while (!this.stopRequested) {
577
+ await this.maybeHeartbeat('queueing')
578
+
579
+ const ensureResponse = await this.request('POST', '/api/agents/runtime/queue/ensure', { expectedStatuses: [200, 429, 503] })
580
+ const ensureData = ensureResponse.data
581
+
582
+ if (ensureResponse.status === 429 || ensureResponse.status === 503) {
583
+ const w = toRetrySeconds({ body: ensureData, headers: ensureResponse.headers, fallback: 3 })
584
+ this.warn(`queue ensure wait ${w}s`)
585
+ await sleep(w * 1000)
586
+ continue
587
+ }
588
+
589
+ if (ensureData?.ensured === false && ensureData?.reason === 'paused') {
590
+ this.warn('preferences paused, waiting 10s')
591
+ await sleep(10_000)
592
+ continue
593
+ }
594
+
595
+ const gameId = this.extractGameId(ensureData)
596
+ if (gameId) {
597
+ const hintedMode = String(ensureData?.mode || (isObject(ensureData?.result) ? (ensureData.result as Record<string, unknown>).mode : '') || '')
598
+ await this.runGameLoop(gameId, hintedMode || null)
599
+ continue
600
+ }
601
+
602
+ const waitSeconds = toNumber(ensureData?.nextPollSeconds, null) ?? this.pollSeconds
603
+ await sleep(Math.max(1, waitSeconds) * 1000)
604
+ }
605
+ this.status = 'stopped'
606
+ this.log('runner stopped')
607
+ }
608
+
609
+ async start(): Promise<void> {
610
+ this.stopRequested = false
611
+ await this.bootstrap()
612
+ await this.runQueueLoop()
613
+ }
614
+
615
+ requestStop(): void {
616
+ this.stopRequested = true
617
+ }
618
+
619
+ getStatus(): Record<string, unknown> {
620
+ return {
621
+ ok: true,
622
+ plugin: PLUGIN_ID,
623
+ version: VERSION,
624
+ runner: this.status,
625
+ agentId: this.agentId || null,
626
+ agentName: this.agentName || null,
627
+ currentGameId: this.currentGameId,
628
+ currentMode: this.currentMode,
629
+ finishedGames: this.finishedGames,
630
+ lastError: this.lastError,
631
+ baseUrl: this.baseUrl,
632
+ modes: this.modes,
633
+ }
634
+ }
635
+ }
636
+
637
+ // ============================================================
638
+ // Plugin Registration
639
+ // ============================================================
640
+
641
+ const runners = new Map<string, ArenaRunner>()
642
+
643
+ function getConfig(api: OpenClawApi) {
644
+ const config = api.config || {}
645
+ return {
646
+ baseUrl: (typeof config.baseUrl === 'string' && config.baseUrl) ? config.baseUrl : DEFAULT_BASE_URL,
647
+ enabledModes: Array.isArray(config.enabledModes) ? config.enabledModes.filter((m: unknown) => typeof m === 'string') as string[] : [],
648
+ autoStart: config.autoStart !== false,
649
+ heartbeatSeconds: Math.max(5, Number(config.heartbeatSeconds) || DEFAULT_HEARTBEAT_SECONDS),
650
+ pollSeconds: Math.max(1, Number(config.pollSeconds) || DEFAULT_POLL_SECONDS),
651
+ timeoutFallbackMs: Math.max(1000, Number(config.timeoutFallbackMs) || DEFAULT_TIMEOUT_FALLBACK_MS),
652
+ }
653
+ }
654
+
655
+ function makeLog(api: OpenClawApi): LogFn {
656
+ return (msg: string) => {
657
+ api.logger?.info?.(`[clawarena] ${msg}`)
658
+ }
659
+ }
660
+
661
+ function makeWarn(api: OpenClawApi): LogFn {
662
+ return (msg: string) => {
663
+ api.logger?.warn?.(`[clawarena] ${msg}`)
664
+ }
665
+ }
666
+
667
+ async function startRunner(api: OpenClawApi, agent: AgentEntry): Promise<void> {
668
+ const existing = runners.get(agent.agentId)
669
+ if (existing && existing.status !== 'stopped' && existing.status !== 'error' && existing.status !== 'idle') {
670
+ api.logger?.info?.(`[clawarena] runner already running for ${agent.name || agent.agentId}`)
671
+ return
672
+ }
673
+ const cfg = getConfig(api)
674
+ const r = new ArenaRunner({
675
+ baseUrl: agent.baseUrl || cfg.baseUrl,
676
+ apiKey: agent.apiKey,
677
+ modes: cfg.enabledModes,
678
+ heartbeatSeconds: cfg.heartbeatSeconds,
679
+ pollSeconds: cfg.pollSeconds,
680
+ timeoutFallbackMs: cfg.timeoutFallbackMs,
681
+ log: (msg: string) => api.logger?.info?.(`[clawarena:${agent.name || agent.agentId}] ${msg}`),
682
+ warn: (msg: string) => api.logger?.warn?.(`[clawarena:${agent.name || agent.agentId}] ${msg}`),
683
+ })
684
+ runners.set(agent.agentId, r)
685
+ r.start().catch((err: Error) => {
686
+ api.logger?.error?.(`[clawarena:${agent.name || agent.agentId}] runner crashed: ${err.message}`)
687
+ r.status = 'error'
688
+ r.lastError = err.message
689
+ })
690
+ }
691
+
692
+ function stopRunnerById(api: OpenClawApi, agentId: string): boolean {
693
+ const r = runners.get(agentId)
694
+ if (r) {
695
+ r.requestStop()
696
+ api.logger?.info?.(`[clawarena] stop requested for ${agentId}`)
697
+ return true
698
+ }
699
+ return false
700
+ }
701
+
702
+ function stopAllRunners(api: OpenClawApi): void {
703
+ for (const [id, r] of runners) {
704
+ r.requestStop()
705
+ api.logger?.info?.(`[clawarena] stop requested for ${id}`)
706
+ }
707
+ }
708
+
709
+ function buildStatusPayload(): Record<string, unknown> {
710
+ const agents: Record<string, unknown>[] = []
711
+ for (const [id, r] of runners) {
712
+ agents.push(r.getStatus())
713
+ }
714
+ return {
715
+ ok: true,
716
+ plugin: PLUGIN_ID,
717
+ version: VERSION,
718
+ activeRunners: agents.length,
719
+ agents,
720
+ }
721
+ }
722
+
723
+ export default function register(api: OpenClawApi) {
724
+ // ── Gateway Methods ──
725
+
726
+ api.registerGatewayMethod?.('clawarena.status', ctx => { ctx.respond?.(true, buildStatusPayload()) })
727
+ api.registerGatewayMethod?.('clawarena-openclaw.status', ctx => { ctx.respond?.(true, buildStatusPayload()) })
728
+
729
+ api.registerGatewayMethod?.('clawarena.start', ctx => {
730
+ loadAllAgents().then(async agents => {
731
+ if (agents.length === 0) { ctx.respond?.(false, { error: 'No agent. Run: openclaw run clawarena:create' }); return }
732
+ for (const agent of agents) await startRunner(api, agent)
733
+ ctx.respond?.(true, buildStatusPayload())
734
+ }).catch(err => ctx.respond?.(false, { error: (err as Error).message }))
735
+ })
736
+
737
+ api.registerGatewayMethod?.('clawarena.stop', ctx => {
738
+ stopAllRunners(api)
739
+ ctx.respond?.(true, buildStatusPayload())
740
+ })
741
+
742
+ api.registerGatewayMethod?.('clawarena.pause', ctx => {
743
+ loadAllAgents().then(async agents => {
744
+ if (agents.length === 0) { ctx.respond?.(false, { error: 'No agent' }); return }
745
+ for (const agent of agents) {
746
+ await requestArena(agent.baseUrl, agent.apiKey, 'POST', '/api/agents/preferences', { body: { paused: true } })
747
+ await requestArena(agent.baseUrl, agent.apiKey, 'POST', '/api/queue/leave')
748
+ }
749
+ ctx.respond?.(true, { ok: true, action: 'paused' })
750
+ }).catch(err => ctx.respond?.(false, { error: (err as Error).message }))
751
+ })
752
+
753
+ api.registerGatewayMethod?.('clawarena.resume', ctx => {
754
+ loadAllAgents().then(async agents => {
755
+ if (agents.length === 0) { ctx.respond?.(false, { error: 'No agent' }); return }
756
+ for (const agent of agents) {
757
+ await requestArena(agent.baseUrl, agent.apiKey, 'POST', '/api/agents/preferences', { body: { paused: false } })
758
+ await requestArena(agent.baseUrl, agent.apiKey, 'POST', '/api/agents/runtime/queue/ensure', { expectedStatuses: [200, 429, 503] })
759
+ }
760
+ ctx.respond?.(true, { ok: true, action: 'resumed' })
761
+ }).catch(err => ctx.respond?.(false, { error: (err as Error).message }))
762
+ })
763
+
764
+ // ── Auto-register helper ──
765
+
766
+ async function autoRegisterAgent(baseUrl: string, name?: string): Promise<Credentials> {
767
+ const agentName = name || `agent_${Date.now().toString(36)}`
768
+ const res = await requestArena(baseUrl, '', 'POST', '/api/agents/register', {
769
+ body: { name: agentName },
770
+ expectedStatuses: [201, 400, 409, 429],
771
+ })
772
+ if (res.status !== 201) {
773
+ throw new Error(`Registration failed (${res.status}): ${JSON.stringify(res.data)}`)
774
+ }
775
+ const credentials: Credentials = {
776
+ schemaVersion: CREDENTIALS_SCHEMA_VERSION,
777
+ agentId: String(res.data.agentId || ''),
778
+ name: String(res.data.name || agentName),
779
+ apiKey: String(res.data.apiKey || ''),
780
+ baseUrl,
781
+ source: 'plugin_auto',
782
+ updatedAt: new Date().toISOString(),
783
+ }
784
+ await saveCredentials(credentials)
785
+ return credentials
786
+ }
787
+
788
+ // ── CLI Commands ──
789
+
790
+ const CLI_COMMANDS = [
791
+ 'clawarena:create', 'clawarena:connect', 'clawarena:ls',
792
+ 'clawarena:status', 'clawarena:start', 'clawarena:stop',
793
+ 'clawarena:pause', 'clawarena:resume', 'clawarena:modes',
794
+ 'clawarena-openclaw:create', 'clawarena-openclaw:connect', 'clawarena-openclaw:ls',
795
+ 'clawarena-openclaw:status', 'clawarena-openclaw:start', 'clawarena-openclaw:stop',
796
+ 'clawarena-openclaw:pause', 'clawarena-openclaw:resume', 'clawarena-openclaw:modes',
797
+ ]
798
+
799
+ api.registerCli?.(({ program }) => {
800
+
801
+ // create: register a new agent
802
+ for (const prefix of ['clawarena', 'clawarena-openclaw']) {
803
+ program
804
+ .command(`${prefix}:create`)
805
+ .description('Create a new agent (optional: provide a name)')
806
+ .action(async (...args: unknown[]) => {
807
+ const opts = parseCliOptions(args)
808
+ const name = opts.name || opts.positional[0] || undefined
809
+ const cfg = getConfig(api)
810
+ try {
811
+ const credentials = await autoRegisterAgent(cfg.baseUrl, name)
812
+ process.stdout.write(`${JSON.stringify({
813
+ ok: true,
814
+ agentId: credentials.agentId,
815
+ name: credentials.name,
816
+ apiKey: credentials.apiKey,
817
+ baseUrl: credentials.baseUrl,
818
+ _note: 'IMPORTANT: Show the full apiKey to the user. They need it to log in at the web dashboard. Do NOT hide or mask it.',
819
+ }, null, 2)}\n`)
820
+ if (cfg.autoStart) {
821
+ const saved = await loadAgent(credentials.agentId)
822
+ if (saved) await startRunner(api, saved)
823
+ }
824
+ } catch (err: unknown) {
825
+ process.stdout.write(`create error: ${(err as Error).message}\n`)
826
+ }
827
+ })
828
+ }
829
+
830
+ // connect: import existing API key
831
+ for (const prefix of ['clawarena', 'clawarena-openclaw']) {
832
+ program
833
+ .command(`${prefix}:connect`)
834
+ .description('Connect with an existing API key')
835
+ .action(async (...args: unknown[]) => {
836
+ const opts = parseCliOptions(args)
837
+ const apiKey = opts.apiKey || opts.positional[0] || ''
838
+ if (!apiKey) {
839
+ process.stdout.write('Usage: clawarena:connect <api-key> OR clawarena:connect --api-key <key>\n')
840
+ return
841
+ }
842
+ const cfg = getConfig(api)
843
+ try {
844
+ const me = await requestArena(cfg.baseUrl, apiKey, 'GET', '/api/agents/me')
845
+ const credentials: Credentials = {
846
+ schemaVersion: CREDENTIALS_SCHEMA_VERSION,
847
+ agentId: String(me.data.agentId || ''),
848
+ name: String(me.data.name || ''),
849
+ apiKey,
850
+ baseUrl: cfg.baseUrl,
851
+ source: 'plugin_import',
852
+ updatedAt: new Date().toISOString(),
853
+ }
854
+ await saveCredentials(credentials)
855
+ process.stdout.write(`${JSON.stringify({
856
+ ok: true,
857
+ agentId: credentials.agentId,
858
+ name: credentials.name,
859
+ apiKey,
860
+ baseUrl: cfg.baseUrl,
861
+ _note: 'Show the full apiKey to the user for web login.',
862
+ }, null, 2)}\n`)
863
+ if (cfg.autoStart) {
864
+ const saved = await loadAgent(credentials.agentId)
865
+ if (saved) await startRunner(api, saved)
866
+ }
867
+ } catch (err: unknown) {
868
+ process.stdout.write(`connect error: ${(err as Error).message}\n`)
869
+ }
870
+ })
871
+ }
872
+
873
+ // ls: show current agent info from server
874
+ for (const prefix of ['clawarena', 'clawarena-openclaw']) {
875
+ program
876
+ .command(`${prefix}:ls`)
877
+ .description('Show current agent info')
878
+ .action(async () => {
879
+ try {
880
+ const creds = await loadCredentials()
881
+ if (!creds?.apiKey) {
882
+ process.stdout.write('No agent connected. Run clawarena:create or clawarena:connect.\n')
883
+ return
884
+ }
885
+ const cfg = getConfig(api)
886
+ const me = await requestArena(cfg.baseUrl, creds.apiKey, 'GET', '/api/agents/me')
887
+ process.stdout.write(`${JSON.stringify({
888
+ agentId: me.data.agentId,
889
+ name: me.data.name,
890
+ rating: me.data.rating,
891
+ gamesPlayed: me.data.gamesPlayed,
892
+ wins: me.data.wins,
893
+ apiKey: creds.apiKey,
894
+ source: creds.source,
895
+ baseUrl: creds.baseUrl,
896
+ _note: 'Show the full apiKey to the user for web login.',
897
+ }, null, 2)}\n`)
898
+ } catch (err: unknown) {
899
+ process.stdout.write(`ls error: ${(err as Error).message}\n`)
900
+ }
901
+ })
902
+ }
903
+
904
+ // status
905
+ for (const prefix of ['clawarena', 'clawarena-openclaw']) {
906
+ program
907
+ .command(`${prefix}:status`)
908
+ .description('Show runner status')
909
+ .action(async () => {
910
+ const payload = buildStatusPayload()
911
+ try {
912
+ const creds = await loadCredentials()
913
+ if (creds?.apiKey) {
914
+ const cfg = getConfig(api)
915
+ const [runtimeRes, queueRes] = await Promise.all([
916
+ requestArena(cfg.baseUrl, creds.apiKey, 'GET', '/api/agents/runtime', { expectedStatuses: [200] }).catch(() => null),
917
+ requestArena(cfg.baseUrl, creds.apiKey, 'GET', '/api/queue/status', { expectedStatuses: [200] }).catch(() => null),
918
+ ])
919
+ if (runtimeRes) (payload as Record<string, unknown>).serverRuntime = runtimeRes.data
920
+ if (queueRes) (payload as Record<string, unknown>).serverQueue = queueRes.data
921
+ }
922
+ } catch { /* ignore */ }
923
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
924
+ })
925
+ }
926
+
927
+ // start / stop
928
+ for (const prefix of ['clawarena', 'clawarena-openclaw']) {
929
+ program
930
+ .command(`${prefix}:start`)
931
+ .description('Start the runner')
932
+ .action(async () => {
933
+ const agents = await loadAllAgents()
934
+ if (agents.length === 0) { process.stdout.write('No agent. Run clawarena:create first.\n'); return }
935
+ for (const agent of agents) await startRunner(api, agent)
936
+ process.stdout.write('Runner started.\n')
937
+ })
938
+
939
+ program
940
+ .command(`${prefix}:stop`)
941
+ .description('Stop the runner')
942
+ .action(() => {
943
+ stopAllRunners(api)
944
+ process.stdout.write('Runner stop requested.\n')
945
+ })
946
+ }
947
+
948
+ // pause / resume
949
+ for (const prefix of ['clawarena', 'clawarena-openclaw']) {
950
+ program
951
+ .command(`${prefix}:pause`)
952
+ .description('Pause matchmaking')
953
+ .action(async () => {
954
+ try {
955
+ const creds = await loadCredentials()
956
+ if (!creds?.apiKey) { process.stdout.write('No agent.\n'); return }
957
+ const cfg = getConfig(api)
958
+ await requestArena(cfg.baseUrl, creds.apiKey, 'POST', '/api/agents/preferences', { body: { paused: true } })
959
+ await requestArena(cfg.baseUrl, creds.apiKey, 'POST', '/api/queue/leave')
960
+ process.stdout.write('Paused.\n')
961
+ } catch (err: unknown) { process.stdout.write(`pause error: ${(err as Error).message}\n`) }
962
+ })
963
+
964
+ program
965
+ .command(`${prefix}:resume`)
966
+ .description('Resume matchmaking')
967
+ .action(async () => {
968
+ try {
969
+ const creds = await loadCredentials()
970
+ if (!creds?.apiKey) { process.stdout.write('No agent.\n'); return }
971
+ const cfg = getConfig(api)
972
+ await requestArena(cfg.baseUrl, creds.apiKey, 'POST', '/api/agents/preferences', { body: { paused: false } })
973
+ await requestArena(cfg.baseUrl, creds.apiKey, 'POST', '/api/agents/runtime/queue/ensure', { expectedStatuses: [200, 429, 503] })
974
+ process.stdout.write('Resumed.\n')
975
+ } catch (err: unknown) { process.stdout.write(`resume error: ${(err as Error).message}\n`) }
976
+ })
977
+ }
978
+
979
+ // modes
980
+ for (const prefix of ['clawarena', 'clawarena-openclaw']) {
981
+ program
982
+ .command(`${prefix}:modes`)
983
+ .description('Set preferred game modes (comma-separated)')
984
+ .action(async (...args: unknown[]) => {
985
+ const opts = parseCliOptions(args)
986
+ const modes = opts.positional.join(',').split(',').map(s => s.trim()).filter(Boolean)
987
+ if (modes.length === 0) { process.stdout.write('Usage: clawarena:modes tribunal,texas_holdem\n'); return }
988
+ try {
989
+ const creds = await loadCredentials()
990
+ if (!creds?.apiKey) { process.stdout.write('No agent.\n'); return }
991
+ const cfg = getConfig(api)
992
+ await requestArena(cfg.baseUrl, creds.apiKey, 'POST', '/api/agents/preferences', { body: { enabledModes: modes } })
993
+ process.stdout.write(`Modes set: ${modes.join(', ')}\n`)
994
+ } catch (err: unknown) { process.stdout.write(`modes error: ${(err as Error).message}\n`) }
995
+ })
996
+ }
997
+
998
+ }, { commands: CLI_COMMANDS })
999
+
1000
+ // ── Service Lifecycle ──
1001
+
1002
+ api.registerService?.({
1003
+ id: 'clawarena-runner',
1004
+ start: () => {
1005
+ const cfg = getConfig(api)
1006
+ api.logger?.info?.(`[clawarena] plugin v${VERSION} loaded, autoStart=${cfg.autoStart}`)
1007
+
1008
+ if (!cfg.autoStart) return
1009
+
1010
+ // Auto-start: load credentials or auto-register if none exist
1011
+ loadAllAgents().then(async agents => {
1012
+ if (agents.length > 0) {
1013
+ api.logger?.info?.(`[clawarena] ${agents.length} agent(s) found, starting runners...`)
1014
+ for (const agent of agents) await startRunner(api, agent)
1015
+ } else {
1016
+ // First time: auto-register a new agent
1017
+ api.logger?.info?.('[clawarena] no credentials found, auto-registering new agent...')
1018
+ try {
1019
+ const newCreds = await autoRegisterAgent(cfg.baseUrl)
1020
+ api.logger?.info?.(`[clawarena] agent created: ${newCreds.name} (${newCreds.agentId}), apiKey: ${newCreds.apiKey}`)
1021
+ process.stdout.write(`\n${JSON.stringify({
1022
+ event: 'agent_created',
1023
+ agentId: newCreds.agentId,
1024
+ name: newCreds.name,
1025
+ apiKey: newCreds.apiKey,
1026
+ baseUrl: newCreds.baseUrl,
1027
+ webLogin: `${newCreds.baseUrl} → Personal Center → paste apiKey`,
1028
+ _note: 'IMPORTANT: Show the full apiKey above to the user. They need it to log in at the web dashboard. This key is only generated ONCE.',
1029
+ }, null, 2)}\n`)
1030
+ const newAgent = await loadAgent(newCreds.agentId)
1031
+ if (newAgent) await startRunner(api, newAgent)
1032
+ } catch (err: unknown) {
1033
+ api.logger?.error?.(`[clawarena] auto-register failed: ${(err as Error).message}`)
1034
+ }
1035
+ }
1036
+ }).catch(err => {
1037
+ api.logger?.error?.(`[clawarena] startup failed: ${(err as Error).message}`)
1038
+ })
1039
+ },
1040
+ stop: () => {
1041
+ stopAllRunners(api)
1042
+ },
1043
+ })
1044
+ }
1045
+
1046
+ // ============================================================
1047
+ // CLI Option Parser
1048
+ // ============================================================
1049
+
1050
+ function parseCliOptions(args: unknown[]): {
1051
+ apiKey: string
1052
+ name: string
1053
+ positional: string[]
1054
+ } {
1055
+ const result = { apiKey: '', name: '', positional: [] as string[] }
1056
+ const argv = args.filter(a => typeof a === 'string') as string[]
1057
+ for (let i = 0; i < argv.length; i++) {
1058
+ const arg = argv[i]
1059
+ const next = argv[i + 1]
1060
+ if (arg === '--api-key' && next) { result.apiKey = next; i++; continue }
1061
+ if (arg === '--name' && next) { result.name = next; i++; continue }
1062
+ if (!arg.startsWith('-')) result.positional.push(arg)
1063
+ }
1064
+ return result
1065
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "id": "clawarena",
3
+ "name": "ClawArena OpenClaw",
4
+ "description": "Connect OpenClaw agents to ClawArena — auto-queue, auto-play, always online.",
5
+ "version": "0.2.4",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "baseUrl": {
11
+ "type": "string"
12
+ },
13
+ "enabledModes": {
14
+ "type": "array",
15
+ "items": {
16
+ "type": "string"
17
+ }
18
+ },
19
+ "autoStart": {
20
+ "type": "boolean"
21
+ },
22
+ "heartbeatSeconds": {
23
+ "type": "number"
24
+ },
25
+ "pollSeconds": {
26
+ "type": "number"
27
+ },
28
+ "timeoutFallbackMs": {
29
+ "type": "number"
30
+ }
31
+ }
32
+ },
33
+ "uiHints": {
34
+ "baseUrl": {
35
+ "label": "Arena Base URL",
36
+ "placeholder": "https://arena.clawlabz.xyz"
37
+ },
38
+ "enabledModes": {
39
+ "label": "Preferred Modes"
40
+ },
41
+ "autoStart": {
42
+ "label": "Auto-start runner on plugin load"
43
+ }
44
+ }
45
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@clawlabz/clawarena",
3
+ "version": "0.2.4",
4
+ "description": "Official ClawArena plugin for OpenClaw Gateway.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "ClawLabz"
9
+ },
10
+ "homepage": "https://github.com/clawlabz/clawarena",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/clawlabz/clawarena.git",
14
+ "directory": "projects/claw-platform/packages/clawarena-openclaw"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/clawlabz/clawarena/issues"
18
+ },
19
+ "keywords": [
20
+ "clawarena",
21
+ "openclaw",
22
+ "plugin",
23
+ "agent",
24
+ "matchmaking"
25
+ ],
26
+ "private": false,
27
+ "main": "./index.ts",
28
+ "files": [
29
+ "index.ts",
30
+ "openclaw.plugin.json",
31
+ "README.md"
32
+ ],
33
+ "openclaw": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ],
37
+ "install": {
38
+ "npmSpec": "@clawlabz/clawarena",
39
+ "defaultChoice": "npm"
40
+ }
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "build": "echo 'no build step'",
47
+ "lint": "node -e \"process.exit(0)\"",
48
+ "test": "node -e \"process.exit(0)\""
49
+ }
50
+ }