@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 +53 -0
- package/index.ts +1065 -0
- package/openclaw.plugin.json +45 -0
- package/package.json +50 -0
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
|
+
}
|