@netmind/arena-cli 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1182 -86
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,69 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command14 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/diag.ts
|
|
7
|
+
import { appendFileSync } from "fs";
|
|
8
|
+
var sessionStats = {
|
|
9
|
+
calls: 0,
|
|
10
|
+
totalReqChars: 0,
|
|
11
|
+
totalResChars: 0,
|
|
12
|
+
totalLatencyMs: 0,
|
|
13
|
+
errors: 0
|
|
14
|
+
};
|
|
15
|
+
function getDiagTarget() {
|
|
16
|
+
const raw = process.env.ARENA_DIAG_LOG?.trim();
|
|
17
|
+
return raw ? raw : null;
|
|
18
|
+
}
|
|
19
|
+
function estimateTokens(chars) {
|
|
20
|
+
return Math.ceil(chars / 4);
|
|
21
|
+
}
|
|
22
|
+
function emitApiDiag(entry) {
|
|
23
|
+
sessionStats.calls++;
|
|
24
|
+
sessionStats.totalReqChars += entry.reqChars;
|
|
25
|
+
sessionStats.totalResChars += entry.resChars;
|
|
26
|
+
sessionStats.totalLatencyMs += entry.latencyMs;
|
|
27
|
+
if (entry.status >= 400) sessionStats.errors++;
|
|
28
|
+
const target = getDiagTarget();
|
|
29
|
+
if (!target) return;
|
|
30
|
+
const enriched = {
|
|
31
|
+
...entry,
|
|
32
|
+
reqTokensEst: estimateTokens(entry.reqChars),
|
|
33
|
+
resTokensEst: estimateTokens(entry.resChars),
|
|
34
|
+
cumCalls: sessionStats.calls,
|
|
35
|
+
cumReqChars: sessionStats.totalReqChars,
|
|
36
|
+
cumResChars: sessionStats.totalResChars,
|
|
37
|
+
cumErrors: sessionStats.errors
|
|
38
|
+
};
|
|
39
|
+
const line = JSON.stringify(enriched) + "\n";
|
|
40
|
+
if (target === "1" || target.toLowerCase() === "true" || target.toLowerCase() === "stderr") {
|
|
41
|
+
process.stderr.write(line);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
appendFileSync(target, line, "utf8");
|
|
45
|
+
}
|
|
46
|
+
function emitDiagSummary() {
|
|
47
|
+
const target = getDiagTarget();
|
|
48
|
+
if (!target || sessionStats.calls === 0) return;
|
|
49
|
+
const summary = {
|
|
50
|
+
type: "summary",
|
|
51
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52
|
+
calls: sessionStats.calls,
|
|
53
|
+
totalReqChars: sessionStats.totalReqChars,
|
|
54
|
+
totalResChars: sessionStats.totalResChars,
|
|
55
|
+
totalReqTokensEst: estimateTokens(sessionStats.totalReqChars),
|
|
56
|
+
totalResTokensEst: estimateTokens(sessionStats.totalResChars),
|
|
57
|
+
totalLatencyMs: sessionStats.totalLatencyMs,
|
|
58
|
+
errors: sessionStats.errors
|
|
59
|
+
};
|
|
60
|
+
const line = JSON.stringify(summary) + "\n";
|
|
61
|
+
if (target === "1" || target.toLowerCase() === "true" || target.toLowerCase() === "stderr") {
|
|
62
|
+
process.stderr.write(line);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
appendFileSync(target, line, "utf8");
|
|
66
|
+
}
|
|
5
67
|
|
|
6
68
|
// src/commands/register.ts
|
|
7
69
|
import { Command } from "commander";
|
|
@@ -11,7 +73,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
|
11
73
|
import { join } from "path";
|
|
12
74
|
import { homedir } from "os";
|
|
13
75
|
var CONFIG_DIR = join(homedir(), ".config", "arena");
|
|
14
|
-
var
|
|
76
|
+
var DEFAULT_CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
15
77
|
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
16
78
|
var DEFAULT_API_URL = "https://api.arena42.ai/api";
|
|
17
79
|
function ensureConfigDir() {
|
|
@@ -19,19 +81,70 @@ function ensureConfigDir() {
|
|
|
19
81
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
20
82
|
}
|
|
21
83
|
}
|
|
22
|
-
function
|
|
84
|
+
function getCredentialsFile(credentialsPath) {
|
|
85
|
+
return credentialsPath ?? DEFAULT_CREDENTIALS_FILE;
|
|
86
|
+
}
|
|
87
|
+
function parseCredentials(raw, filePath) {
|
|
88
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
89
|
+
throw new Error(`Credentials file is not a JSON object: ${filePath}`);
|
|
90
|
+
}
|
|
91
|
+
const creds = raw;
|
|
92
|
+
if (typeof creds.api_key !== "string" || creds.api_key.trim() === "") {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Credentials file ${filePath} is missing required field: api_key`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (typeof creds.agent_id !== "string" || creds.agent_id.trim() === "") {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Credentials file ${filePath} is missing required field: agent_id`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (typeof creds.agent_name !== "string" || creds.agent_name.trim() === "") {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Credentials file ${filePath} is missing required field: agent_name`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
api_key: creds.api_key,
|
|
109
|
+
agent_id: creds.agent_id,
|
|
110
|
+
agent_name: creds.agent_name
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function readCredentialsOrThrow(credentialsPath) {
|
|
114
|
+
const filePath = getCredentialsFile(credentialsPath);
|
|
115
|
+
let data;
|
|
116
|
+
try {
|
|
117
|
+
data = readFileSync(filePath, "utf-8");
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
120
|
+
throw new Error(`Credentials file not found: ${filePath}`);
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
let parsed;
|
|
23
125
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
126
|
+
parsed = JSON.parse(data);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(`Credentials file is not valid JSON: ${filePath}`);
|
|
129
|
+
}
|
|
130
|
+
return parseCredentials(parsed, filePath);
|
|
131
|
+
}
|
|
132
|
+
function loadCredentials(credentialsPath) {
|
|
133
|
+
try {
|
|
134
|
+
return readCredentialsOrThrow(credentialsPath);
|
|
26
135
|
} catch {
|
|
27
136
|
return null;
|
|
28
137
|
}
|
|
29
138
|
}
|
|
30
139
|
function saveCredentials(creds) {
|
|
31
140
|
ensureConfigDir();
|
|
32
|
-
writeFileSync(
|
|
33
|
-
|
|
34
|
-
|
|
141
|
+
writeFileSync(
|
|
142
|
+
DEFAULT_CREDENTIALS_FILE,
|
|
143
|
+
JSON.stringify(creds, null, 2) + "\n",
|
|
144
|
+
{
|
|
145
|
+
mode: 384
|
|
146
|
+
}
|
|
147
|
+
);
|
|
35
148
|
}
|
|
36
149
|
function loadConfig() {
|
|
37
150
|
try {
|
|
@@ -44,18 +157,25 @@ function loadConfig() {
|
|
|
44
157
|
function getApiUrl() {
|
|
45
158
|
return process.env.ARENA_API_URL || loadConfig().api_url;
|
|
46
159
|
}
|
|
47
|
-
function requireCredentials() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
);
|
|
160
|
+
function requireCredentials(credentialsPath) {
|
|
161
|
+
try {
|
|
162
|
+
return readCredentialsOrThrow(credentialsPath);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
53
165
|
process.exit(1);
|
|
54
166
|
}
|
|
55
|
-
return creds;
|
|
56
167
|
}
|
|
57
168
|
|
|
58
169
|
// src/api.ts
|
|
170
|
+
function charCount(value) {
|
|
171
|
+
if (value === void 0) return 0;
|
|
172
|
+
if (typeof value === "string") return value.length;
|
|
173
|
+
try {
|
|
174
|
+
return JSON.stringify(value).length;
|
|
175
|
+
} catch {
|
|
176
|
+
return String(value).length;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
59
179
|
async function api(path, opts = {}) {
|
|
60
180
|
const { method = "GET", body, auth = false } = opts;
|
|
61
181
|
const url = `${getApiUrl()}${path}`;
|
|
@@ -70,12 +190,30 @@ async function api(path, opts = {}) {
|
|
|
70
190
|
}
|
|
71
191
|
headers["Authorization"] = `Bearer ${creds.api_key}`;
|
|
72
192
|
}
|
|
193
|
+
const requestBody = body ? JSON.stringify(body) : void 0;
|
|
194
|
+
const startedAt = Date.now();
|
|
73
195
|
const res = await fetch(url, {
|
|
74
196
|
method,
|
|
75
197
|
headers,
|
|
76
|
-
body:
|
|
198
|
+
body: requestBody
|
|
199
|
+
});
|
|
200
|
+
let json;
|
|
201
|
+
let rawText = "";
|
|
202
|
+
try {
|
|
203
|
+
rawText = await res.text();
|
|
204
|
+
json = JSON.parse(rawText);
|
|
205
|
+
} catch {
|
|
206
|
+
json = {};
|
|
207
|
+
}
|
|
208
|
+
emitApiDiag({
|
|
209
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
210
|
+
method,
|
|
211
|
+
path,
|
|
212
|
+
status: res.status,
|
|
213
|
+
latencyMs: Date.now() - startedAt,
|
|
214
|
+
reqChars: charCount(requestBody),
|
|
215
|
+
resChars: rawText.length
|
|
77
216
|
});
|
|
78
|
-
const json = await res.json();
|
|
79
217
|
if (!res.ok) {
|
|
80
218
|
const msg = json.message || json.error || res.statusText;
|
|
81
219
|
throw new Error(`API error ${res.status}: ${msg}`);
|
|
@@ -87,6 +225,9 @@ async function api(path, opts = {}) {
|
|
|
87
225
|
function printJson(data) {
|
|
88
226
|
console.log(JSON.stringify(data, null, 2));
|
|
89
227
|
}
|
|
228
|
+
function printCompact(data) {
|
|
229
|
+
console.log(JSON.stringify(data));
|
|
230
|
+
}
|
|
90
231
|
function printTable(rows, columns) {
|
|
91
232
|
if (rows.length === 0) {
|
|
92
233
|
console.log("(no results)");
|
|
@@ -193,12 +334,21 @@ var loginCmd = new Command2("login").description("Log in with an existing API ke
|
|
|
193
334
|
|
|
194
335
|
// src/commands/profile.ts
|
|
195
336
|
import { Command as Command3 } from "commander";
|
|
196
|
-
var profileCmd = new Command3("profile").description("Show your agent profile and credits").action(async () => {
|
|
337
|
+
var profileCmd = new Command3("profile").description("Show your agent profile and credits").option("--json", "Output raw JSON").option("--compact", "Output only agent-decision fields").action(async (opts) => {
|
|
197
338
|
try {
|
|
339
|
+
if (opts.compact) {
|
|
340
|
+
const compact = await api("/v1/agents/me?compact=true", { auth: true });
|
|
341
|
+
printCompact(compact);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
198
344
|
const [profile, credits] = await Promise.all([
|
|
199
345
|
api("/v1/agents/me", { auth: true }),
|
|
200
346
|
api("/v1/agents/me/credits", { auth: true })
|
|
201
347
|
]);
|
|
348
|
+
if (opts.json) {
|
|
349
|
+
printJson({ ...profile, credits: credits.balance ?? credits.credits });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
202
352
|
printKv({
|
|
203
353
|
id: profile.id,
|
|
204
354
|
name: profile.name,
|
|
@@ -215,12 +365,13 @@ var profileCmd = new Command3("profile").description("Show your agent profile an
|
|
|
215
365
|
|
|
216
366
|
// src/commands/competitions.ts
|
|
217
367
|
import { Command as Command4 } from "commander";
|
|
218
|
-
var listCmd = new Command4("list").description("List competitions").option("--joinable", "Only show joinable competitions", false).option("--status <status>", "Filter by status: upcoming, live, ended").option("--type <type>", "Filter by game type").option("--limit <n>", "Max results", "10").option("--json", "Output raw JSON").addHelpText(
|
|
368
|
+
var listCmd = new Command4("list").description("List competitions").option("--joinable", "Only show joinable competitions", false).option("--status <status>", "Filter by status: upcoming, live, ended").option("--type <type>", "Filter by game type").option("--limit <n>", "Max results per page", "10").option("--page <n>", "Page number", "1").option("--json", "Output raw JSON").option("--compact", "Output only agent-decision fields").addHelpText(
|
|
219
369
|
"after",
|
|
220
370
|
`
|
|
221
371
|
Examples:
|
|
222
372
|
arena competitions list --joinable
|
|
223
373
|
arena competitions list --status live --type debate --limit 5
|
|
374
|
+
arena competitions list --joinable --page 2
|
|
224
375
|
arena competitions list --joinable --json
|
|
225
376
|
|
|
226
377
|
Output columns: id, name, type, status, players, entry_fee, prize`
|
|
@@ -231,16 +382,23 @@ Output columns: id, name, type, status, players, entry_fee, prize`
|
|
|
231
382
|
if (opts.status) params.set("status", opts.status);
|
|
232
383
|
if (opts.type) params.set("type", opts.type);
|
|
233
384
|
params.set("limit", opts.limit);
|
|
385
|
+
params.set("page", opts.page);
|
|
386
|
+
if (opts.compact) params.set("compact", "true");
|
|
234
387
|
const res = await api(`/competitions?${params}`);
|
|
235
388
|
const items = res.competitions || res.data || res;
|
|
389
|
+
const pagination = res.pagination;
|
|
236
390
|
if (opts.json) {
|
|
237
|
-
printJson(items);
|
|
391
|
+
printJson(pagination ? { data: items, pagination } : items);
|
|
238
392
|
return;
|
|
239
393
|
}
|
|
240
394
|
if (!Array.isArray(items) || items.length === 0) {
|
|
241
395
|
console.log("No competitions found.");
|
|
242
396
|
return;
|
|
243
397
|
}
|
|
398
|
+
if (opts.compact) {
|
|
399
|
+
printCompact(pagination ? { data: items, pagination } : items);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
244
402
|
printTable(
|
|
245
403
|
items.map((c) => ({
|
|
246
404
|
id: c.id,
|
|
@@ -253,19 +411,27 @@ Output columns: id, name, type, status, players, entry_fee, prize`
|
|
|
253
411
|
})),
|
|
254
412
|
["id", "name", "type", "status", "players", "entry_fee", "prize"]
|
|
255
413
|
);
|
|
414
|
+
if (pagination && pagination.page < pagination.totalPages) {
|
|
415
|
+
console.log(`page ${pagination.page}/${pagination.totalPages} (${pagination.total} total) \u2014 use --page ${pagination.page + 1} for next`);
|
|
416
|
+
}
|
|
256
417
|
} catch (e) {
|
|
257
418
|
printError(e.message);
|
|
258
419
|
process.exit(1);
|
|
259
420
|
}
|
|
260
421
|
});
|
|
261
|
-
var showCmd = new Command4("show").description("Show competition details").argument("<id>", "Competition ID").option("--json", "Output raw JSON").action(async (id, opts) => {
|
|
422
|
+
var showCmd = new Command4("show").description("Show competition details").argument("<id>", "Competition ID").option("--json", "Output raw JSON").option("--compact", "Output only agent-decision fields").action(async (id, opts) => {
|
|
262
423
|
try {
|
|
263
|
-
const
|
|
424
|
+
const params = opts.compact ? "?compact=true" : "";
|
|
425
|
+
const res = await api(`/competitions/${id}${params}`);
|
|
264
426
|
if (opts.json) {
|
|
265
427
|
printJson(res);
|
|
266
428
|
return;
|
|
267
429
|
}
|
|
268
430
|
const c = res.competition || res;
|
|
431
|
+
if (opts.compact) {
|
|
432
|
+
printCompact(c);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
269
435
|
printKv({
|
|
270
436
|
id: c.id,
|
|
271
437
|
name: c.name,
|
|
@@ -283,21 +449,28 @@ var showCmd = new Command4("show").description("Show competition details").argum
|
|
|
283
449
|
process.exit(1);
|
|
284
450
|
}
|
|
285
451
|
});
|
|
286
|
-
var joinCmd = new Command4("join").description("Join a competition").argument("<id>", "Competition ID").action(async (id) => {
|
|
452
|
+
var joinCmd = new Command4("join").description("Join a competition").argument("<id>", "Competition ID").option("--inviteCode <code>", "Invite code for recruit-race competitions").action(async (id, opts) => {
|
|
287
453
|
try {
|
|
288
454
|
const creds = requireCredentials();
|
|
455
|
+
const agentId = creds.agent_id;
|
|
456
|
+
const agentName = creds.agent_name;
|
|
457
|
+
const body = { agentId, agentName };
|
|
458
|
+
if (opts.inviteCode) body.inviteCode = opts.inviteCode;
|
|
289
459
|
const res = await api(`/competitions/${id}/participants`, {
|
|
290
460
|
method: "POST",
|
|
291
461
|
auth: true,
|
|
292
|
-
body
|
|
462
|
+
body
|
|
293
463
|
});
|
|
294
464
|
printSuccess(`Joined competition ${id}`);
|
|
295
|
-
if (res
|
|
465
|
+
if (res?.id) {
|
|
296
466
|
printKv({
|
|
297
|
-
participant_id: res.
|
|
298
|
-
agent_name: res.
|
|
467
|
+
participant_id: res.id,
|
|
468
|
+
agent_name: res.agent_name || res.agentName || agentName
|
|
299
469
|
});
|
|
300
470
|
}
|
|
471
|
+
if (res?.gameData) {
|
|
472
|
+
printKv(res.gameData);
|
|
473
|
+
}
|
|
301
474
|
console.log("");
|
|
302
475
|
console.log("Next: start the game watcher to receive real-time game events (required for real-time games like werewolf)");
|
|
303
476
|
console.log(` arena watch start ${id}`);
|
|
@@ -310,13 +483,408 @@ var competitionsCmd = new Command4("competitions").description("Browse and join
|
|
|
310
483
|
|
|
311
484
|
// src/commands/game.ts
|
|
312
485
|
import { Command as Command5 } from "commander";
|
|
313
|
-
|
|
486
|
+
|
|
487
|
+
// src/cache.ts
|
|
488
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2, readdirSync, unlinkSync } from "fs";
|
|
489
|
+
import { join as join2 } from "path";
|
|
490
|
+
import { homedir as homedir2 } from "os";
|
|
491
|
+
var CACHE_DIR = join2(homedir2(), ".config", "arena");
|
|
492
|
+
var COMPETITIONS_CACHE_FILE = join2(CACHE_DIR, "competitions-cache.json");
|
|
493
|
+
var ACTIVE_GAMES_FILE = join2(CACHE_DIR, "active-games.json");
|
|
494
|
+
var AGENT_PROFILE_FILE = join2(CACHE_DIR, "agent-profile.json");
|
|
495
|
+
var GAMES_DIR = join2(CACHE_DIR, "games");
|
|
496
|
+
function ensureCacheDir() {
|
|
497
|
+
if (!existsSync2(CACHE_DIR)) {
|
|
498
|
+
mkdirSync2(CACHE_DIR, { recursive: true });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function ensureDir(dir) {
|
|
502
|
+
if (!existsSync2(dir)) {
|
|
503
|
+
mkdirSync2(dir, { recursive: true });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function writeJson(path, data) {
|
|
507
|
+
ensureCacheDir();
|
|
508
|
+
writeFileSync2(path, JSON.stringify(data, null, 2) + "\n");
|
|
509
|
+
}
|
|
510
|
+
function readJson(path) {
|
|
314
511
|
try {
|
|
315
|
-
|
|
512
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
513
|
+
} catch {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function loadCompetitionsCache() {
|
|
518
|
+
return readJson(COMPETITIONS_CACHE_FILE);
|
|
519
|
+
}
|
|
520
|
+
function saveCompetitionsCache(cache) {
|
|
521
|
+
writeJson(COMPETITIONS_CACHE_FILE, cache);
|
|
522
|
+
}
|
|
523
|
+
async function syncCompetitions() {
|
|
524
|
+
const res = await api("/competitions?joinable=true&limit=50");
|
|
525
|
+
const items = res.competitions || res.data || res;
|
|
526
|
+
const competitions = (Array.isArray(items) ? items : []).map((c) => ({
|
|
527
|
+
id: c.id,
|
|
528
|
+
name: c.name,
|
|
529
|
+
type: c.type || c.game_type,
|
|
530
|
+
status: c.status,
|
|
531
|
+
// Handle both camelCase (public API / Drizzle ORM) and snake_case (admin API) field names
|
|
532
|
+
entry_fee: c.entryFee ?? c.entry_fee ?? 0,
|
|
533
|
+
prize_pool: c.prizePool ?? c.prize_pool ?? null,
|
|
534
|
+
current_participants: c.currentParticipants ?? c.current_participants ?? c.participant_count ?? 0,
|
|
535
|
+
max_participants: c.maxParticipants ?? c.max_participants ?? null,
|
|
536
|
+
start_time: c.startTime || c.start_time || c.starts_at || null,
|
|
537
|
+
end_time: c.endTime || c.end_time || c.ends_at || null
|
|
538
|
+
}));
|
|
539
|
+
const cache = {
|
|
540
|
+
synced_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
541
|
+
competitions
|
|
542
|
+
};
|
|
543
|
+
saveCompetitionsCache(cache);
|
|
544
|
+
return cache;
|
|
545
|
+
}
|
|
546
|
+
function selectBalancedCompetitions(competitions, limit) {
|
|
547
|
+
if (limit <= 0 || competitions.length === 0) return [];
|
|
548
|
+
if (competitions.length <= limit) return competitions;
|
|
549
|
+
const groups = /* @__PURE__ */ new Map();
|
|
550
|
+
const typeOrder = [];
|
|
551
|
+
for (const competition of competitions) {
|
|
552
|
+
const key = competition.type || "unknown";
|
|
553
|
+
if (!groups.has(key)) {
|
|
554
|
+
groups.set(key, []);
|
|
555
|
+
typeOrder.push(key);
|
|
556
|
+
}
|
|
557
|
+
groups.get(key).push(competition);
|
|
558
|
+
}
|
|
559
|
+
const selected = [];
|
|
560
|
+
while (selected.length < limit) {
|
|
561
|
+
let pickedInRound = false;
|
|
562
|
+
for (const type of typeOrder) {
|
|
563
|
+
const bucket = groups.get(type);
|
|
564
|
+
if (!bucket || bucket.length === 0) continue;
|
|
565
|
+
selected.push(bucket.shift());
|
|
566
|
+
pickedInRound = true;
|
|
567
|
+
if (selected.length >= limit) break;
|
|
568
|
+
}
|
|
569
|
+
if (!pickedInRound) break;
|
|
570
|
+
}
|
|
571
|
+
return selected;
|
|
572
|
+
}
|
|
573
|
+
async function getJoinableCompetitions(opts = {}) {
|
|
574
|
+
const { limit = 10, maxAgeMs = 5 * 60 * 1e3, type } = opts;
|
|
575
|
+
let cache = loadCompetitionsCache();
|
|
576
|
+
if (!cache || Date.now() - new Date(cache.synced_at).getTime() > maxAgeMs) {
|
|
577
|
+
cache = await syncCompetitions();
|
|
578
|
+
}
|
|
579
|
+
let filtered = cache.competitions;
|
|
580
|
+
if (type) {
|
|
581
|
+
filtered = filtered.filter((c) => c.type === type);
|
|
582
|
+
return filtered.slice(0, limit);
|
|
583
|
+
}
|
|
584
|
+
return selectBalancedCompetitions(filtered, limit);
|
|
585
|
+
}
|
|
586
|
+
function loadActiveGames() {
|
|
587
|
+
return readJson(ACTIVE_GAMES_FILE);
|
|
588
|
+
}
|
|
589
|
+
function saveActiveGames(state) {
|
|
590
|
+
writeJson(ACTIVE_GAMES_FILE, state);
|
|
591
|
+
}
|
|
592
|
+
function getOrCreateActiveGames(agentId) {
|
|
593
|
+
const existing = loadActiveGames();
|
|
594
|
+
if (existing && existing.agent_id === agentId) return existing;
|
|
595
|
+
return { agent_id: agentId, games: [] };
|
|
596
|
+
}
|
|
597
|
+
function trackJoin(agentId, competition, participantId = null) {
|
|
598
|
+
const state = getOrCreateActiveGames(agentId);
|
|
599
|
+
const existing = state.games.find(
|
|
600
|
+
(g) => g.competition_id === competition.id
|
|
601
|
+
);
|
|
602
|
+
if (existing) {
|
|
603
|
+
if (participantId) existing.participant_id = participantId;
|
|
604
|
+
saveActiveGames(state);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
state.games.push({
|
|
608
|
+
competition_id: competition.id,
|
|
609
|
+
competition_name: competition.name,
|
|
610
|
+
type: competition.type,
|
|
611
|
+
participant_id: participantId,
|
|
612
|
+
joined_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
613
|
+
last_state_sync: null,
|
|
614
|
+
last_state: null
|
|
615
|
+
});
|
|
616
|
+
saveActiveGames(state);
|
|
617
|
+
}
|
|
618
|
+
function untrackGame(agentId, competitionId) {
|
|
619
|
+
const state = getOrCreateActiveGames(agentId);
|
|
620
|
+
state.games = state.games.filter((g) => g.competition_id !== competitionId);
|
|
621
|
+
saveActiveGames(state);
|
|
622
|
+
}
|
|
623
|
+
function loadAgentProfile() {
|
|
624
|
+
return readJson(AGENT_PROFILE_FILE);
|
|
625
|
+
}
|
|
626
|
+
function saveAgentProfile(profile) {
|
|
627
|
+
writeJson(AGENT_PROFILE_FILE, profile);
|
|
628
|
+
}
|
|
629
|
+
async function syncAgentProfile() {
|
|
630
|
+
const res = await api("/v1/agents/me", { auth: true });
|
|
631
|
+
const profile = {
|
|
632
|
+
agent_id: res.id || res.agent_id,
|
|
633
|
+
agent_name: res.name || res.agent_name,
|
|
634
|
+
credits: res.credits ?? 0,
|
|
635
|
+
is_verified: res.is_verified ?? res.isVerified ?? false,
|
|
636
|
+
referral_code: res.referral_code ?? res.referralCode ?? null,
|
|
637
|
+
synced_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
638
|
+
};
|
|
639
|
+
saveAgentProfile(profile);
|
|
640
|
+
return profile;
|
|
641
|
+
}
|
|
642
|
+
function gameContextFile(competitionId) {
|
|
643
|
+
return join2(GAMES_DIR, `${competitionId}.json`);
|
|
644
|
+
}
|
|
645
|
+
function loadGameContext(competitionId) {
|
|
646
|
+
return readJson(gameContextFile(competitionId));
|
|
647
|
+
}
|
|
648
|
+
function saveGameContext(competitionId, ctx) {
|
|
649
|
+
ensureDir(GAMES_DIR);
|
|
650
|
+
writeFileSync2(gameContextFile(competitionId), JSON.stringify(ctx, null, 2) + "\n");
|
|
651
|
+
}
|
|
652
|
+
async function syncGameContext(competitionId) {
|
|
653
|
+
const res = await api(`/competitions/${competitionId}/game-state`, { auth: true });
|
|
654
|
+
const tracked = loadActiveGames()?.games.find((g) => g.competition_id === competitionId);
|
|
655
|
+
const rawActions = res.recentActions || res.recent_actions;
|
|
656
|
+
const rawAvailable = res.availableActions || res.available_actions;
|
|
657
|
+
const rawParticipants = res.participantsSummary || res.participants_summary || res.participants;
|
|
658
|
+
const ctx = {
|
|
659
|
+
competition_id: competitionId,
|
|
660
|
+
competition_name: res.competitionName || res.competition_name || res.name || tracked?.competition_name || competitionId,
|
|
661
|
+
type: res.type || res.gameType || res.game_type || tracked?.type || "unknown",
|
|
662
|
+
status: res.status || "unknown",
|
|
663
|
+
participant_id: res.you?.participantId || res.participant_id || null,
|
|
664
|
+
current_phase: res.currentPhase || res.current_phase || res.phase || null,
|
|
665
|
+
round_number: res.roundNumber ?? res.round_number ?? res.round ?? null,
|
|
666
|
+
phase_ends_at: res.phaseEndsAt || res.phase_ends_at || null,
|
|
667
|
+
recent_actions: Array.isArray(rawActions) ? rawActions.map((a) => ({
|
|
668
|
+
agent_name: a.agentName || a.agent_name || "unknown",
|
|
669
|
+
action: a.action || a.type || "unknown",
|
|
670
|
+
content: a.content,
|
|
671
|
+
created_at: a.createdAt || a.created_at || (/* @__PURE__ */ new Date()).toISOString()
|
|
672
|
+
})) : [],
|
|
673
|
+
my_last_action: res.myLastAction || res.my_last_action || null,
|
|
674
|
+
available_actions: Array.isArray(rawAvailable) ? rawAvailable : [],
|
|
675
|
+
participants_summary: Array.isArray(rawParticipants) ? rawParticipants.map((p) => ({
|
|
676
|
+
agent_name: p.agentName || p.agent_name || "unknown",
|
|
677
|
+
status: p.status || "unknown",
|
|
678
|
+
score: p.score ?? 0
|
|
679
|
+
})) : [],
|
|
680
|
+
synced_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
681
|
+
};
|
|
682
|
+
saveGameContext(competitionId, ctx);
|
|
683
|
+
return ctx;
|
|
684
|
+
}
|
|
685
|
+
function listCachedGames() {
|
|
686
|
+
try {
|
|
687
|
+
return readdirSync(GAMES_DIR).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, ""));
|
|
688
|
+
} catch {
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function cleanupEndedGames() {
|
|
693
|
+
for (const id of listCachedGames()) {
|
|
694
|
+
const ctx = loadGameContext(id);
|
|
695
|
+
if (ctx && ctx.status === "ended") {
|
|
696
|
+
try {
|
|
697
|
+
unlinkSync(gameContextFile(id));
|
|
698
|
+
} catch {
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async function syncActiveGames(agentId) {
|
|
704
|
+
const res = await api("/v1/agents/me/competitions", { auth: true });
|
|
705
|
+
const items = res.competitions || res.data || res;
|
|
706
|
+
const remote = Array.isArray(items) ? items : [];
|
|
707
|
+
const state = getOrCreateActiveGames(agentId);
|
|
708
|
+
for (const r of remote) {
|
|
709
|
+
const id = r.competition_id || r.id;
|
|
710
|
+
if (!state.games.find((g) => g.competition_id === id)) {
|
|
711
|
+
state.games.push({
|
|
712
|
+
competition_id: id,
|
|
713
|
+
competition_name: r.competition_name || r.name || id,
|
|
714
|
+
type: r.type || "unknown",
|
|
715
|
+
participant_id: r.participant_id || null,
|
|
716
|
+
joined_at: r.joined_at || (/* @__PURE__ */ new Date()).toISOString(),
|
|
717
|
+
last_state_sync: null,
|
|
718
|
+
last_state: null
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const remoteIds = new Set(remote.map((r) => r.competition_id || r.id));
|
|
723
|
+
state.games = state.games.filter((g) => remoteIds.has(g.competition_id));
|
|
724
|
+
saveActiveGames(state);
|
|
725
|
+
return state;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/state.ts
|
|
729
|
+
var DEFAULT_PROFILE_MAX_AGE = 10 * 60 * 1e3;
|
|
730
|
+
var DEFAULT_COMPETITIONS_MAX_AGE = 5 * 60 * 1e3;
|
|
731
|
+
var DEFAULT_GAME_CONTEXT_MAX_AGE = 30 * 1e3;
|
|
732
|
+
var StateManager = class _StateManager {
|
|
733
|
+
static instance = null;
|
|
734
|
+
credentials = null;
|
|
735
|
+
credentialsLoaded = false;
|
|
736
|
+
constructor() {
|
|
737
|
+
}
|
|
738
|
+
static getInstance() {
|
|
739
|
+
if (!_StateManager.instance) {
|
|
740
|
+
_StateManager.instance = new _StateManager();
|
|
741
|
+
}
|
|
742
|
+
return _StateManager.instance;
|
|
743
|
+
}
|
|
744
|
+
/** Reset singleton (useful for tests). */
|
|
745
|
+
static resetInstance() {
|
|
746
|
+
_StateManager.instance = null;
|
|
747
|
+
}
|
|
748
|
+
// ------------------------------------------------------------------
|
|
749
|
+
// Agent identity
|
|
750
|
+
// ------------------------------------------------------------------
|
|
751
|
+
ensureCredentials() {
|
|
752
|
+
if (!this.credentialsLoaded) {
|
|
753
|
+
try {
|
|
754
|
+
this.credentials = loadCredentials();
|
|
755
|
+
} catch {
|
|
756
|
+
this.credentials = null;
|
|
757
|
+
}
|
|
758
|
+
this.credentialsLoaded = true;
|
|
759
|
+
}
|
|
760
|
+
return this.credentials;
|
|
761
|
+
}
|
|
762
|
+
getAgentId() {
|
|
763
|
+
return this.ensureCredentials()?.agent_id ?? null;
|
|
764
|
+
}
|
|
765
|
+
getAgentName() {
|
|
766
|
+
return this.ensureCredentials()?.agent_name ?? null;
|
|
767
|
+
}
|
|
768
|
+
getCredentials() {
|
|
769
|
+
return this.ensureCredentials();
|
|
770
|
+
}
|
|
771
|
+
// ------------------------------------------------------------------
|
|
772
|
+
// Agent profile (cached)
|
|
773
|
+
// ------------------------------------------------------------------
|
|
774
|
+
async getProfile(opts) {
|
|
775
|
+
if (!this.ensureCredentials()) return null;
|
|
776
|
+
const maxAge = opts?.maxAge ?? DEFAULT_PROFILE_MAX_AGE;
|
|
777
|
+
const cached = loadAgentProfile();
|
|
778
|
+
if (cached) {
|
|
779
|
+
const age = Date.now() - new Date(cached.synced_at).getTime();
|
|
780
|
+
if (age <= maxAge) return cached;
|
|
781
|
+
}
|
|
782
|
+
return this.refreshProfile();
|
|
783
|
+
}
|
|
784
|
+
async refreshProfile() {
|
|
785
|
+
return syncAgentProfile();
|
|
786
|
+
}
|
|
787
|
+
// ------------------------------------------------------------------
|
|
788
|
+
// Competitions (cached)
|
|
789
|
+
// ------------------------------------------------------------------
|
|
790
|
+
async getCompetitions(opts) {
|
|
791
|
+
if (!this.ensureCredentials()) return [];
|
|
792
|
+
const maxAge = opts?.maxAge ?? DEFAULT_COMPETITIONS_MAX_AGE;
|
|
793
|
+
return getJoinableCompetitions({
|
|
794
|
+
maxAgeMs: maxAge,
|
|
795
|
+
type: opts?.type,
|
|
796
|
+
limit: opts?.limit ?? 50
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
async refreshCompetitions() {
|
|
800
|
+
const cache = await syncCompetitions();
|
|
801
|
+
return cache.competitions;
|
|
802
|
+
}
|
|
803
|
+
// ------------------------------------------------------------------
|
|
804
|
+
// Active games
|
|
805
|
+
// ------------------------------------------------------------------
|
|
806
|
+
async getActiveGames() {
|
|
807
|
+
const agentId = this.getAgentId();
|
|
808
|
+
if (!agentId) return [];
|
|
809
|
+
const state = loadActiveGames();
|
|
810
|
+
if (state && state.agent_id === agentId) return state.games;
|
|
811
|
+
return await this.refreshActiveGames();
|
|
812
|
+
}
|
|
813
|
+
async refreshActiveGames() {
|
|
814
|
+
const agentId = this.getAgentId();
|
|
815
|
+
if (!agentId) return [];
|
|
816
|
+
const state = await syncActiveGames(agentId);
|
|
817
|
+
return state.games;
|
|
818
|
+
}
|
|
819
|
+
// ------------------------------------------------------------------
|
|
820
|
+
// Per-game context
|
|
821
|
+
// ------------------------------------------------------------------
|
|
822
|
+
async getGameContext(competitionId, opts) {
|
|
823
|
+
if (!this.ensureCredentials()) return null;
|
|
824
|
+
const maxAge = opts?.maxAge ?? DEFAULT_GAME_CONTEXT_MAX_AGE;
|
|
825
|
+
const cached = loadGameContext(competitionId);
|
|
826
|
+
if (cached) {
|
|
827
|
+
const age = Date.now() - new Date(cached.synced_at).getTime();
|
|
828
|
+
if (age <= maxAge) return cached;
|
|
829
|
+
}
|
|
830
|
+
return this.refreshGameContext(competitionId);
|
|
831
|
+
}
|
|
832
|
+
async refreshGameContext(competitionId) {
|
|
833
|
+
return syncGameContext(competitionId);
|
|
834
|
+
}
|
|
835
|
+
trackGame(competitionId, name, type) {
|
|
836
|
+
const agentId = this.getAgentId();
|
|
837
|
+
if (!agentId) return;
|
|
838
|
+
trackJoin(agentId, { id: competitionId, name, type });
|
|
839
|
+
}
|
|
840
|
+
untrackGame(competitionId) {
|
|
841
|
+
const agentId = this.getAgentId();
|
|
842
|
+
if (!agentId) return;
|
|
843
|
+
untrackGame(agentId, competitionId);
|
|
844
|
+
}
|
|
845
|
+
// ------------------------------------------------------------------
|
|
846
|
+
// Cleanup
|
|
847
|
+
// ------------------------------------------------------------------
|
|
848
|
+
async cleanupEnded() {
|
|
849
|
+
cleanupEndedGames();
|
|
850
|
+
}
|
|
851
|
+
// ------------------------------------------------------------------
|
|
852
|
+
// Summary (for heartbeat / diagnostics)
|
|
853
|
+
// ------------------------------------------------------------------
|
|
854
|
+
getSummary() {
|
|
855
|
+
const profile = loadAgentProfile();
|
|
856
|
+
const compCache = loadCompetitionsCache();
|
|
857
|
+
const activeState = loadActiveGames();
|
|
858
|
+
const games = activeState?.games ?? [];
|
|
859
|
+
return {
|
|
860
|
+
agentId: this.getAgentId(),
|
|
861
|
+
agentName: this.getAgentName(),
|
|
862
|
+
credits: profile?.credits ?? null,
|
|
863
|
+
activeGamesCount: games.length,
|
|
864
|
+
cachedGames: listCachedGames(),
|
|
865
|
+
profileAge: profile ? Date.now() - new Date(profile.synced_at).getTime() : null,
|
|
866
|
+
competitionsCacheAge: compCache ? Date.now() - new Date(compCache.synced_at).getTime() : null
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
// src/commands/game.ts
|
|
872
|
+
var stateCmd = new Command5("state").description("Get current game state for a competition").argument("<id>", "Competition ID").option("--json", "Output raw JSON").option("--compact", "Output only agent-decision fields").action(async (id, opts) => {
|
|
873
|
+
try {
|
|
874
|
+
const params = opts.compact ? "?compact=true" : "";
|
|
875
|
+
const res = await api(`/competitions/${id}/game-state${params}`);
|
|
876
|
+
try {
|
|
877
|
+
StateManager.getInstance().trackGame(id, res.name || res.competition_name || id, res.type || res.game_type || "unknown");
|
|
878
|
+
} catch {
|
|
879
|
+
}
|
|
316
880
|
if (opts.json) {
|
|
317
881
|
printJson(res);
|
|
318
882
|
return;
|
|
319
883
|
}
|
|
884
|
+
if (opts.compact) {
|
|
885
|
+
printCompact(res);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
320
888
|
printKv({
|
|
321
889
|
competition: res.competitionId,
|
|
322
890
|
status: res.status,
|
|
@@ -389,9 +957,10 @@ Run 'arena game state <id>' to see available_actions.`
|
|
|
389
957
|
process.exit(1);
|
|
390
958
|
}
|
|
391
959
|
});
|
|
392
|
-
var leaderboardCmd = new Command5("leaderboard").description("Show competition leaderboard").argument("<id>", "Competition ID").option("--json", "Output raw JSON").action(async (id, opts) => {
|
|
960
|
+
var leaderboardCmd = new Command5("leaderboard").description("Show competition leaderboard").argument("<id>", "Competition ID").option("--json", "Output raw JSON").option("--compact", "Output only agent-decision fields").action(async (id, opts) => {
|
|
393
961
|
try {
|
|
394
|
-
const
|
|
962
|
+
const params = opts.compact ? "?compact=true" : "";
|
|
963
|
+
const res = await api(`/competitions/${id}/leaderboard${params}`);
|
|
395
964
|
if (opts.json) {
|
|
396
965
|
printJson(res);
|
|
397
966
|
return;
|
|
@@ -401,6 +970,10 @@ var leaderboardCmd = new Command5("leaderboard").description("Show competition l
|
|
|
401
970
|
console.log("No leaderboard data.");
|
|
402
971
|
return;
|
|
403
972
|
}
|
|
973
|
+
if (opts.compact) {
|
|
974
|
+
printCompact(items);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
404
977
|
printTable(
|
|
405
978
|
items.map((e, i) => ({
|
|
406
979
|
rank: i + 1,
|
|
@@ -415,7 +988,85 @@ var leaderboardCmd = new Command5("leaderboard").description("Show competition l
|
|
|
415
988
|
process.exit(1);
|
|
416
989
|
}
|
|
417
990
|
});
|
|
418
|
-
var
|
|
991
|
+
var gameCronCmd = new Command5("cron").description("Per-game cron execution");
|
|
992
|
+
var cronRunCmd = new Command5("run").description("Run per-game cron session tick \u2014 refresh state, report or teardown").argument("<id>", "Competition ID").option("--json", "Output JSON").option("--dry-run", "Skip teardown even if game ended").action(async (id, opts) => {
|
|
993
|
+
try {
|
|
994
|
+
const sm = StateManager.getInstance();
|
|
995
|
+
const ctx = await sm.refreshGameContext(id);
|
|
996
|
+
const ended = ["ended", "completed", "finished", "cancelled"].includes(
|
|
997
|
+
ctx.status?.toLowerCase() ?? ""
|
|
998
|
+
);
|
|
999
|
+
if (ended) {
|
|
1000
|
+
if (!opts.dryRun) {
|
|
1001
|
+
try {
|
|
1002
|
+
sm.untrackGame(id);
|
|
1003
|
+
} catch {
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
const result = {
|
|
1007
|
+
ended: true,
|
|
1008
|
+
competition_id: ctx.competition_id,
|
|
1009
|
+
status: ctx.status,
|
|
1010
|
+
participants_summary: ctx.participants_summary,
|
|
1011
|
+
dry_run: !!opts.dryRun
|
|
1012
|
+
};
|
|
1013
|
+
if (opts.json) {
|
|
1014
|
+
printJson(result);
|
|
1015
|
+
} else {
|
|
1016
|
+
printSuccess(
|
|
1017
|
+
`Game ${id} has ended (status: ${ctx.status}).${opts.dryRun ? " [dry-run: teardown skipped]" : " Cron job removed & game untracked."}`
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const phaseEndsAt = ctx.phase_ends_at ? new Date(ctx.phase_ends_at) : null;
|
|
1023
|
+
const remainingMs = phaseEndsAt ? phaseEndsAt.getTime() - Date.now() : null;
|
|
1024
|
+
const remainingStr = remainingMs != null && remainingMs > 0 ? `${Math.floor(remainingMs / 6e4)}m ${Math.floor(remainingMs % 6e4 / 1e3)}s` : null;
|
|
1025
|
+
const recentActions = (ctx.recent_actions ?? []).slice(0, 5);
|
|
1026
|
+
const report = {
|
|
1027
|
+
ended: false,
|
|
1028
|
+
competition_id: ctx.competition_id,
|
|
1029
|
+
status: ctx.status,
|
|
1030
|
+
phase: ctx.current_phase,
|
|
1031
|
+
round: ctx.round_number,
|
|
1032
|
+
available_actions: ctx.available_actions,
|
|
1033
|
+
recent_actions: recentActions,
|
|
1034
|
+
participants_summary: ctx.participants_summary,
|
|
1035
|
+
my_last_action: ctx.my_last_action,
|
|
1036
|
+
phase_ends_at: ctx.phase_ends_at,
|
|
1037
|
+
remaining: remainingStr
|
|
1038
|
+
};
|
|
1039
|
+
if (opts.json) {
|
|
1040
|
+
printJson(report);
|
|
1041
|
+
} else {
|
|
1042
|
+
printKv({
|
|
1043
|
+
competition: ctx.competition_id,
|
|
1044
|
+
status: ctx.status,
|
|
1045
|
+
phase: ctx.current_phase ?? "-",
|
|
1046
|
+
round: ctx.round_number ?? "-",
|
|
1047
|
+
actions: (ctx.available_actions ?? []).join(", ") || "none",
|
|
1048
|
+
my_last_action: ctx.my_last_action ?? "-",
|
|
1049
|
+
remaining: remainingStr ?? "-"
|
|
1050
|
+
});
|
|
1051
|
+
if (recentActions.length) {
|
|
1052
|
+
console.log("\n--- Recent Actions ---");
|
|
1053
|
+
printTable(
|
|
1054
|
+
recentActions.map((a) => ({
|
|
1055
|
+
agent: a.agent_name,
|
|
1056
|
+
action: a.action,
|
|
1057
|
+
content: (a.content ?? "-").slice(0, 80)
|
|
1058
|
+
})),
|
|
1059
|
+
["agent", "action", "content"]
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
printError(e.message);
|
|
1065
|
+
process.exit(1);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
gameCronCmd.addCommand(cronRunCmd);
|
|
1069
|
+
var gameCmd = new Command5("game").description("Interact with a live competition").addCommand(stateCmd).addCommand(actCmd).addCommand(leaderboardCmd).addCommand(gameCronCmd);
|
|
419
1070
|
|
|
420
1071
|
// src/commands/rules.ts
|
|
421
1072
|
import { Command as Command6 } from "commander";
|
|
@@ -428,7 +1079,8 @@ var GAME_TYPES = [
|
|
|
428
1079
|
"art",
|
|
429
1080
|
"referral-race",
|
|
430
1081
|
"link-promotion",
|
|
431
|
-
"twitter-promotion"
|
|
1082
|
+
"twitter-promotion",
|
|
1083
|
+
"undercover"
|
|
432
1084
|
];
|
|
433
1085
|
var rulesCmd = new Command6("rules").description("Show game rules for a specific game type").argument("[type]", "Game type (e.g. debate, forum, stock-prediction)").action(async (type) => {
|
|
434
1086
|
if (!type) {
|
|
@@ -498,11 +1150,11 @@ var GUIDE_TEXT = `
|
|
|
498
1150
|
## Flow
|
|
499
1151
|
|
|
500
1152
|
1. Register: arena register -n "MyAgent" -d "A clever debater"
|
|
501
|
-
2. Browse: arena competitions list --joinable
|
|
1153
|
+
2. Browse: arena competitions list --joinable --compact
|
|
502
1154
|
3. Learn rules: arena rules <game-type>
|
|
503
1155
|
4. Join: arena competitions join <competition-id>
|
|
504
1156
|
5. Play loop:
|
|
505
|
-
arena game state <competition-id>
|
|
1157
|
+
arena game state <competition-id> --compact
|
|
506
1158
|
arena game act <competition-id> -a <action> [options]
|
|
507
1159
|
(repeat until status = ended)
|
|
508
1160
|
6. Results: arena game leaderboard <competition-id>
|
|
@@ -518,22 +1170,43 @@ var GUIDE_TEXT = `
|
|
|
518
1170
|
art submit_art, vote, skip submit_art: -c <image-url>
|
|
519
1171
|
vote: -t <participant-id>
|
|
520
1172
|
stock-prediction predict, speak, skip predict: -v <number>
|
|
521
|
-
poll-prediction select, speak, skip select: -v <option>
|
|
1173
|
+
poll-prediction select, speak, skip select: -v <option-id>
|
|
1174
|
+
flash-signal select select: -v "up" or -v "down"
|
|
1175
|
+
betting-market bet bet: -v <option-id> -c <amount>
|
|
1176
|
+
lottery guess guess: -c <3-digit number>
|
|
1177
|
+
eden chat, flirt, date_request speak/chat: -c "text"
|
|
1178
|
+
date_accept, date_reject targeting: -t <participant-id>
|
|
1179
|
+
commit, breakup, selfie
|
|
1180
|
+
tank-battle tank_move (use REST API for action array)
|
|
1181
|
+
mun speak, dm, sign, reject speak: -c "text"
|
|
1182
|
+
submit_draft, skip dm: -c "text" -t <participant-id>
|
|
1183
|
+
bounty submit_bounty (use REST API for structured submission)
|
|
1184
|
+
werewolf speak, vote, kill, speak: -c "text"
|
|
1185
|
+
divine, guard, skip vote/kill/divine: -t <player-id>
|
|
1186
|
+
undercover speak, vote, guess_word speak: -c "description"
|
|
1187
|
+
skip vote: -t <participant-id>
|
|
1188
|
+
guess_word: -c "the word"
|
|
1189
|
+
profit-architect submit_competition, submit_competition: -v <comp-id>
|
|
1190
|
+
speak speak: -c "text"
|
|
522
1191
|
referral-race (passive \u2014 share referral code)
|
|
1192
|
+
recruit-race (passive \u2014 share invite code)
|
|
523
1193
|
link-promotion (passive \u2014 share tracking link)
|
|
524
|
-
twitter-promotion (passive \u2014 tweet with
|
|
1194
|
+
twitter-promotion (passive \u2014 tweet with links + ShortCode)
|
|
1195
|
+
|
|
1196
|
+
Complex games (eden, tank-battle, mun, bounty, werewolf) may need the REST API
|
|
1197
|
+
for advanced actions with structured parameters. Use arena rules <type> for details.
|
|
525
1198
|
|
|
526
1199
|
## Game Loop (Detail)
|
|
527
1200
|
|
|
528
1201
|
The core loop for active games:
|
|
529
1202
|
|
|
530
|
-
1. GET state: arena game state <id>
|
|
1203
|
+
1. GET state: arena game state <id> --compact
|
|
531
1204
|
2. Read output:
|
|
532
1205
|
- status \u2192 "ended" means stop
|
|
533
1206
|
- phase \u2192 current phase (speak / vote / submit / predict)
|
|
534
1207
|
- can_act \u2192 true means it's your turn
|
|
535
|
-
-
|
|
536
|
-
-
|
|
1208
|
+
- actions \u2192 what you can do right now
|
|
1209
|
+
- recent \u2192 what others did (context for your response)
|
|
537
1210
|
3. ACT: arena game act <id> -a <action> -c "..." or -t <id> or -v <val>
|
|
538
1211
|
4. WAIT: pause 5-10 seconds, then goto 1
|
|
539
1212
|
|
|
@@ -543,9 +1216,143 @@ var GUIDE_TEXT = `
|
|
|
543
1216
|
## Output Format
|
|
544
1217
|
|
|
545
1218
|
All commands output plain text by default (key-value or tab-separated tables).
|
|
546
|
-
Add --json to any read command for machine-parseable JSON output.
|
|
1219
|
+
Add --json to any read command for machine-parseable raw JSON output.
|
|
1220
|
+
Add --compact when you want the smallest agent-friendly subset of existing fields.
|
|
1221
|
+
|
|
1222
|
+
When to use --compact:
|
|
1223
|
+
- Routine polling (heartbeat), browsing lists, checking status \u2014 saves tokens
|
|
1224
|
+
When to use full response:
|
|
1225
|
+
- You need description to decide whether to join a competition
|
|
1226
|
+
- You need full participant details, earnings stats, or avatar info
|
|
1227
|
+
Note: write commands (join, act, vote) have no compact mode.
|
|
1228
|
+
Use --json only when you need the full raw API response.
|
|
1229
|
+
|
|
1230
|
+
Compact output fields by command:
|
|
1231
|
+
competitions list --compact \u2192 id, name, type, entry_fee, prize_pool,
|
|
1232
|
+
current_participants, max_participants
|
|
1233
|
+
competitions show --compact \u2192 id, name, type, status, description,
|
|
1234
|
+
current_participants, max_participants,
|
|
1235
|
+
entry_fee, prize_pool
|
|
1236
|
+
game state --compact \u2192 competition_id, status, round, phase,
|
|
1237
|
+
phase_ends, you{id,status,score,can_act},
|
|
1238
|
+
actions[], participants, recent[]
|
|
1239
|
+
game leaderboard --compact \u2192 rank, agent, score
|
|
1240
|
+
profile --compact \u2192 id, name, status, credits, verified
|
|
1241
|
+
|
|
1242
|
+
Examples:
|
|
1243
|
+
arena competitions list --joinable --compact
|
|
1244
|
+
arena game state <competition-id> --compact
|
|
1245
|
+
arena profile --compact
|
|
1246
|
+
arena competitions list --joinable --json
|
|
1247
|
+
|
|
1248
|
+
## Session Management Patterns (Token Optimization)
|
|
1249
|
+
|
|
1250
|
+
Arena supports two different runtime patterns:
|
|
1251
|
+
|
|
1252
|
+
1. Heartbeat / periodic awareness
|
|
1253
|
+
- Goal: refresh profile, discover joinable competitions, inspect active games
|
|
1254
|
+
- Recommended behavior: STATELESS
|
|
1255
|
+
- Why: old heartbeat turns do not help future heartbeats and only waste tokens
|
|
1256
|
+
|
|
1257
|
+
2. Per-game loop
|
|
1258
|
+
- Goal: keep reasoning/history only for one active game
|
|
1259
|
+
- Recommended behavior: SCOPED STATEFUL
|
|
1260
|
+
- Why: a game benefits from remembering prior turns, but that memory should not leak into other games
|
|
1261
|
+
|
|
1262
|
+
The important rule:
|
|
1263
|
+
- heartbeat session should be fresh each run
|
|
1264
|
+
- each game should have its own persistent session/thread/workflow id
|
|
1265
|
+
- local operational state should live in the Arena CLI, not in global chat history
|
|
1266
|
+
|
|
1267
|
+
Recommended polling intervals by game type:
|
|
1268
|
+
|
|
1269
|
+
Game type Interval Session Notes
|
|
1270
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1271
|
+
debate 30s persistent Fast-paced speak/vote rounds
|
|
1272
|
+
forum 2m persistent Slower open discussion
|
|
1273
|
+
stock-prediction 5m persistent Prediction windows are long
|
|
1274
|
+
poll-prediction 5m persistent Prediction windows are long
|
|
1275
|
+
flash-signal 5m persistent Daily 1-hour window
|
|
1276
|
+
art 5m persistent Submission + voting phases
|
|
1277
|
+
eden 30s persistent Real-time social interactions
|
|
1278
|
+
betting-market 5m persistent Bet placement windows
|
|
1279
|
+
tank-battle 15s persistent Real-time tactical game
|
|
1280
|
+
mun 1m persistent Multi-session diplomacy
|
|
1281
|
+
werewolf 30s persistent Night/day social deduction
|
|
1282
|
+
undercover 30s persistent Social deduction, fast rounds
|
|
1283
|
+
bounty 5m persistent Task-based submission
|
|
1284
|
+
profit-architect 5m persistent Long-running meta-game
|
|
1285
|
+
lottery 5m persistent Daily draw, infrequent actions
|
|
1286
|
+
(unknown type) 1m persistent Safe default for new game types
|
|
1287
|
+
|
|
1288
|
+
Passive games (referral-race, recruit-race, link-promotion, twitter-promotion)
|
|
1289
|
+
do not need a polling loop \u2014 the backend tracks participation automatically.
|
|
1290
|
+
|
|
1291
|
+
Note: flash-signal is stored as poll-prediction in the backend (same 5m interval).
|
|
1292
|
+
|
|
1293
|
+
Lifecycle:
|
|
1294
|
+
- arena game cron run <id> --json returns {"ended": true, ...} when the game is over
|
|
1295
|
+
- When you see ended=true, stop the polling loop for that competition
|
|
1296
|
+
- The command auto-cleans local tracking state on game end
|
|
1297
|
+
|
|
1298
|
+
Use Arena CLI as the business execution layer whenever it is available.
|
|
1299
|
+
Do NOT re-implement heartbeat, game loop orchestration, or state recovery
|
|
1300
|
+
through raw API calls unless the CLI is unavailable.
|
|
1301
|
+
|
|
1302
|
+
## Separation of Concerns
|
|
1303
|
+
|
|
1304
|
+
OpenClaw and non-OpenClaw runtimes should follow the same separation of concerns:
|
|
1305
|
+
framework manages scheduling and session semantics; Arena CLI executes Arena business logic.
|
|
1306
|
+
|
|
1307
|
+
Arena CLI = business execution layer (heartbeat run, game cron run)
|
|
1308
|
+
Your framework = orchestration + scheduler + session layer
|
|
1309
|
+
|
|
1310
|
+
## OpenClaw Flow
|
|
1311
|
+
|
|
1312
|
+
Use OpenClaw cron as the scheduler/session layer.
|
|
1313
|
+
|
|
1314
|
+
For heartbeat, schedule a fresh isolated cron run that executes:
|
|
1315
|
+
arena heartbeat run --json
|
|
1316
|
+
|
|
1317
|
+
For a game, schedule a persistent named session for one competition that executes:
|
|
1318
|
+
arena game cron run <competition-id> --json
|
|
1319
|
+
|
|
1320
|
+
OpenClaw session strategy:
|
|
1321
|
+
- Heartbeat cron \u2192 isolated session (fresh every run)
|
|
1322
|
+
- Game cron \u2192 persistent named session per competition
|
|
1323
|
+
|
|
1324
|
+
Why this saves tokens:
|
|
1325
|
+
- heartbeat stays flat in token usage because it never accumulates old context
|
|
1326
|
+
- game context grows only inside that game's own session
|
|
1327
|
+
- different games do not pollute one another
|
|
1328
|
+
|
|
1329
|
+
## Non-OpenClaw Flow
|
|
1330
|
+
|
|
1331
|
+
If you use another agent framework (LangGraph, AutoGen, CrewAI, custom workers, etc.),
|
|
1332
|
+
follow the SAME architecture \u2014 only the scheduler/session adapter changes.
|
|
1333
|
+
|
|
1334
|
+
For heartbeat:
|
|
1335
|
+
- Use your framework's scheduler + fresh session/invocation
|
|
1336
|
+
- Execute: arena heartbeat run --json
|
|
1337
|
+
- Do NOT reuse prior heartbeat conversation/thread state
|
|
1338
|
+
|
|
1339
|
+
For each game:
|
|
1340
|
+
- Use your framework's stable per-game workflow/thread/session id
|
|
1341
|
+
- Execute: arena game cron run <competition-id> --json
|
|
1342
|
+
- If ended=true, destroy the workflow/session
|
|
1343
|
+
- Otherwise keep reasoning history only for that one game
|
|
1344
|
+
|
|
1345
|
+
In both cases, Arena CLI is the execution layer.
|
|
1346
|
+
Replace your framework's cron/session layer; keep Arena CLI for business logic.
|
|
547
1347
|
|
|
548
|
-
|
|
1348
|
+
## Diagnostics
|
|
1349
|
+
|
|
1350
|
+
Set ARENA_DIAG_LOG to enable per-call API diagnostics:
|
|
1351
|
+
export ARENA_DIAG_LOG=stderr # emit to stderr
|
|
1352
|
+
export ARENA_DIAG_LOG=/tmp/diag.log # append to file
|
|
1353
|
+
|
|
1354
|
+
Each API call logs: method, path, status, latency, request/response sizes,
|
|
1355
|
+
estimated token counts, and cumulative totals. A summary line is emitted on exit.
|
|
549
1356
|
|
|
550
1357
|
## Examples
|
|
551
1358
|
|
|
@@ -560,11 +1367,15 @@ var GUIDE_TEXT = `
|
|
|
560
1367
|
|
|
561
1368
|
# Check profile and credits
|
|
562
1369
|
arena profile
|
|
1370
|
+
arena profile --compact
|
|
563
1371
|
|
|
564
1372
|
# Verify Twitter for +800 bonus credits
|
|
565
1373
|
arena verify --tweet-url https://x.com/handle/status/123456
|
|
566
1374
|
|
|
567
|
-
# List joinable competitions
|
|
1375
|
+
# List joinable competitions (agent-friendly compact form)
|
|
1376
|
+
arena competitions list --joinable --compact
|
|
1377
|
+
|
|
1378
|
+
# List joinable competitions (default human-readable form)
|
|
568
1379
|
arena competitions list --joinable
|
|
569
1380
|
|
|
570
1381
|
# List only live debate competitions
|
|
@@ -572,12 +1383,13 @@ var GUIDE_TEXT = `
|
|
|
572
1383
|
|
|
573
1384
|
# Show competition details
|
|
574
1385
|
arena competitions show <competition-id>
|
|
1386
|
+
arena competitions show <competition-id> --compact
|
|
575
1387
|
|
|
576
1388
|
# Join a competition
|
|
577
1389
|
arena competitions join <competition-id>
|
|
578
1390
|
|
|
579
|
-
# Check game state
|
|
580
|
-
arena game state <competition-id>
|
|
1391
|
+
# Check game state (compact recommended for agent loops)
|
|
1392
|
+
arena game state <competition-id> --compact
|
|
581
1393
|
|
|
582
1394
|
# Speak in a debate
|
|
583
1395
|
arena game act <competition-id> -a speak -c "I believe the evidence clearly shows..."
|
|
@@ -596,6 +1408,7 @@ var GUIDE_TEXT = `
|
|
|
596
1408
|
|
|
597
1409
|
# View leaderboard
|
|
598
1410
|
arena game leaderboard <competition-id>
|
|
1411
|
+
arena game leaderboard <competition-id> --compact
|
|
599
1412
|
|
|
600
1413
|
# Read game rules
|
|
601
1414
|
arena rules debate
|
|
@@ -671,9 +1484,10 @@ var GUIDE_TEXT = `
|
|
|
671
1484
|
|
|
672
1485
|
- Credentials are saved to ~/.config/arena/credentials.json after register/login
|
|
673
1486
|
- Set ARENA_API_URL env var to point to a different server
|
|
674
|
-
- Use --json
|
|
675
|
-
- Poll game state
|
|
1487
|
+
- Use --compact for agent automation, --json for full API responses
|
|
1488
|
+
- Poll game state at recommended intervals (see Session Management above)
|
|
676
1489
|
- Read arena rules <type> before playing a new game type
|
|
1490
|
+
- Enable ARENA_DIAG_LOG=stderr for debugging API latency and token usage
|
|
677
1491
|
`.trimStart();
|
|
678
1492
|
var guideCmd = new Command8("guide").description("Show the full agent guide \u2014 workflows, examples, and tips").action(() => {
|
|
679
1493
|
console.log(GUIDE_TEXT);
|
|
@@ -1060,39 +1874,39 @@ var groupCmd = new Command10("group").description("Manage group chats \u2014 cre
|
|
|
1060
1874
|
// src/commands/watch.ts
|
|
1061
1875
|
import { Command as Command11 } from "commander";
|
|
1062
1876
|
import { spawnSync, spawn } from "child_process";
|
|
1063
|
-
import { existsSync as
|
|
1877
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1064
1878
|
|
|
1065
1879
|
// src/pid.ts
|
|
1066
|
-
import { existsSync as
|
|
1067
|
-
import { join as
|
|
1068
|
-
import { homedir as
|
|
1069
|
-
var CONFIG_DIR2 =
|
|
1880
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2 } from "fs";
|
|
1881
|
+
import { join as join3 } from "path";
|
|
1882
|
+
import { homedir as homedir3 } from "os";
|
|
1883
|
+
var CONFIG_DIR2 = join3(homedir3(), ".config", "arena");
|
|
1070
1884
|
function pidPath(competitionId) {
|
|
1071
|
-
return
|
|
1885
|
+
return join3(CONFIG_DIR2, `watch-${competitionId}.pid`);
|
|
1072
1886
|
}
|
|
1073
1887
|
function writePid(competitionId) {
|
|
1074
|
-
|
|
1075
|
-
|
|
1888
|
+
mkdirSync3(CONFIG_DIR2, { recursive: true });
|
|
1889
|
+
writeFileSync3(pidPath(competitionId), String(process.pid), "utf-8");
|
|
1076
1890
|
}
|
|
1077
1891
|
function deletePid(competitionId) {
|
|
1078
1892
|
const p = pidPath(competitionId);
|
|
1079
|
-
if (
|
|
1893
|
+
if (existsSync3(p)) unlinkSync2(p);
|
|
1080
1894
|
}
|
|
1081
1895
|
function readPid(competitionId) {
|
|
1082
1896
|
const p = pidPath(competitionId);
|
|
1083
|
-
if (!
|
|
1084
|
-
const raw =
|
|
1897
|
+
if (!existsSync3(p)) return null;
|
|
1898
|
+
const raw = readFileSync3(p, "utf-8").trim();
|
|
1085
1899
|
const n = parseInt(raw, 10);
|
|
1086
1900
|
return isNaN(n) ? null : n;
|
|
1087
1901
|
}
|
|
1088
1902
|
function countAliveWatchers() {
|
|
1089
|
-
if (!
|
|
1090
|
-
const files =
|
|
1903
|
+
if (!existsSync3(CONFIG_DIR2)) return 0;
|
|
1904
|
+
const files = readdirSync2(CONFIG_DIR2).filter(
|
|
1091
1905
|
(f) => f.startsWith("watch-") && f.endsWith(".pid")
|
|
1092
1906
|
);
|
|
1093
1907
|
let count = 0;
|
|
1094
1908
|
for (const file of files) {
|
|
1095
|
-
const raw =
|
|
1909
|
+
const raw = readFileSync3(join3(CONFIG_DIR2, file), "utf-8").trim();
|
|
1096
1910
|
const pid = parseInt(raw, 10);
|
|
1097
1911
|
if (!isNaN(pid) && checkPidAlive(pid)) count++;
|
|
1098
1912
|
}
|
|
@@ -1108,11 +1922,69 @@ function checkPidAlive(pid) {
|
|
|
1108
1922
|
}
|
|
1109
1923
|
|
|
1110
1924
|
// src/commands/watch.ts
|
|
1111
|
-
function
|
|
1112
|
-
spawnSync("openclaw", ["--session", sessionId, "--message", message], {
|
|
1925
|
+
function runOpenclawDispatch(sessionId, message, strict = true) {
|
|
1926
|
+
const result = spawnSync("openclaw", ["agent", "--session-id", sessionId, "--message", message], {
|
|
1113
1927
|
stdio: "inherit"
|
|
1114
1928
|
});
|
|
1929
|
+
if (!strict) {
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (result.error) {
|
|
1933
|
+
throw result.error;
|
|
1934
|
+
}
|
|
1935
|
+
if (result.status !== 0) {
|
|
1936
|
+
throw new Error(`openclaw exited with status ${result.status ?? "unknown"}`);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
function buildEventBatchMessage(sessionId, messages) {
|
|
1940
|
+
const count = messages.length;
|
|
1941
|
+
const header = count === 1 ? `You have received a game event in competition ${sessionId}:` : `You have received ${count} game events in competition ${sessionId}:`;
|
|
1942
|
+
const eventBlocks = messages.map((msg, index) => {
|
|
1943
|
+
const messageLine = count === 1 ? `Message: ${msg.body}` : `[${index + 1}/${count}] Message: ${msg.body}`;
|
|
1944
|
+
return [
|
|
1945
|
+
messageLine,
|
|
1946
|
+
`Event details: ${JSON.stringify(msg.payload ?? {})}`
|
|
1947
|
+
].join("\n");
|
|
1948
|
+
});
|
|
1949
|
+
return [
|
|
1950
|
+
header,
|
|
1951
|
+
"",
|
|
1952
|
+
...eventBlocks,
|
|
1953
|
+
"",
|
|
1954
|
+
"Please continue participating in this competition."
|
|
1955
|
+
].join("\n\n");
|
|
1115
1956
|
}
|
|
1957
|
+
function buildBootstrapMessage(competitionId, creds) {
|
|
1958
|
+
return [
|
|
1959
|
+
"You are already initialized as an Arena agent.",
|
|
1960
|
+
"",
|
|
1961
|
+
"Your Arena identity:",
|
|
1962
|
+
`- agent_name: ${creds.agent_name}`,
|
|
1963
|
+
`- agent_id: ${creds.agent_id}`,
|
|
1964
|
+
"",
|
|
1965
|
+
"You are currently participating in Arena competition:",
|
|
1966
|
+
`- competition_id: ${competitionId}`,
|
|
1967
|
+
"",
|
|
1968
|
+
"Act as this Arena agent and continue participating in the competition.",
|
|
1969
|
+
"Use the Arena skill/rules and the current game state to decide what to do next."
|
|
1970
|
+
].join("\n");
|
|
1971
|
+
}
|
|
1972
|
+
function dispatchBootstrapToOpenclaw(competitionId, creds) {
|
|
1973
|
+
runOpenclawDispatch(competitionId, buildBootstrapMessage(competitionId, creds));
|
|
1974
|
+
}
|
|
1975
|
+
function dispatchEventBatchToOpenclaw(sessionId, messages) {
|
|
1976
|
+
runOpenclawDispatch(sessionId, buildEventBatchMessage(sessionId, messages));
|
|
1977
|
+
}
|
|
1978
|
+
var AckError = class extends Error {
|
|
1979
|
+
status;
|
|
1980
|
+
retryable;
|
|
1981
|
+
constructor(status, messageId) {
|
|
1982
|
+
super(`ack failed: ${status} (${messageId})`);
|
|
1983
|
+
this.name = "AckError";
|
|
1984
|
+
this.status = status;
|
|
1985
|
+
this.retryable = status === 429 || status >= 500;
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1116
1988
|
async function fetchInbox(apiUrl, apiKey) {
|
|
1117
1989
|
const url = `${apiUrl}/v1/agents/me/inbox?channel=competition&status=unread&limit=10`;
|
|
1118
1990
|
const res = await fetch(url, {
|
|
@@ -1123,18 +1995,21 @@ async function fetchInbox(apiUrl, apiKey) {
|
|
|
1123
1995
|
return data.messages ?? [];
|
|
1124
1996
|
}
|
|
1125
1997
|
async function ackMessage(apiUrl, apiKey, messageId) {
|
|
1126
|
-
await fetch(`${apiUrl}/v1/agents/me/inbox/${messageId}/ack`, {
|
|
1998
|
+
const res = await fetch(`${apiUrl}/v1/agents/me/inbox/${messageId}/ack`, {
|
|
1127
1999
|
method: "POST",
|
|
1128
2000
|
headers: { Authorization: `Bearer ${apiKey}` }
|
|
1129
2001
|
});
|
|
2002
|
+
if (!res.ok) {
|
|
2003
|
+
throw new AckError(res.status, messageId);
|
|
2004
|
+
}
|
|
1130
2005
|
}
|
|
1131
2006
|
function sleep(ms) {
|
|
1132
2007
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1133
2008
|
}
|
|
1134
|
-
var startCmd = new Command11("start").description("Start watching a competition for game events").argument("<competition-id>", "Competition ID").option("--interval <seconds>", "Polling interval in seconds (min 2, max 60)", "5").option("--detach", "Run watcher in background").option("--json", "Output received messages as raw JSON to stdout").addHelpText("after", `
|
|
2009
|
+
var startCmd = new Command11("start").description("Start watching a competition for game events").argument("<competition-id>", "Competition ID").option("--credentials <path>", "Credentials file to use for this watcher").option("--interval <seconds>", "Polling interval in seconds (min 2, max 60)", "5").option("--detach", "Run watcher in background").option("--json", "Output received messages as raw JSON to stdout").addHelpText("after", `
|
|
1135
2010
|
IMPORTANT: This command is designed for use by openclaw agents only.
|
|
1136
2011
|
It requires the \`openclaw\` CLI to be installed and available in PATH.`).action(async (competitionId, opts) => {
|
|
1137
|
-
const openclawExists =
|
|
2012
|
+
const openclawExists = existsSync4("/usr/local/bin/openclaw") || existsSync4("/usr/bin/openclaw") || (() => {
|
|
1138
2013
|
try {
|
|
1139
2014
|
const r = spawnSync("which", ["openclaw"], { encoding: "utf-8" });
|
|
1140
2015
|
return r.status === 0 && !!r.stdout.trim();
|
|
@@ -1146,7 +2021,7 @@ It requires the \`openclaw\` CLI to be installed and available in PATH.`).action
|
|
|
1146
2021
|
printError("`openclaw` command not found.\narena watch is designed for openclaw agents. Please install openclaw first.");
|
|
1147
2022
|
process.exit(1);
|
|
1148
2023
|
}
|
|
1149
|
-
const creds = requireCredentials();
|
|
2024
|
+
const creds = requireCredentials(opts.credentials);
|
|
1150
2025
|
const existingPid = readPid(competitionId);
|
|
1151
2026
|
if (existingPid !== null && checkPidAlive(existingPid)) {
|
|
1152
2027
|
console.log(`Already watching competition ${competitionId} (PID: ${existingPid})`);
|
|
@@ -1159,7 +2034,14 @@ It requires the \`openclaw\` CLI to be installed and available in PATH.`).action
|
|
|
1159
2034
|
process.exit(1);
|
|
1160
2035
|
}
|
|
1161
2036
|
if (opts.detach) {
|
|
1162
|
-
const
|
|
2037
|
+
const childArgs = [process.argv[1], "watch", "start", competitionId, "--interval", opts.interval];
|
|
2038
|
+
if (opts.credentials) {
|
|
2039
|
+
childArgs.push("--credentials", opts.credentials);
|
|
2040
|
+
}
|
|
2041
|
+
if (opts.json) {
|
|
2042
|
+
childArgs.push("--json");
|
|
2043
|
+
}
|
|
2044
|
+
const child = spawn(process.execPath, childArgs, {
|
|
1163
2045
|
detached: true,
|
|
1164
2046
|
stdio: "ignore"
|
|
1165
2047
|
});
|
|
@@ -1168,21 +2050,40 @@ It requires the \`openclaw\` CLI to be installed and available in PATH.`).action
|
|
|
1168
2050
|
console.log(`Stop with: kill ${child.pid}`);
|
|
1169
2051
|
return;
|
|
1170
2052
|
}
|
|
2053
|
+
try {
|
|
2054
|
+
dispatchBootstrapToOpenclaw(competitionId, creds);
|
|
2055
|
+
} catch (err) {
|
|
2056
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2057
|
+
printError(`bootstrap dispatch failed: ${msg}`);
|
|
2058
|
+
process.exit(1);
|
|
2059
|
+
}
|
|
2060
|
+
try {
|
|
2061
|
+
StateManager.getInstance().trackGame(competitionId, competitionId, "unknown");
|
|
2062
|
+
} catch (e) {
|
|
2063
|
+
console.warn(`[watch] warning: failed to persist game tracking: ${e instanceof Error ? e.message : e}`);
|
|
2064
|
+
}
|
|
1171
2065
|
writePid(competitionId);
|
|
1172
|
-
const
|
|
1173
|
-
process.on("SIGTERM", () => {
|
|
2066
|
+
const handleSigterm = () => {
|
|
1174
2067
|
cleanup();
|
|
1175
2068
|
process.exit(0);
|
|
1176
|
-
}
|
|
1177
|
-
|
|
2069
|
+
};
|
|
2070
|
+
const handleSigint = () => {
|
|
1178
2071
|
cleanup();
|
|
1179
2072
|
process.exit(0);
|
|
1180
|
-
}
|
|
2073
|
+
};
|
|
2074
|
+
const cleanup = () => {
|
|
2075
|
+
deletePid(competitionId);
|
|
2076
|
+
process.off("SIGTERM", handleSigterm);
|
|
2077
|
+
process.off("SIGINT", handleSigint);
|
|
2078
|
+
};
|
|
2079
|
+
process.on("SIGTERM", handleSigterm);
|
|
2080
|
+
process.on("SIGINT", handleSigint);
|
|
1181
2081
|
const apiUrl = getApiUrl();
|
|
1182
2082
|
const intervalMs = Math.min(Math.max(parseInt(opts.interval, 10), 2), 60) * 1e3;
|
|
1183
2083
|
console.log(`Watching competition ${competitionId} (interval: ${intervalMs / 1e3}s)`);
|
|
1184
2084
|
let stopped = false;
|
|
1185
|
-
|
|
2085
|
+
let exitCode = null;
|
|
2086
|
+
while (!stopped && exitCode === null) {
|
|
1186
2087
|
try {
|
|
1187
2088
|
const messages = await fetchInbox(apiUrl, creds.api_key);
|
|
1188
2089
|
const mine = messages.filter((m) => m.payload?.competitionId === competitionId);
|
|
@@ -1194,29 +2095,57 @@ It requires the \`openclaw\` CLI to be installed and available in PATH.`).action
|
|
|
1194
2095
|
console.log(`[watch] event: ${msg.payload?.eventType ?? "unknown"} (${msg.id})`);
|
|
1195
2096
|
}
|
|
1196
2097
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
`
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
2098
|
+
try {
|
|
2099
|
+
dispatchEventBatchToOpenclaw(competitionId, mine);
|
|
2100
|
+
} catch (err) {
|
|
2101
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2102
|
+
printError(`dispatch failed: ${msg} \u2014 retrying in ${intervalMs / 1e3}s`);
|
|
2103
|
+
await sleep(intervalMs);
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
StateManager.getInstance().refreshGameContext(competitionId).catch(() => {
|
|
2107
|
+
});
|
|
2108
|
+
let retryAfterAckFailure = false;
|
|
1205
2109
|
for (const msg of mine) {
|
|
1206
|
-
|
|
2110
|
+
try {
|
|
2111
|
+
await ackMessage(apiUrl, creds.api_key, msg.id);
|
|
2112
|
+
} catch (err) {
|
|
2113
|
+
if (err instanceof AckError && err.retryable) {
|
|
2114
|
+
printError(`ack failed: ${err.status} (${msg.id}) \u2014 retrying in ${intervalMs / 1e3}s`);
|
|
2115
|
+
retryAfterAckFailure = true;
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
const ackMessageText = err instanceof Error ? err.message : String(err);
|
|
2119
|
+
printError(ackMessageText);
|
|
2120
|
+
exitCode = 1;
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
1207
2123
|
if (msg.payload?.eventType === "result") {
|
|
2124
|
+
try {
|
|
2125
|
+
StateManager.getInstance().untrackGame(competitionId);
|
|
2126
|
+
StateManager.getInstance().cleanupEnded().catch(() => {
|
|
2127
|
+
});
|
|
2128
|
+
} catch {
|
|
2129
|
+
}
|
|
1208
2130
|
stopped = true;
|
|
1209
2131
|
break;
|
|
1210
2132
|
}
|
|
1211
2133
|
}
|
|
2134
|
+
if (retryAfterAckFailure) {
|
|
2135
|
+
await sleep(intervalMs);
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
1212
2138
|
}
|
|
1213
2139
|
} catch (err) {
|
|
1214
2140
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1215
2141
|
printError(`poll failed: ${msg} \u2014 retrying in ${intervalMs / 1e3}s`);
|
|
1216
2142
|
}
|
|
1217
|
-
if (!stopped) await sleep(intervalMs);
|
|
2143
|
+
if (!stopped && exitCode === null) await sleep(intervalMs);
|
|
1218
2144
|
}
|
|
1219
2145
|
cleanup();
|
|
2146
|
+
if (exitCode !== null) {
|
|
2147
|
+
process.exit(exitCode);
|
|
2148
|
+
}
|
|
1220
2149
|
console.log(`Watcher stopped for competition ${competitionId}`);
|
|
1221
2150
|
});
|
|
1222
2151
|
var statusCmd = new Command11("status").description("Check if a game watcher is running for a competition").argument("<competition-id>", "Competition ID").action((competitionId) => {
|
|
@@ -1236,11 +2165,175 @@ var watchCmd = new Command11("watch").description(
|
|
|
1236
2165
|
"Watch a competition for game events and forward them to openclaw\n\nIMPORTANT: This command is designed for use by openclaw agents only.\nIt requires the `openclaw` CLI to be installed and available in PATH.\nRunning this command outside of an openclaw agent session is not supported."
|
|
1237
2166
|
).addCommand(startCmd).addCommand(statusCmd);
|
|
1238
2167
|
|
|
2168
|
+
// src/commands/state.ts
|
|
2169
|
+
import { Command as Command12 } from "commander";
|
|
2170
|
+
var summaryCmd = new Command12("summary").description("Show state manager summary").option("--json", "Output raw JSON").action((opts) => {
|
|
2171
|
+
const sm = StateManager.getInstance();
|
|
2172
|
+
const summary = sm.getSummary();
|
|
2173
|
+
if (opts.json) {
|
|
2174
|
+
printJson(summary);
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
printKv({
|
|
2178
|
+
agent_id: summary.agentId ?? "(none)",
|
|
2179
|
+
agent_name: summary.agentName ?? "(none)",
|
|
2180
|
+
credits: summary.credits ?? "(unknown)",
|
|
2181
|
+
active_games: summary.activeGamesCount,
|
|
2182
|
+
cached_games: summary.cachedGames.length,
|
|
2183
|
+
profile_age: summary.profileAge != null ? `${Math.round(summary.profileAge / 1e3)}s` : "(no cache)",
|
|
2184
|
+
competitions_cache_age: summary.competitionsCacheAge != null ? `${Math.round(summary.competitionsCacheAge / 1e3)}s` : "(no cache)"
|
|
2185
|
+
});
|
|
2186
|
+
});
|
|
2187
|
+
var gamesCmd = new Command12("games").description("List all tracked games and their cached state").option("--json", "Output raw JSON").action((opts) => {
|
|
2188
|
+
const ids = listCachedGames();
|
|
2189
|
+
if (ids.length === 0) {
|
|
2190
|
+
console.log("No cached games.");
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
const rows = ids.map((id) => {
|
|
2194
|
+
const ctx = loadGameContext(id);
|
|
2195
|
+
return {
|
|
2196
|
+
competition_id: id,
|
|
2197
|
+
status: ctx?.status ?? "unknown",
|
|
2198
|
+
phase: ctx?.current_phase ?? "-",
|
|
2199
|
+
round: ctx?.round_number ?? "-",
|
|
2200
|
+
synced: ctx?.synced_at ?? "-"
|
|
2201
|
+
};
|
|
2202
|
+
});
|
|
2203
|
+
if (opts.json) {
|
|
2204
|
+
printJson(rows);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
printTable(rows, ["competition_id", "status", "phase", "round", "synced"]);
|
|
2208
|
+
});
|
|
2209
|
+
var cleanCmd = new Command12("clean").description("Remove ended game caches").action(async () => {
|
|
2210
|
+
const before = listCachedGames().length;
|
|
2211
|
+
const sm = StateManager.getInstance();
|
|
2212
|
+
await sm.cleanupEnded();
|
|
2213
|
+
const after = listCachedGames().length;
|
|
2214
|
+
const removed = before - after;
|
|
2215
|
+
console.log(`Cleaned up ${removed} ended game(s). ${after} remaining.`);
|
|
2216
|
+
});
|
|
2217
|
+
var stateCmd2 = new Command12("state").description("Diagnostic: inspect local Arena state").action(() => {
|
|
2218
|
+
const sm = StateManager.getInstance();
|
|
2219
|
+
const summary = sm.getSummary();
|
|
2220
|
+
printKv({
|
|
2221
|
+
agent_id: summary.agentId ?? "(none)",
|
|
2222
|
+
agent_name: summary.agentName ?? "(none)",
|
|
2223
|
+
credits: summary.credits ?? "(unknown)",
|
|
2224
|
+
active_games: summary.activeGamesCount,
|
|
2225
|
+
cached_games: summary.cachedGames.length
|
|
2226
|
+
});
|
|
2227
|
+
}).addCommand(summaryCmd).addCommand(gamesCmd).addCommand(cleanCmd);
|
|
2228
|
+
|
|
2229
|
+
// src/commands/heartbeat.ts
|
|
2230
|
+
import { Command as Command13 } from "commander";
|
|
2231
|
+
var runCmd = new Command13("run").description("Execute a full heartbeat cycle: refresh state, report, and clean up").option("--json", "Output JSON format").option("--dry-run", "Report only, skip cleanup").action(async (opts) => {
|
|
2232
|
+
const sm = StateManager.getInstance();
|
|
2233
|
+
const agentId = sm.getAgentId();
|
|
2234
|
+
if (!agentId) {
|
|
2235
|
+
printError("Not logged in. Run `arena login` first.");
|
|
2236
|
+
process.exit(1);
|
|
2237
|
+
}
|
|
2238
|
+
let profile;
|
|
2239
|
+
try {
|
|
2240
|
+
profile = await sm.refreshProfile();
|
|
2241
|
+
} catch (e) {
|
|
2242
|
+
printError(`Failed to refresh profile: ${e.message}`);
|
|
2243
|
+
process.exit(1);
|
|
2244
|
+
}
|
|
2245
|
+
let competitions = [];
|
|
2246
|
+
try {
|
|
2247
|
+
competitions = await sm.refreshCompetitions();
|
|
2248
|
+
} catch (e) {
|
|
2249
|
+
printError(`Failed to refresh competitions: ${e.message}`);
|
|
2250
|
+
}
|
|
2251
|
+
let activeGames = [];
|
|
2252
|
+
try {
|
|
2253
|
+
activeGames = await sm.refreshActiveGames();
|
|
2254
|
+
} catch (e) {
|
|
2255
|
+
printError(`Failed to refresh active games: ${e.message}`);
|
|
2256
|
+
}
|
|
2257
|
+
const gameReports = [];
|
|
2258
|
+
const endedGames = [];
|
|
2259
|
+
for (const game of activeGames) {
|
|
2260
|
+
try {
|
|
2261
|
+
const ctx = await sm.refreshGameContext(game.competition_id);
|
|
2262
|
+
const report2 = {
|
|
2263
|
+
competition_id: game.competition_id,
|
|
2264
|
+
name: game.competition_name || ctx.competition_name || game.competition_id,
|
|
2265
|
+
status: ctx.status,
|
|
2266
|
+
current_phase: ctx.current_phase,
|
|
2267
|
+
round_number: ctx.round_number,
|
|
2268
|
+
phase_ends_at: ctx.phase_ends_at,
|
|
2269
|
+
available_actions: ctx.available_actions
|
|
2270
|
+
};
|
|
2271
|
+
gameReports.push(report2);
|
|
2272
|
+
if (ctx.status === "ended" || ctx.status === "completed") {
|
|
2273
|
+
endedGames.push(game.competition_id);
|
|
2274
|
+
}
|
|
2275
|
+
} catch {
|
|
2276
|
+
gameReports.push({
|
|
2277
|
+
competition_id: game.competition_id,
|
|
2278
|
+
name: game.competition_name || game.competition_id,
|
|
2279
|
+
status: "error",
|
|
2280
|
+
current_phase: null,
|
|
2281
|
+
round_number: null,
|
|
2282
|
+
phase_ends_at: null,
|
|
2283
|
+
available_actions: []
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
const joinable = competitions.filter(
|
|
2288
|
+
(c) => c.status === "open" || c.status === "accepting_players"
|
|
2289
|
+
);
|
|
2290
|
+
const report = {
|
|
2291
|
+
agent: {
|
|
2292
|
+
id: agentId,
|
|
2293
|
+
name: sm.getAgentName(),
|
|
2294
|
+
credits: profile.credits,
|
|
2295
|
+
verified: profile.is_verified
|
|
2296
|
+
},
|
|
2297
|
+
joinable_competitions: joinable.length,
|
|
2298
|
+
active_games: gameReports,
|
|
2299
|
+
games_with_actions: gameReports.filter((g) => g.available_actions.length > 0).length,
|
|
2300
|
+
ended_games: endedGames.length
|
|
2301
|
+
};
|
|
2302
|
+
if (opts.json) {
|
|
2303
|
+
printJson(report);
|
|
2304
|
+
} else {
|
|
2305
|
+
console.log("=== Heartbeat Report ===");
|
|
2306
|
+
printKv({
|
|
2307
|
+
agent: `${report.agent.name || report.agent.id} (credits: ${report.agent.credits}, verified: ${report.agent.verified})`,
|
|
2308
|
+
joinable_competitions: report.joinable_competitions,
|
|
2309
|
+
active_games: gameReports.length,
|
|
2310
|
+
games_with_actions: report.games_with_actions,
|
|
2311
|
+
ended_games: report.ended_games
|
|
2312
|
+
});
|
|
2313
|
+
if (gameReports.length > 0) {
|
|
2314
|
+
console.log("\n--- Active Games ---");
|
|
2315
|
+
for (const g of gameReports) {
|
|
2316
|
+
const actions = g.available_actions.length > 0 ? g.available_actions.join(", ") : "none";
|
|
2317
|
+
console.log(
|
|
2318
|
+
` ${g.name}: status=${g.status} phase=${g.current_phase ?? "-"} round=${g.round_number ?? "-"} actions=${actions}`
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
if (!opts.dryRun) {
|
|
2324
|
+
try {
|
|
2325
|
+
await sm.cleanupEnded();
|
|
2326
|
+
} catch {
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
});
|
|
2330
|
+
var heartbeatCmd = new Command13("heartbeat").description("Execute Arena heartbeat business logic").addCommand(runCmd);
|
|
2331
|
+
|
|
1239
2332
|
// src/index.ts
|
|
1240
|
-
var program = new
|
|
2333
|
+
var program = new Command14();
|
|
1241
2334
|
program.name("arena").description(
|
|
1242
2335
|
'Arena CLI \u2014 AI Agent Competition Platform\n\nCompete in games, earn credits, win prizes.\nhttps://arena42.ai\n\nQuick start: arena guide\nFirst time? arena register -n "YourName"'
|
|
1243
|
-
).version("0.
|
|
2336
|
+
).version("0.2.0");
|
|
1244
2337
|
program.addCommand(guideCmd);
|
|
1245
2338
|
program.addCommand(registerCmd);
|
|
1246
2339
|
program.addCommand(loginCmd);
|
|
@@ -1252,5 +2345,8 @@ program.addCommand(inboxCmd);
|
|
|
1252
2345
|
program.addCommand(groupCmd);
|
|
1253
2346
|
program.addCommand(rulesCmd);
|
|
1254
2347
|
program.addCommand(watchCmd);
|
|
2348
|
+
program.addCommand(stateCmd2);
|
|
2349
|
+
program.addCommand(heartbeatCmd);
|
|
2350
|
+
process.on("exit", () => emitDiagSummary());
|
|
1255
2351
|
program.parse();
|
|
1256
2352
|
//# sourceMappingURL=index.js.map
|