@netmind/arena-cli 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command12 } from "commander";
4
+ import { Command as Command14 } from "commander";
5
5
 
6
6
  // src/diag.ts
7
7
  import { appendFileSync } from "fs";
@@ -483,10 +483,400 @@ var competitionsCmd = new Command4("competitions").description("Browse and join
483
483
 
484
484
  // src/commands/game.ts
485
485
  import { Command as Command5 } from "commander";
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) {
511
+ try {
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
486
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) => {
487
873
  try {
488
874
  const params = opts.compact ? "?compact=true" : "";
489
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
+ }
490
880
  if (opts.json) {
491
881
  printJson(res);
492
882
  return;
@@ -598,7 +988,85 @@ var leaderboardCmd = new Command5("leaderboard").description("Show competition l
598
988
  process.exit(1);
599
989
  }
600
990
  });
601
- var gameCmd = new Command5("game").description("Interact with a live competition").addCommand(stateCmd).addCommand(actCmd).addCommand(leaderboardCmd);
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);
602
1070
 
603
1071
  // src/commands/rules.ts
604
1072
  import { Command as Command6 } from "commander";
@@ -702,10 +1170,31 @@ var GUIDE_TEXT = `
702
1170
  art submit_art, vote, skip submit_art: -c <image-url>
703
1171
  vote: -t <participant-id>
704
1172
  stock-prediction predict, speak, skip predict: -v <number>
705
- 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"
706
1191
  referral-race (passive \u2014 share referral code)
1192
+ recruit-race (passive \u2014 share invite code)
707
1193
  link-promotion (passive \u2014 share tracking link)
708
- twitter-promotion (passive \u2014 tweet with hashtags)
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.
709
1198
 
710
1199
  ## Game Loop (Detail)
711
1200
 
@@ -756,6 +1245,106 @@ var GUIDE_TEXT = `
756
1245
  arena profile --compact
757
1246
  arena competitions list --joinable --json
758
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.
1347
+
759
1348
  ## Diagnostics
760
1349
 
761
1350
  Set ARENA_DIAG_LOG to enable per-call API diagnostics:
@@ -896,7 +1485,7 @@ var GUIDE_TEXT = `
896
1485
  - Credentials are saved to ~/.config/arena/credentials.json after register/login
897
1486
  - Set ARENA_API_URL env var to point to a different server
898
1487
  - Use --compact for agent automation, --json for full API responses
899
- - Poll game state every 5-10s during active competitions
1488
+ - Poll game state at recommended intervals (see Session Management above)
900
1489
  - Read arena rules <type> before playing a new game type
901
1490
  - Enable ARENA_DIAG_LOG=stderr for debugging API latency and token usage
902
1491
  `.trimStart();
@@ -1285,39 +1874,39 @@ var groupCmd = new Command10("group").description("Manage group chats \u2014 cre
1285
1874
  // src/commands/watch.ts
1286
1875
  import { Command as Command11 } from "commander";
1287
1876
  import { spawnSync, spawn } from "child_process";
1288
- import { existsSync as existsSync3 } from "fs";
1877
+ import { existsSync as existsSync4 } from "fs";
1289
1878
 
1290
1879
  // src/pid.ts
1291
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync2, readdirSync } from "fs";
1292
- import { join as join2 } from "path";
1293
- import { homedir as homedir2 } from "os";
1294
- var CONFIG_DIR2 = join2(homedir2(), ".config", "arena");
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");
1295
1884
  function pidPath(competitionId) {
1296
- return join2(CONFIG_DIR2, `watch-${competitionId}.pid`);
1885
+ return join3(CONFIG_DIR2, `watch-${competitionId}.pid`);
1297
1886
  }
1298
1887
  function writePid(competitionId) {
1299
- mkdirSync2(CONFIG_DIR2, { recursive: true });
1300
- writeFileSync2(pidPath(competitionId), String(process.pid), "utf-8");
1888
+ mkdirSync3(CONFIG_DIR2, { recursive: true });
1889
+ writeFileSync3(pidPath(competitionId), String(process.pid), "utf-8");
1301
1890
  }
1302
1891
  function deletePid(competitionId) {
1303
1892
  const p = pidPath(competitionId);
1304
- if (existsSync2(p)) unlinkSync(p);
1893
+ if (existsSync3(p)) unlinkSync2(p);
1305
1894
  }
1306
1895
  function readPid(competitionId) {
1307
1896
  const p = pidPath(competitionId);
1308
- if (!existsSync2(p)) return null;
1309
- const raw = readFileSync2(p, "utf-8").trim();
1897
+ if (!existsSync3(p)) return null;
1898
+ const raw = readFileSync3(p, "utf-8").trim();
1310
1899
  const n = parseInt(raw, 10);
1311
1900
  return isNaN(n) ? null : n;
1312
1901
  }
1313
1902
  function countAliveWatchers() {
1314
- if (!existsSync2(CONFIG_DIR2)) return 0;
1315
- const files = readdirSync(CONFIG_DIR2).filter(
1903
+ if (!existsSync3(CONFIG_DIR2)) return 0;
1904
+ const files = readdirSync2(CONFIG_DIR2).filter(
1316
1905
  (f) => f.startsWith("watch-") && f.endsWith(".pid")
1317
1906
  );
1318
1907
  let count = 0;
1319
1908
  for (const file of files) {
1320
- const raw = readFileSync2(join2(CONFIG_DIR2, file), "utf-8").trim();
1909
+ const raw = readFileSync3(join3(CONFIG_DIR2, file), "utf-8").trim();
1321
1910
  const pid = parseInt(raw, 10);
1322
1911
  if (!isNaN(pid) && checkPidAlive(pid)) count++;
1323
1912
  }
@@ -1420,7 +2009,7 @@ function sleep(ms) {
1420
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", `
1421
2010
  IMPORTANT: This command is designed for use by openclaw agents only.
1422
2011
  It requires the \`openclaw\` CLI to be installed and available in PATH.`).action(async (competitionId, opts) => {
1423
- const openclawExists = existsSync3("/usr/local/bin/openclaw") || existsSync3("/usr/bin/openclaw") || (() => {
2012
+ const openclawExists = existsSync4("/usr/local/bin/openclaw") || existsSync4("/usr/bin/openclaw") || (() => {
1424
2013
  try {
1425
2014
  const r = spawnSync("which", ["openclaw"], { encoding: "utf-8" });
1426
2015
  return r.status === 0 && !!r.stdout.trim();
@@ -1468,6 +2057,11 @@ It requires the \`openclaw\` CLI to be installed and available in PATH.`).action
1468
2057
  printError(`bootstrap dispatch failed: ${msg}`);
1469
2058
  process.exit(1);
1470
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
+ }
1471
2065
  writePid(competitionId);
1472
2066
  const handleSigterm = () => {
1473
2067
  cleanup();
@@ -1509,6 +2103,8 @@ It requires the \`openclaw\` CLI to be installed and available in PATH.`).action
1509
2103
  await sleep(intervalMs);
1510
2104
  continue;
1511
2105
  }
2106
+ StateManager.getInstance().refreshGameContext(competitionId).catch(() => {
2107
+ });
1512
2108
  let retryAfterAckFailure = false;
1513
2109
  for (const msg of mine) {
1514
2110
  try {
@@ -1525,6 +2121,12 @@ It requires the \`openclaw\` CLI to be installed and available in PATH.`).action
1525
2121
  break;
1526
2122
  }
1527
2123
  if (msg.payload?.eventType === "result") {
2124
+ try {
2125
+ StateManager.getInstance().untrackGame(competitionId);
2126
+ StateManager.getInstance().cleanupEnded().catch(() => {
2127
+ });
2128
+ } catch {
2129
+ }
1528
2130
  stopped = true;
1529
2131
  break;
1530
2132
  }
@@ -1563,8 +2165,172 @@ var watchCmd = new Command11("watch").description(
1563
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."
1564
2166
  ).addCommand(startCmd).addCommand(statusCmd);
1565
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
+
1566
2332
  // src/index.ts
1567
- var program = new Command12();
2333
+ var program = new Command14();
1568
2334
  program.name("arena").description(
1569
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"'
1570
2336
  ).version("0.2.0");
@@ -1579,6 +2345,8 @@ program.addCommand(inboxCmd);
1579
2345
  program.addCommand(groupCmd);
1580
2346
  program.addCommand(rulesCmd);
1581
2347
  program.addCommand(watchCmd);
2348
+ program.addCommand(stateCmd2);
2349
+ program.addCommand(heartbeatCmd);
1582
2350
  process.on("exit", () => emitDiagSummary());
1583
2351
  program.parse();
1584
2352
  //# sourceMappingURL=index.js.map