@myclaw163/clawclaw-cli 0.6.64 → 0.6.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +427 -427
- package/package.json +48 -48
- package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
- package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
- package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
- package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
- package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
- package/scripts/find-hide-spots.py +157 -157
- package/scripts/postinstall.mjs +20 -20
- package/scripts/sync-bundled-skill.mjs +244 -244
- package/scripts/sync-bundled-skill.test.mjs +152 -152
- package/skills/clawclaw/SKILL.md +245 -244
- package/skills/clawclaw/references/CHATTERBOX.md +142 -142
- package/skills/clawclaw/references/COMMANDS.md +148 -148
- package/skills/clawclaw/references/GAME-MECHANICS.md +188 -186
- package/skills/clawclaw/references/HUB.md +48 -48
- package/skills/clawclaw/references/KNOWLEDGE.md +43 -43
- package/skills/clawclaw/references/STRATEGIES.md +57 -57
- package/skills/clawclaw/references/STREAM.md +92 -92
- package/skills/clawclaw/references/TACTICS.md +65 -65
- package/src/assets/clawclaw-ascii-map.txt +40 -40
- package/src/cli.ts +110 -110
- package/src/commands/_schema.ts +109 -109
- package/src/commands/account.ts +209 -209
- package/src/commands/config.ts +30 -30
- package/src/commands/do.test.ts +73 -73
- package/src/commands/do.ts +126 -126
- package/src/commands/events.test.ts +71 -71
- package/src/commands/events.ts +155 -155
- package/src/commands/game-map.test.ts +28 -28
- package/src/commands/game-start-plan.test.ts +84 -84
- package/src/commands/game.ts +1027 -1027
- package/src/commands/history-player.test.ts +102 -102
- package/src/commands/history.ts +573 -573
- package/src/commands/hub.test.ts +96 -96
- package/src/commands/hub.ts +234 -234
- package/src/commands/knowledge.test.ts +19 -19
- package/src/commands/knowledge.ts +168 -168
- package/src/commands/load.test.ts +51 -51
- package/src/commands/load.ts +13 -13
- package/src/commands/meeting-history.test.ts +106 -106
- package/src/commands/memory.ts +40 -40
- package/src/commands/peek.ts +45 -45
- package/src/commands/persona.ts +57 -57
- package/src/commands/setup/codex.ts +248 -248
- package/src/commands/setup/hermes.test.ts +96 -96
- package/src/commands/setup/hermes.ts +76 -76
- package/src/commands/setup/index.ts +13 -13
- package/src/commands/setup/openclaw.test.ts +114 -114
- package/src/commands/setup/openclaw.ts +147 -147
- package/src/commands/skill.ts +128 -128
- package/src/commands/state.ts +46 -46
- package/src/commands/strategy.test.ts +135 -135
- package/src/commands/strategy.ts +180 -180
- package/src/commands/tts.ts +128 -128
- package/src/commands/upgrade.test.ts +82 -82
- package/src/commands/upgrade.ts +148 -148
- package/src/commands/watch.test.ts +966 -966
- package/src/commands/watch.ts +659 -659
- package/src/lib/auth.test.ts +59 -59
- package/src/lib/auth.ts +186 -186
- package/src/lib/command-meta.ts +37 -37
- package/src/lib/game-client.ts +391 -391
- package/src/lib/host-config-patcher.test.ts +130 -130
- package/src/lib/host-config-patcher.ts +151 -151
- package/src/lib/http-keepalive.ts +15 -15
- package/src/lib/http-transport.test.ts +42 -42
- package/src/lib/http-transport.ts +113 -113
- package/src/lib/hub-client.test.ts +56 -56
- package/src/lib/hub-client.ts +88 -88
- package/src/lib/hub-install.test.ts +98 -98
- package/src/lib/hub-install.ts +121 -121
- package/src/lib/hub-reminder.ts +56 -56
- package/src/lib/hub-unzip.test.ts +69 -69
- package/src/lib/hub-unzip.ts +62 -62
- package/src/lib/init-command.test.ts +75 -75
- package/src/lib/init-command.ts +120 -120
- package/src/lib/knowledge-store.test.ts +180 -180
- package/src/lib/knowledge-store.ts +374 -374
- package/src/lib/load-context.test.ts +52 -52
- package/src/lib/load-context.ts +52 -52
- package/src/lib/match-state.test.ts +134 -134
- package/src/lib/match-state.ts +94 -94
- package/src/lib/netease-tts.ts +83 -83
- package/src/lib/normalize.ts +42 -42
- package/src/lib/persona.test.ts +41 -41
- package/src/lib/persona.ts +72 -72
- package/src/lib/server-registry.ts +152 -152
- package/src/lib/skill-version.test.ts +48 -48
- package/src/lib/skill-version.ts +19 -19
- package/src/lib/strategy-export.test.ts +232 -232
- package/src/lib/strategy-export.ts +242 -242
- package/src/lib/tts-keys.ts +7 -7
- package/src/lib/tts-speech.test.ts +63 -63
- package/src/lib/tts-speech.ts +76 -76
- package/src/lib/workspace-argv.test.ts +49 -49
- package/src/lib/workspace-argv.ts +44 -44
- package/src/perception/player-history-store.test.ts +87 -87
- package/src/perception/player-history-store.ts +194 -194
- package/src/pipeline/event-format.test.ts +135 -135
- package/src/pipeline/event-format.ts +376 -376
- package/src/pipeline/event-hints.ts +173 -173
- package/src/pipeline/event-store.test.ts +28 -28
- package/src/pipeline/event-store.ts +193 -193
- package/src/pipeline/pipeline.ts +35 -35
- package/src/runtime/auto-upgrade.test.ts +66 -66
- package/src/runtime/auto-upgrade.ts +31 -31
- package/src/runtime/event-daemon.test.ts +107 -107
- package/src/runtime/event-daemon.ts +409 -409
- package/src/runtime/owner-control.ts +150 -150
- package/src/runtime/raw-ws-log.test.ts +33 -33
- package/src/runtime/raw-ws-log.ts +32 -32
- package/src/runtime/runtime-logger.ts +107 -107
- package/src/runtime/ws-client.test.ts +104 -104
- package/src/runtime/ws-client.ts +272 -272
- package/src/sdk/action.ts +166 -166
- package/src/sdk/index.ts +111 -111
- package/src/sdk/types.ts +159 -159
- package/src/strategies/avoid-lone.ts +11 -11
- package/src/strategies/avoid-players.knowledge.md +20 -20
- package/src/strategies/avoid-players.ts +15 -15
- package/src/strategies/corpse-patrol.ts +22 -22
- package/src/strategies/crab-sabotage.ts +21 -21
- package/src/strategies/custom-module.test.ts +269 -269
- package/src/strategies/find-player.ts +16 -16
- package/src/strategies/game-utils.test.ts +190 -190
- package/src/strategies/game-utils.ts +782 -782
- package/src/strategies/goals/anchor-linger.ts +77 -77
- package/src/strategies/goals/avoid-lone-top.ts +168 -168
- package/src/strategies/goals/avoid-players-top.test.ts +83 -83
- package/src/strategies/goals/avoid-players-top.ts +121 -121
- package/src/strategies/goals/conversation-goal.ts +51 -51
- package/src/strategies/goals/corpse-patrol-top.ts +91 -91
- package/src/strategies/goals/crab-octopus-reflexes.ts +93 -93
- package/src/strategies/goals/crab-sabotage-top.ts +197 -197
- package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
- package/src/strategies/goals/find-player-top.ts +93 -93
- package/src/strategies/goals/flee-players-goal.ts +53 -53
- package/src/strategies/goals/follow-companion-goal.ts +106 -106
- package/src/strategies/goals/goal-manager.ts +41 -41
- package/src/strategies/goals/goal-root-strategy.ts +49 -49
- package/src/strategies/goals/goal.ts +28 -28
- package/src/strategies/goals/hide-top.ts +197 -197
- package/src/strategies/goals/keep-away-goal.ts +217 -217
- package/src/strategies/goals/kill-frenzy-top.ts +80 -80
- package/src/strategies/goals/kill-lone-top.ts +160 -160
- package/src/strategies/goals/kill-target-goal.ts +59 -59
- package/src/strategies/goals/kill-target-top.ts +109 -109
- package/src/strategies/goals/leaf-goal.ts +25 -25
- package/src/strategies/goals/linger-corpse-goal.ts +35 -35
- package/src/strategies/goals/lone-kill-core.ts +82 -82
- package/src/strategies/goals/lone-kill-goal.ts +24 -24
- package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
- package/src/strategies/goals/lone-kill-task-top.ts +86 -86
- package/src/strategies/goals/move-room-goal.ts +60 -60
- package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
- package/src/strategies/goals/normal-shrimp-top.ts +242 -242
- package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
- package/src/strategies/goals/paradise-fish-top.ts +207 -207
- package/src/strategies/goals/patrol-top.ts +57 -57
- package/src/strategies/goals/report-patrol-top.ts +80 -80
- package/src/strategies/goals/safe-task-goal.ts +102 -102
- package/src/strategies/goals/social-task-top.ts +161 -161
- package/src/strategies/goals/task-kill-report-top.ts +163 -163
- package/src/strategies/goals/task-only-top.ts +57 -57
- package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
- package/src/strategies/goals/task-report-top.ts +57 -57
- package/src/strategies/goals/wander-task-goal.ts +33 -33
- package/src/strategies/goals/warrior-shrimp-top.test.ts +86 -86
- package/src/strategies/goals/warrior-shrimp-top.ts +500 -497
- package/src/strategies/greeting.ts +53 -53
- package/src/strategies/hide-spots.ts +123 -123
- package/src/strategies/hide.ts +23 -23
- package/src/strategies/kill-frenzy.ts +12 -12
- package/src/strategies/kill-lone.knowledge.md +20 -20
- package/src/strategies/kill-lone.ts +13 -13
- package/src/strategies/kill-target.ts +18 -18
- package/src/strategies/loader.test.ts +678 -678
- package/src/strategies/loader.ts +172 -172
- package/src/strategies/lone-kill-task.ts +21 -21
- package/src/strategies/meeting-gate.test.ts +59 -59
- package/src/strategies/meeting-gate.ts +23 -23
- package/src/strategies/move-room.ts +15 -15
- package/src/strategies/new-events-backfill.ts +98 -98
- package/src/strategies/paradise-fish.knowledge.md +20 -20
- package/src/strategies/paradise-fish.ts +25 -25
- package/src/strategies/pathfind/distance-field.ts +150 -150
- package/src/strategies/pathfind/escape-planner.test.ts +197 -197
- package/src/strategies/pathfind/escape-planner.ts +355 -355
- package/src/strategies/pathfind/walkable-grid.ts +117 -117
- package/src/strategies/patrol.ts +11 -11
- package/src/strategies/player-targets.ts +13 -13
- package/src/strategies/report-patrol.ts +11 -11
- package/src/strategies/shrimp-memory.knowledge.md +20 -20
- package/src/strategies/shrimp-memory.ts +25 -25
- package/src/strategies/social-task.test.ts +28 -28
- package/src/strategies/social-task.ts +49 -49
- package/src/strategies/spawn.ts +82 -82
- package/src/strategies/speech-module.ts +123 -123
- package/src/strategies/strategy-loop.ts +771 -771
- package/src/strategies/task-kill-report.ts +17 -17
- package/src/strategies/task-only.ts +11 -11
- package/src/strategies/task-report.ts +22 -22
- package/src/strategies/types.ts +102 -102
- package/src/strategies/warrior-memory.knowledge.md +22 -22
- package/src/strategies/warrior-memory.ts +16 -16
package/src/commands/watch.ts
CHANGED
|
@@ -1,659 +1,659 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import { existsSync, readFileSync, openSync, readSync, closeSync, statSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { AuthStore } from '../lib/auth.js';
|
|
5
|
-
import { getProfileStateDir } from '../lib/init-command.js';
|
|
6
|
-
import { setMeta } from '../lib/command-meta.js';
|
|
7
|
-
import { EventStore, extractNewEvents, isLocalControlEvent } from '../pipeline/event-store.js';
|
|
8
|
-
import { compactEventForEvents, formatEventMessage, shortStateText } from '../pipeline/event-format.js';
|
|
9
|
-
import { PlayerHistoryStore } from '../perception/player-history-store.js';
|
|
10
|
-
import { DEFAULT_MATCH_TIMEOUT_MS } from '../lib/match-state.js';
|
|
11
|
-
import { hubReminder, readCachedGamesPlayed } from '../lib/hub-reminder.js';
|
|
12
|
-
import { sendOwnerControlRequest } from '../runtime/owner-control.js';
|
|
13
|
-
|
|
14
|
-
export const POLL_INTERVAL_MS = 220;
|
|
15
|
-
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
16
|
-
const RUNTIME_WAIT_MS = 30_000;
|
|
17
|
-
|
|
18
|
-
export class WatchNotReadyError extends Error {
|
|
19
|
-
constructor(message: string) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.name = 'WatchNotReadyError';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Legacy helper for tests and explicit file-backed attaches. Owner paths use control socket snapshots. */
|
|
26
|
-
export async function waitForFeed(
|
|
27
|
-
feedPath: string,
|
|
28
|
-
opts?: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number },
|
|
29
|
-
): Promise<boolean> {
|
|
30
|
-
const timeoutMs = opts?.timeoutMs ?? RUNTIME_WAIT_MS;
|
|
31
|
-
const pollMs = opts?.pollMs ?? POLL_INTERVAL_MS;
|
|
32
|
-
const deadline = Date.now() + timeoutMs;
|
|
33
|
-
while (Date.now() < deadline) {
|
|
34
|
-
if (existsSync(feedPath)) return true;
|
|
35
|
-
if (opts?.signal?.aborted) return false;
|
|
36
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
37
|
-
}
|
|
38
|
-
return existsSync(feedPath);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const DEFAULT_MONITOR_EVENT_PRIORITY = 50;
|
|
42
|
-
|
|
43
|
-
export interface MonitorEventConfig {
|
|
44
|
-
priority: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export const MONITOR_EVENT_CONFIG: Record<string, MonitorEventConfig> = {
|
|
48
|
-
kill: { priority: 50 },
|
|
49
|
-
killed: { priority: 99 },
|
|
50
|
-
murder_witnessed: { priority: 50 },
|
|
51
|
-
warrior_shrimp_self_destruct: { priority: 50 },
|
|
52
|
-
|
|
53
|
-
task_completed: { priority: 50 },
|
|
54
|
-
task_sabotaged: { priority: 50 },
|
|
55
|
-
|
|
56
|
-
corpse_spotted: { priority: 50 },
|
|
57
|
-
octopus_time_start: { priority: 50 },
|
|
58
|
-
|
|
59
|
-
emergency_started: { priority: 50 },
|
|
60
|
-
emergency_resolved: { priority: 50 },
|
|
61
|
-
|
|
62
|
-
exile: { priority: 50 },
|
|
63
|
-
no_exile: { priority: 50 },
|
|
64
|
-
|
|
65
|
-
vote_speech_phase_ended: { priority: 50 },
|
|
66
|
-
death_speech: { priority: 50 },
|
|
67
|
-
wandering_speech: { priority: 50 },
|
|
68
|
-
|
|
69
|
-
meeting_briefing: { priority: 50 },
|
|
70
|
-
speech: { priority: 50 },
|
|
71
|
-
speech_skipped: { priority: 50 },
|
|
72
|
-
speech_your_turn: { priority: 99 },
|
|
73
|
-
|
|
74
|
-
vote_phase_start: { priority: 99 },
|
|
75
|
-
vote_cast: { priority: 50 },
|
|
76
|
-
meeting_ended: { priority: 50 },
|
|
77
|
-
|
|
78
|
-
game_over: { priority: 50 },
|
|
79
|
-
role_assigned: { priority: 50 },
|
|
80
|
-
// game_started intentionally not registered.
|
|
81
|
-
|
|
82
|
-
// #region: Matchmaking-phase synthetic events
|
|
83
|
-
match_waiting: { priority: 50 },
|
|
84
|
-
match_timeout: { priority: 50 },
|
|
85
|
-
robot_speak_rule: { priority: 50 },
|
|
86
|
-
// #endregion
|
|
87
|
-
|
|
88
|
-
// Strategy → agent real-time briefing (the automation explaining a decisive
|
|
89
|
-
// autonomous move, e.g. self-reporting its own kill). Ranked above ambient
|
|
90
|
-
// wander events so it leads the events[] list.
|
|
91
|
-
strategy_alert: { priority: 60 },
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
export const NOTABLE_EVENT_TYPES = new Set(Object.keys(MONITOR_EVENT_CONFIG));
|
|
95
|
-
|
|
96
|
-
/** Events whose trigger should be delayed to allow merging with nearby events.
|
|
97
|
-
* Key = event type, Value = delay in milliseconds.
|
|
98
|
-
* During the delay window, all incoming events (both delayable and non-delayable)
|
|
99
|
-
* are buffered and emitted together when the timer expires.
|
|
100
|
-
* Only one delay runs at a time — subsequent delayable events do NOT reset the timer. */
|
|
101
|
-
export const DELAYABLE_EVENT_CONFIG: Record<string, number> = {
|
|
102
|
-
kill: 2000,
|
|
103
|
-
emergency_started: 2000,
|
|
104
|
-
corpse_spotted: 2000,
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
export interface GameEvent {
|
|
108
|
-
type: string;
|
|
109
|
-
tick?: number;
|
|
110
|
-
ts?: string;
|
|
111
|
-
[key: string]: any;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function eventKey(evt: GameEvent): string {
|
|
115
|
-
const type = evt.type ?? '?';
|
|
116
|
-
const tick = evt.tick ?? 0;
|
|
117
|
-
const parts = [
|
|
118
|
-
evt.actor_name,
|
|
119
|
-
evt.spotted_name,
|
|
120
|
-
evt.killer_name,
|
|
121
|
-
evt.result_target,
|
|
122
|
-
evt.target_name,
|
|
123
|
-
evt.corpse_name,
|
|
124
|
-
evt.task_name,
|
|
125
|
-
].filter(Boolean);
|
|
126
|
-
const disc = parts.length > 0 ? parts.join('|') : '';
|
|
127
|
-
return `${type}@${tick}#${disc}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export interface EventClassification {
|
|
131
|
-
notable: boolean;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function classifyEvent(evt: GameEvent, _youName: string | null = null): EventClassification {
|
|
135
|
-
const notable = !!(evt.type && NOTABLE_EVENT_TYPES.has(evt.type));
|
|
136
|
-
return { notable };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function monitorEventPriority(event: GameEvent): number {
|
|
140
|
-
return MONITOR_EVENT_CONFIG[event.type]?.priority ?? DEFAULT_MONITOR_EVENT_PRIORITY;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
interface RouteRule {
|
|
144
|
-
reason: string;
|
|
145
|
-
match: (triggers: string[]) => boolean;
|
|
146
|
-
nextStep: string;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const ROUTING_RULES: RouteRule[] = [
|
|
150
|
-
{ reason: 'speech_your_turn', match: (t) => t.includes('speech_your_turn'), nextStep: 'It is YOUR turn to speak. Submit `ccl do -s "<draft>"` immediately. Server skips you after 45s.' },
|
|
151
|
-
{ reason: 'role_assigned', match: (t) => t.includes('role_assigned'), nextStep: 'Tell user your role, faction, win condition, and first plan.' },
|
|
152
|
-
{ reason: 'vote_cast', match: (t) => t.includes('vote_cast'), nextStep: 'A player just cast their vote. Read events[] to track who voted. No `ccl` action needed — cast your own vote immediately when vote phase starts.' },
|
|
153
|
-
{ reason: 'match_start', match: (t) => t.includes('match_start'), nextStep: 'Matchmaking queue entered. The game runtime is live and the stream is attached. Chat with the user while waiting for allocation.' },
|
|
154
|
-
{ reason: 'match_waiting', match: (t) => t.includes('match_waiting'), nextStep: 'Still in queue (see `events[].waited_secs`). Keep chatting with the user; no tactical action required.' },
|
|
155
|
-
{ reason: 'match_timeout', match: (t) => t.includes('match_timeout'), nextStep: `Cumulative wait reached ${Math.round(DEFAULT_MATCH_TIMEOUT_MS / 60_000)} min (see \`events[].waited_secs\`). The stream will exit — discuss with the user: launch a fresh \`ccl game start\` to retry, or call it a session.` },
|
|
156
|
-
{ reason: 'game_over', match: (t) => t.includes('game_over'), nextStep: 'Game ended. The current ccl game start process is exiting automatically. Review the result with the user; start another match only if the user asks.' },
|
|
157
|
-
{ reason: 'strategy_alert', match: (t) => t.includes('strategy_alert'), nextStep: 'Your automation just made a decisive autonomous move and is briefing you (read events[].message). Lock your story to it before you next speak or vote — e.g. if it reported a corpse that is likely your own kill, present yourself as the proactive reporter, steer suspicion onto the bystanders, and do NOT confess.' },
|
|
158
|
-
];
|
|
159
|
-
|
|
160
|
-
const DEFAULT_NEXT_STEP = 'React to summary fields. No relaunch needed — the stream stays attached.';
|
|
161
|
-
|
|
162
|
-
function routeTriggers(triggers: string[]): { exitReason: string; nextStep: string } {
|
|
163
|
-
const rule = ROUTING_RULES.find((r) => r.match(triggers));
|
|
164
|
-
return {
|
|
165
|
-
exitReason: rule?.reason ?? triggers[0],
|
|
166
|
-
nextStep: rule?.nextStep ?? DEFAULT_NEXT_STEP,
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function nextStepFor(reason: string): string {
|
|
171
|
-
return routeTriggers([reason]).nextStep;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function nextStepForTriggers(triggers: string[]): string {
|
|
175
|
-
return routeTriggers(triggers).nextStep;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function sortEventsForMonitor(events: GameEvent[]): GameEvent[] {
|
|
179
|
-
return events
|
|
180
|
-
.map((event, index) => ({
|
|
181
|
-
event,
|
|
182
|
-
index,
|
|
183
|
-
priority: monitorEventPriority(event),
|
|
184
|
-
}))
|
|
185
|
-
.sort((a, b) => (b.priority - a.priority) || (a.index - b.index))
|
|
186
|
-
.map(({ event }) => event);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function readSeatMapForMonitor(sessionPath: string | null): Record<string, number> {
|
|
190
|
-
if (!sessionPath) return {};
|
|
191
|
-
try {
|
|
192
|
-
return PlayerHistoryStore.forSession(sessionPath).read()?.seats ?? {};
|
|
193
|
-
} catch {
|
|
194
|
-
return {};
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function cleanObject<T extends Record<string, any>>(obj: T): T {
|
|
199
|
-
for (const key of Object.keys(obj)) {
|
|
200
|
-
if (obj[key] === undefined) delete obj[key];
|
|
201
|
-
}
|
|
202
|
-
return obj;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export function compactEventForMonitor(event: GameEvent): Record<string, any> {
|
|
206
|
-
return compactEventForEvents(event);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export function summarizeFeed(feed: any): any | null {
|
|
210
|
-
if (!feed || typeof feed !== 'object') return null;
|
|
211
|
-
const summary: any = {
|
|
212
|
-
phase: feed.phase,
|
|
213
|
-
you: {
|
|
214
|
-
name: feed.you?.name,
|
|
215
|
-
seat: feed.you?.seat,
|
|
216
|
-
role: feed.you?.role,
|
|
217
|
-
role_display: feed.you?.role_display,
|
|
218
|
-
faction: feed.you?.faction,
|
|
219
|
-
alive: feed.you?.alive,
|
|
220
|
-
x: feed.you?.x,
|
|
221
|
-
y: feed.you?.y,
|
|
222
|
-
currently_moving: feed.you?.currently_moving,
|
|
223
|
-
doing_task: feed.you?.doing_task,
|
|
224
|
-
kill_cooldown_secs: feed.you?.kill_cooldown_secs,
|
|
225
|
-
kills_remaining: feed.you?.kills_remaining,
|
|
226
|
-
},
|
|
227
|
-
game: feed.game,
|
|
228
|
-
urgent: feed.urgent,
|
|
229
|
-
meeting: feed.meeting,
|
|
230
|
-
};
|
|
231
|
-
if (feed.automation) summary.automation = feed.automation;
|
|
232
|
-
return summary;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function readFeedSummary(feedPath: string): any | null {
|
|
236
|
-
try {
|
|
237
|
-
const feed = JSON.parse(readFileSync(feedPath, 'utf8'));
|
|
238
|
-
return summarizeFeed(feed);
|
|
239
|
-
} catch {
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export function compactSummaryForMonitor(summary: any | null, triggers: string[]): any | null {
|
|
245
|
-
if (!summary || summary.phase !== 'meeting') return summary;
|
|
246
|
-
const oldMeeting = summary.meeting ?? {};
|
|
247
|
-
const oldYou = summary.you ?? {};
|
|
248
|
-
const isVotePhase = triggers.includes('vote_phase_start')
|
|
249
|
-
|| (!triggers.includes('speech_your_turn') && oldMeeting.sub_phase === 'vote');
|
|
250
|
-
const meeting: Record<string, any> = {
|
|
251
|
-
caller: oldMeeting.caller,
|
|
252
|
-
sub_phase: triggers.includes('vote_phase_start') ? 'vote' : oldMeeting.sub_phase,
|
|
253
|
-
alive_players: oldMeeting.alive_players,
|
|
254
|
-
};
|
|
255
|
-
if (triggers.includes('speech_your_turn')) {
|
|
256
|
-
meeting.sub_phase = 'speech';
|
|
257
|
-
meeting.current_speaker = oldYou.name;
|
|
258
|
-
meeting.is_my_turn = true;
|
|
259
|
-
} else if (!isVotePhase) {
|
|
260
|
-
meeting.current_speaker = oldMeeting.current_speaker;
|
|
261
|
-
meeting.is_my_turn = oldMeeting.is_my_turn;
|
|
262
|
-
}
|
|
263
|
-
if (isVotePhase) {
|
|
264
|
-
meeting.votes_submitted_count = Array.isArray(oldMeeting.votes_submitted)
|
|
265
|
-
? oldMeeting.votes_submitted.length
|
|
266
|
-
: 0;
|
|
267
|
-
}
|
|
268
|
-
return cleanObject({
|
|
269
|
-
phase: summary.phase,
|
|
270
|
-
you: cleanObject({
|
|
271
|
-
name: oldYou.name,
|
|
272
|
-
role: oldYou.role,
|
|
273
|
-
faction: oldYou.faction,
|
|
274
|
-
}),
|
|
275
|
-
game: summary.game,
|
|
276
|
-
meeting: cleanObject(meeting),
|
|
277
|
-
automation: summary.automation,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
export interface RunStreamingOptions {
|
|
282
|
-
feedPath: string;
|
|
283
|
-
sessionPath: string | null;
|
|
284
|
-
readSummary?: () => any | null;
|
|
285
|
-
/** Optional dynamic resolver — called each poll so the stream can follow session rotation across games. */
|
|
286
|
-
getSessionPath?: () => string | null;
|
|
287
|
-
stdout: (line: string) => void;
|
|
288
|
-
signal?: AbortSignal;
|
|
289
|
-
skipFeedWait?: boolean;
|
|
290
|
-
pollIntervalMs?: number;
|
|
291
|
-
/** Emit a `heartbeat` NDJSON line after this many ms of silence so consumers don't see the stream as hung. Default 30s. Pass 0 to disable. */
|
|
292
|
-
heartbeatIntervalMs?: number;
|
|
293
|
-
/** Max ms to wait for feed.json when launching alongside `game start`. Default 30s. */
|
|
294
|
-
runtimeWaitMs?: number;
|
|
295
|
-
/**
|
|
296
|
-
* Per-event-type delay overrides for the delay buffer (see DELAYABLE_EVENT_CONFIG).
|
|
297
|
-
* Pass `{ kill: 0 }` to disable the kill delay in tests.
|
|
298
|
-
* Values from DELAYABLE_EVENT_CONFIG are used for any type not overridden here.
|
|
299
|
-
*/
|
|
300
|
-
delayableEventMs?: Record<string, number>;
|
|
301
|
-
/**
|
|
302
|
-
* Event types to exclude from the initial `caught_up` backlog (useful when the
|
|
303
|
-
* caller already emitted them live before chaining into runStreaming, e.g.
|
|
304
|
-
* `ccl game start` emits `match_waiting` / `match_timeout` during its polling
|
|
305
|
-
* phase). Does NOT affect ongoing event tailing — only the one-time pre-attach
|
|
306
|
-
* backlog drain.
|
|
307
|
-
*/
|
|
308
|
-
skipBacklogTypes?: string[];
|
|
309
|
-
/**
|
|
310
|
-
* Emit a first `game_start` beat even when no backlog is present. `ccl game start`
|
|
311
|
-
* uses this to tell the agent the stream is attached without carrying a huge
|
|
312
|
-
* `initial_payload`.
|
|
313
|
-
*/
|
|
314
|
-
emitGameStart?: boolean;
|
|
315
|
-
/**
|
|
316
|
-
* When set, attached to the `game_over` emission (as a `hub_reminder` field
|
|
317
|
-
* and appended to `next_step`) so the agent sees the nudge in the same beat it
|
|
318
|
-
* starts the post-game debrief. Callers compute it via `hubReminder()`; pass
|
|
319
|
-
* nothing once the account has adopted a hub or custom strategy.
|
|
320
|
-
*/
|
|
321
|
-
hubReminder?: string;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export async function runStreaming(opts: RunStreamingOptions): Promise<void> {
|
|
325
|
-
if (!opts.skipFeedWait) {
|
|
326
|
-
const feedReady = await waitForFeed(opts.feedPath, {
|
|
327
|
-
signal: opts.signal,
|
|
328
|
-
timeoutMs: opts.runtimeWaitMs ?? RUNTIME_WAIT_MS,
|
|
329
|
-
pollMs: opts.pollIntervalMs ?? POLL_INTERVAL_MS,
|
|
330
|
-
});
|
|
331
|
-
if (!feedReady) {
|
|
332
|
-
throw new WatchNotReadyError(
|
|
333
|
-
'Game runtime not ready (no feed.json). Run `ccl game start` to start or recover the game stream.',
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
338
|
-
const heartbeatInterval = opts.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
339
|
-
const resolvedDelays: Record<string, number> = { ...DELAYABLE_EVENT_CONFIG, ...(opts.delayableEventMs ?? {}) };
|
|
340
|
-
let lastEmitMs = Date.now();
|
|
341
|
-
const emit = (obj: any): void => {
|
|
342
|
-
opts.stdout(JSON.stringify(obj) + '\n');
|
|
343
|
-
lastEmitMs = Date.now();
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
// Initial player name from owner snapshot or legacy feed file (best-effort).
|
|
347
|
-
let youName: string | null = null;
|
|
348
|
-
try {
|
|
349
|
-
youName = opts.readSummary?.()?.you?.name
|
|
350
|
-
?? JSON.parse(readFileSync(opts.feedPath, 'utf8'))?.you?.name
|
|
351
|
-
?? null;
|
|
352
|
-
} catch {}
|
|
353
|
-
|
|
354
|
-
// Tail state for the .jsonl
|
|
355
|
-
const seenKeys = new Set<string>();
|
|
356
|
-
let filePos = 0;
|
|
357
|
-
let fileIno = 0;
|
|
358
|
-
let lineBuffer = '';
|
|
359
|
-
let currentSessionPath: string | null = opts.sessionPath;
|
|
360
|
-
|
|
361
|
-
if (currentSessionPath && existsSync(currentSessionPath)) {
|
|
362
|
-
filePos = 0;
|
|
363
|
-
fileIno = statSync(currentSessionPath).ino;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Cross-game session rotation: the runtime may create a new events.jsonl when a new
|
|
367
|
-
// game starts. If the caller provided a dynamic resolver, poll it and switch
|
|
368
|
-
// tail state to the new file. Without this, the stream would silently follow the
|
|
369
|
-
// previous game's file forever and look like a huge event delay.
|
|
370
|
-
const refreshSessionPath = (): void => {
|
|
371
|
-
if (!opts.getSessionPath) return;
|
|
372
|
-
const latest = opts.getSessionPath();
|
|
373
|
-
if (!latest || latest === currentSessionPath) return;
|
|
374
|
-
currentSessionPath = latest;
|
|
375
|
-
filePos = 0;
|
|
376
|
-
lineBuffer = '';
|
|
377
|
-
try { fileIno = existsSync(latest) ? statSync(latest).ino : 0; } catch { fileIno = 0; }
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
const drainNewEvents = (): GameEvent[] => {
|
|
381
|
-
if (!currentSessionPath || !existsSync(currentSessionPath)) return [];
|
|
382
|
-
let stat;
|
|
383
|
-
try { stat = statSync(currentSessionPath); } catch { return []; }
|
|
384
|
-
if (stat.ino !== fileIno || stat.size < filePos) {
|
|
385
|
-
filePos = 0; fileIno = stat.ino; lineBuffer = '';
|
|
386
|
-
}
|
|
387
|
-
if (stat.size <= filePos) return [];
|
|
388
|
-
let fd: number;
|
|
389
|
-
try { fd = openSync(currentSessionPath, 'r'); } catch { return []; }
|
|
390
|
-
const buf = Buffer.alloc(stat.size - filePos);
|
|
391
|
-
readSync(fd, buf, 0, buf.length, filePos);
|
|
392
|
-
closeSync(fd);
|
|
393
|
-
filePos = stat.size;
|
|
394
|
-
lineBuffer += buf.toString('utf8');
|
|
395
|
-
const lastNl = lineBuffer.lastIndexOf('\n');
|
|
396
|
-
if (lastNl < 0) return [];
|
|
397
|
-
const chunk = lineBuffer.slice(0, lastNl);
|
|
398
|
-
lineBuffer = lineBuffer.slice(lastNl + 1);
|
|
399
|
-
const events: GameEvent[] = [];
|
|
400
|
-
for (const line of chunk.split('\n')) {
|
|
401
|
-
if (!line) continue;
|
|
402
|
-
try {
|
|
403
|
-
const evt = JSON.parse(line);
|
|
404
|
-
if (!isLocalControlEvent(evt)) events.push(evt);
|
|
405
|
-
for (const nested of extractNewEvents(evt) as GameEvent[]) {
|
|
406
|
-
if (!isLocalControlEvent(nested)) events.push(nested);
|
|
407
|
-
}
|
|
408
|
-
} catch {}
|
|
409
|
-
}
|
|
410
|
-
return events;
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
// Pre-attach backlog: drain once, classify, hold for first emit.
|
|
414
|
-
const backlogEvents = drainNewEvents();
|
|
415
|
-
const skipSet = new Set(opts.skipBacklogTypes ?? []);
|
|
416
|
-
const backlogNotable = backlogEvents.filter((e) => {
|
|
417
|
-
if (e.type && skipSet.has(e.type as string)) return false;
|
|
418
|
-
return classifyEvent(e, youName).notable;
|
|
419
|
-
});
|
|
420
|
-
let caughtUpEvents: GameEvent[] = backlogNotable.slice(-20);
|
|
421
|
-
for (const e of backlogEvents) seenKeys.add(eventKey(e));
|
|
422
|
-
|
|
423
|
-
const buildOut = (
|
|
424
|
-
triggers: string[],
|
|
425
|
-
_nextStep: string,
|
|
426
|
-
events: GameEvent[] = [],
|
|
427
|
-
): any => {
|
|
428
|
-
const rawSummary = opts.readSummary?.() ?? readFeedSummary(opts.feedPath);
|
|
429
|
-
const summary = compactSummaryForMonitor(rawSummary, triggers);
|
|
430
|
-
// Server pushes `meeting_briefing` without `speech_order` (that field only lives in
|
|
431
|
-
// meeting state, which the runtime projects onto the owner snapshot).
|
|
432
|
-
// Inject it onto the briefing event here so consumers can read it from events[]
|
|
433
|
-
// directly. Guarded by caller match so a stale speech_order from a previous
|
|
434
|
-
// meeting isn't ever attached.
|
|
435
|
-
const enrichMeetingBriefing = (e: GameEvent): GameEvent => {
|
|
436
|
-
if (e.type !== 'meeting_briefing') return e;
|
|
437
|
-
if (e.speech_order) return e;
|
|
438
|
-
const so = rawSummary?.meeting?.speech_order;
|
|
439
|
-
if (!Array.isArray(so)) return e;
|
|
440
|
-
const sumCaller = rawSummary?.meeting?.caller;
|
|
441
|
-
if (sumCaller && e.caller && sumCaller !== e.caller) return e;
|
|
442
|
-
return { ...e, speech_order: so };
|
|
443
|
-
};
|
|
444
|
-
const sourceEvents = events.length > 0
|
|
445
|
-
? sortEventsForMonitor(events).map(enrichMeetingBriefing)
|
|
446
|
-
: triggers.map((type) => ({ type }));
|
|
447
|
-
const context = {
|
|
448
|
-
summary,
|
|
449
|
-
seats: readSeatMapForMonitor(currentSessionPath),
|
|
450
|
-
maxTextLength: 90,
|
|
451
|
-
};
|
|
452
|
-
return {
|
|
453
|
-
events: sourceEvents.map((event) => event.type),
|
|
454
|
-
messages: sourceEvents.map((event) => formatEventMessage(event, context)),
|
|
455
|
-
state: shortStateText(summary),
|
|
456
|
-
};
|
|
457
|
-
};
|
|
458
|
-
|
|
459
|
-
const fireTrigger = (
|
|
460
|
-
triggers: string[],
|
|
461
|
-
triggeredEvents: GameEvent[],
|
|
462
|
-
): void => {
|
|
463
|
-
if (triggers.length === 0) return;
|
|
464
|
-
const { nextStep } = routeTriggers(triggers);
|
|
465
|
-
const out = buildOut(triggers, nextStep, triggeredEvents.slice(-10));
|
|
466
|
-
if (opts.hubReminder && triggers.includes('game_over')) {
|
|
467
|
-
out.hub_reminder = opts.hubReminder;
|
|
468
|
-
}
|
|
469
|
-
emit(out);
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
// Always emit an attached first beat when there is a pre-attach backlog OR
|
|
473
|
-
// the caller explicitly requests one.
|
|
474
|
-
if (caughtUpEvents.length > 0 || opts.emitGameStart) {
|
|
475
|
-
const hasBacklog = caughtUpEvents.length > 0;
|
|
476
|
-
const nextStep = opts.emitGameStart
|
|
477
|
-
? hasBacklog
|
|
478
|
-
? 'Game stream attached. Read summary and caught_up.notable_events for opening events; current strategy appears in summary.automation.strategy.'
|
|
479
|
-
: 'Game stream attached. Read summary and wait for role_assigned; current strategy appears in summary.automation.strategy.'
|
|
480
|
-
: 'Catch-up complete. Read summary, then continue play.';
|
|
481
|
-
const attachedEvents = hasBacklog ? caughtUpEvents : [{ type: 'game_start' }];
|
|
482
|
-
const attachedOut = buildOut(attachedEvents.map((event) => event.type), nextStep, attachedEvents);
|
|
483
|
-
caughtUpEvents = [];
|
|
484
|
-
emit(attachedOut);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Delay buffer: merge rapid-fire wandering-phase events into one notification
|
|
488
|
-
let delayActive = false;
|
|
489
|
-
let delayDeadline = 0;
|
|
490
|
-
const delayBuffer: { triggers: Set<string>; events: GameEvent[] } = {
|
|
491
|
-
triggers: new Set(),
|
|
492
|
-
events: [],
|
|
493
|
-
};
|
|
494
|
-
|
|
495
|
-
while (!opts.signal?.aborted) {
|
|
496
|
-
refreshSessionPath();
|
|
497
|
-
|
|
498
|
-
const triggers: string[] = [];
|
|
499
|
-
const triggeredEvents: GameEvent[] = [];
|
|
500
|
-
|
|
501
|
-
const newEvents = drainNewEvents();
|
|
502
|
-
for (const evt of newEvents) {
|
|
503
|
-
const cls = classifyEvent(evt, youName);
|
|
504
|
-
if (!cls.notable) continue;
|
|
505
|
-
const key = eventKey(evt);
|
|
506
|
-
if (seenKeys.has(key)) continue;
|
|
507
|
-
seenKeys.add(key);
|
|
508
|
-
triggeredEvents.push(evt);
|
|
509
|
-
if (evt.type && !triggers.includes(evt.type)) {
|
|
510
|
-
triggers.push(evt.type);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// ── Delay buffer: merge rapid-fire wandering-phase events ──
|
|
515
|
-
// Only active during wandering; meeting-phase events (my_turn, speech, etc.)
|
|
516
|
-
// are not in DELAYABLE_EVENT_CONFIG and won't start a delay.
|
|
517
|
-
if (delayActive) {
|
|
518
|
-
if (Date.now() < delayDeadline) {
|
|
519
|
-
// Still within delay window — buffer everything and wait
|
|
520
|
-
for (const t of triggers) delayBuffer.triggers.add(t);
|
|
521
|
-
for (const e of triggeredEvents) delayBuffer.events.push(e);
|
|
522
|
-
triggers.length = 0;
|
|
523
|
-
triggeredEvents.length = 0;
|
|
524
|
-
} else {
|
|
525
|
-
// Delay expired — flush buffer in front of current batch
|
|
526
|
-
for (const t of delayBuffer.triggers) {
|
|
527
|
-
if (!triggers.includes(t)) triggers.unshift(t);
|
|
528
|
-
}
|
|
529
|
-
triggeredEvents.unshift(...delayBuffer.events);
|
|
530
|
-
delayBuffer.triggers.clear();
|
|
531
|
-
delayBuffer.events.length = 0;
|
|
532
|
-
delayActive = false;
|
|
533
|
-
}
|
|
534
|
-
} else if (triggers.length > 0) {
|
|
535
|
-
// Check if any trigger is delayable; start a delay if so
|
|
536
|
-
let delayMs = 0;
|
|
537
|
-
for (const t of triggers) {
|
|
538
|
-
const ms = resolvedDelays[t];
|
|
539
|
-
if (ms !== undefined && ms > delayMs) delayMs = ms;
|
|
540
|
-
}
|
|
541
|
-
if (delayMs > 0) {
|
|
542
|
-
delayActive = true;
|
|
543
|
-
delayDeadline = Date.now() + delayMs;
|
|
544
|
-
for (const t of triggers) delayBuffer.triggers.add(t);
|
|
545
|
-
for (const e of triggeredEvents) delayBuffer.events.push(e);
|
|
546
|
-
triggers.length = 0;
|
|
547
|
-
triggeredEvents.length = 0;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
if (triggers.length > 0) {
|
|
552
|
-
fireTrigger(triggers, triggeredEvents);
|
|
553
|
-
if (triggers.includes('game_over')) return;
|
|
554
|
-
} else if (!delayActive && heartbeatInterval > 0 && Date.now() - lastEmitMs >= heartbeatInterval) {
|
|
555
|
-
// Idle keepalive — prevents downstream adapters from interpreting a quiet
|
|
556
|
-
// wandering/transit period as a hung subprocess and killing it on their
|
|
557
|
-
// own default timeout (commonly 60s).
|
|
558
|
-
emit(buildOut(
|
|
559
|
-
['heartbeat'],
|
|
560
|
-
'Stream is alive; no notable events. Continue waiting — no action required.',
|
|
561
|
-
));
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
if (opts.signal?.aborted) break;
|
|
565
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
export interface SnapshotOnceOptions {
|
|
570
|
-
feedPath: string;
|
|
571
|
-
stdout: (line: string) => void;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
export function snapshotOnce(opts: SnapshotOnceOptions): void {
|
|
575
|
-
if (!existsSync(opts.feedPath)) {
|
|
576
|
-
throw new Error('ccl game runtime is not running. Start it first with `ccl game start`.');
|
|
577
|
-
}
|
|
578
|
-
const summary = readFeedSummary(opts.feedPath);
|
|
579
|
-
opts.stdout(JSON.stringify({
|
|
580
|
-
events: ['snapshot'],
|
|
581
|
-
messages: [formatEventMessage({ type: 'snapshot' })],
|
|
582
|
-
state: shortStateText(summary),
|
|
583
|
-
}) + '\n');
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
export function buildErrorLine(err: any, opts?: { notReadyNextStep?: string }): string {
|
|
587
|
-
const msg = err?.message ?? String(err);
|
|
588
|
-
const notReady = err?.name === 'WatchNotReadyError' || /not ready|feed\.json/i.test(msg);
|
|
589
|
-
const reason = notReady ? 'not_ready' : 'error';
|
|
590
|
-
const event = {
|
|
591
|
-
type: reason,
|
|
592
|
-
message: notReady
|
|
593
|
-
? (opts?.notReadyNextStep ?? 'Game runtime not running. Run `ccl game start` to begin a new session.')
|
|
594
|
-
: msg,
|
|
595
|
-
};
|
|
596
|
-
return JSON.stringify({
|
|
597
|
-
events: [reason],
|
|
598
|
-
messages: [formatEventMessage(event)],
|
|
599
|
-
state: 'state unavailable',
|
|
600
|
-
error: msg,
|
|
601
|
-
}) + '\n';
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export function createWatchCommand(): Command {
|
|
605
|
-
const cmd = new Command('watch')
|
|
606
|
-
.description('Stream notable game events as NDJSON. Exits on game_over. For a one-shot snapshot use `ccl peek`.')
|
|
607
|
-
.action(async () => {
|
|
608
|
-
const store = new AuthStore();
|
|
609
|
-
const profile = store.getActive();
|
|
610
|
-
if (!profile) {
|
|
611
|
-
process.stdout.write(JSON.stringify({ exit_reason: ['not_logged_in'], error: 'Not logged in' }) + '\n');
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
const stateDir = getProfileStateDir(profile);
|
|
615
|
-
const feedPath = join(stateDir, 'feed.json');
|
|
616
|
-
const sessionPath = EventStore.latestSessionPath();
|
|
617
|
-
const ctrl = new AbortController();
|
|
618
|
-
process.on('SIGINT', () => ctrl.abort());
|
|
619
|
-
process.on('SIGTERM', () => ctrl.abort());
|
|
620
|
-
let summaryCache: any | null = null;
|
|
621
|
-
let summaryPoll: ReturnType<typeof setInterval> | null = null;
|
|
622
|
-
try {
|
|
623
|
-
const snapshot = await sendOwnerControlRequest(stateDir, 'snapshot');
|
|
624
|
-
if (!snapshot?.ok) {
|
|
625
|
-
throw new WatchNotReadyError(
|
|
626
|
-
'Game runtime not ready (no active ccl game start owner). Run `ccl game start` to start or recover the game stream.',
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
summaryCache = snapshot.summary ?? null;
|
|
630
|
-
summaryPoll = setInterval(() => {
|
|
631
|
-
void sendOwnerControlRequest(stateDir, 'snapshot')
|
|
632
|
-
.then((response) => {
|
|
633
|
-
if (response?.ok) summaryCache = response.summary ?? null;
|
|
634
|
-
})
|
|
635
|
-
.catch(() => {});
|
|
636
|
-
}, 1000);
|
|
637
|
-
await runStreaming({
|
|
638
|
-
feedPath,
|
|
639
|
-
sessionPath,
|
|
640
|
-
getSessionPath: () => EventStore.latestSessionPath(),
|
|
641
|
-
stdout: (s) => process.stdout.write(s),
|
|
642
|
-
signal: ctrl.signal,
|
|
643
|
-
skipFeedWait: true,
|
|
644
|
-
readSummary: () => summaryCache,
|
|
645
|
-
hubReminder: hubReminder(readCachedGamesPlayed()),
|
|
646
|
-
});
|
|
647
|
-
} catch (err: any) {
|
|
648
|
-
process.stdout.write(buildErrorLine(err, {
|
|
649
|
-
notReadyNextStep: 'Run `ccl game start` to start or recover the game stream.',
|
|
650
|
-
}));
|
|
651
|
-
process.exit(1);
|
|
652
|
-
} finally {
|
|
653
|
-
if (summaryPoll) clearInterval(summaryPoll);
|
|
654
|
-
}
|
|
655
|
-
});
|
|
656
|
-
// ── Adapter metadata: streaming mode blocks until game_over; cap at 30min for stuck-process safety ──
|
|
657
|
-
setMeta(cmd, { longRunning: true, timeoutMs: 1_800_000 });
|
|
658
|
-
return cmd;
|
|
659
|
-
}
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { existsSync, readFileSync, openSync, readSync, closeSync, statSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { AuthStore } from '../lib/auth.js';
|
|
5
|
+
import { getProfileStateDir } from '../lib/init-command.js';
|
|
6
|
+
import { setMeta } from '../lib/command-meta.js';
|
|
7
|
+
import { EventStore, extractNewEvents, isLocalControlEvent } from '../pipeline/event-store.js';
|
|
8
|
+
import { compactEventForEvents, formatEventMessage, shortStateText } from '../pipeline/event-format.js';
|
|
9
|
+
import { PlayerHistoryStore } from '../perception/player-history-store.js';
|
|
10
|
+
import { DEFAULT_MATCH_TIMEOUT_MS } from '../lib/match-state.js';
|
|
11
|
+
import { hubReminder, readCachedGamesPlayed } from '../lib/hub-reminder.js';
|
|
12
|
+
import { sendOwnerControlRequest } from '../runtime/owner-control.js';
|
|
13
|
+
|
|
14
|
+
export const POLL_INTERVAL_MS = 220;
|
|
15
|
+
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
16
|
+
const RUNTIME_WAIT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
export class WatchNotReadyError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'WatchNotReadyError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Legacy helper for tests and explicit file-backed attaches. Owner paths use control socket snapshots. */
|
|
26
|
+
export async function waitForFeed(
|
|
27
|
+
feedPath: string,
|
|
28
|
+
opts?: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number },
|
|
29
|
+
): Promise<boolean> {
|
|
30
|
+
const timeoutMs = opts?.timeoutMs ?? RUNTIME_WAIT_MS;
|
|
31
|
+
const pollMs = opts?.pollMs ?? POLL_INTERVAL_MS;
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
if (existsSync(feedPath)) return true;
|
|
35
|
+
if (opts?.signal?.aborted) return false;
|
|
36
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
37
|
+
}
|
|
38
|
+
return existsSync(feedPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MONITOR_EVENT_PRIORITY = 50;
|
|
42
|
+
|
|
43
|
+
export interface MonitorEventConfig {
|
|
44
|
+
priority: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const MONITOR_EVENT_CONFIG: Record<string, MonitorEventConfig> = {
|
|
48
|
+
kill: { priority: 50 },
|
|
49
|
+
killed: { priority: 99 },
|
|
50
|
+
murder_witnessed: { priority: 50 },
|
|
51
|
+
warrior_shrimp_self_destruct: { priority: 50 },
|
|
52
|
+
|
|
53
|
+
task_completed: { priority: 50 },
|
|
54
|
+
task_sabotaged: { priority: 50 },
|
|
55
|
+
|
|
56
|
+
corpse_spotted: { priority: 50 },
|
|
57
|
+
octopus_time_start: { priority: 50 },
|
|
58
|
+
|
|
59
|
+
emergency_started: { priority: 50 },
|
|
60
|
+
emergency_resolved: { priority: 50 },
|
|
61
|
+
|
|
62
|
+
exile: { priority: 50 },
|
|
63
|
+
no_exile: { priority: 50 },
|
|
64
|
+
|
|
65
|
+
vote_speech_phase_ended: { priority: 50 },
|
|
66
|
+
death_speech: { priority: 50 },
|
|
67
|
+
wandering_speech: { priority: 50 },
|
|
68
|
+
|
|
69
|
+
meeting_briefing: { priority: 50 },
|
|
70
|
+
speech: { priority: 50 },
|
|
71
|
+
speech_skipped: { priority: 50 },
|
|
72
|
+
speech_your_turn: { priority: 99 },
|
|
73
|
+
|
|
74
|
+
vote_phase_start: { priority: 99 },
|
|
75
|
+
vote_cast: { priority: 50 },
|
|
76
|
+
meeting_ended: { priority: 50 },
|
|
77
|
+
|
|
78
|
+
game_over: { priority: 50 },
|
|
79
|
+
role_assigned: { priority: 50 },
|
|
80
|
+
// game_started intentionally not registered.
|
|
81
|
+
|
|
82
|
+
// #region: Matchmaking-phase synthetic events
|
|
83
|
+
match_waiting: { priority: 50 },
|
|
84
|
+
match_timeout: { priority: 50 },
|
|
85
|
+
robot_speak_rule: { priority: 50 },
|
|
86
|
+
// #endregion
|
|
87
|
+
|
|
88
|
+
// Strategy → agent real-time briefing (the automation explaining a decisive
|
|
89
|
+
// autonomous move, e.g. self-reporting its own kill). Ranked above ambient
|
|
90
|
+
// wander events so it leads the events[] list.
|
|
91
|
+
strategy_alert: { priority: 60 },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const NOTABLE_EVENT_TYPES = new Set(Object.keys(MONITOR_EVENT_CONFIG));
|
|
95
|
+
|
|
96
|
+
/** Events whose trigger should be delayed to allow merging with nearby events.
|
|
97
|
+
* Key = event type, Value = delay in milliseconds.
|
|
98
|
+
* During the delay window, all incoming events (both delayable and non-delayable)
|
|
99
|
+
* are buffered and emitted together when the timer expires.
|
|
100
|
+
* Only one delay runs at a time — subsequent delayable events do NOT reset the timer. */
|
|
101
|
+
export const DELAYABLE_EVENT_CONFIG: Record<string, number> = {
|
|
102
|
+
kill: 2000,
|
|
103
|
+
emergency_started: 2000,
|
|
104
|
+
corpse_spotted: 2000,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export interface GameEvent {
|
|
108
|
+
type: string;
|
|
109
|
+
tick?: number;
|
|
110
|
+
ts?: string;
|
|
111
|
+
[key: string]: any;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function eventKey(evt: GameEvent): string {
|
|
115
|
+
const type = evt.type ?? '?';
|
|
116
|
+
const tick = evt.tick ?? 0;
|
|
117
|
+
const parts = [
|
|
118
|
+
evt.actor_name,
|
|
119
|
+
evt.spotted_name,
|
|
120
|
+
evt.killer_name,
|
|
121
|
+
evt.result_target,
|
|
122
|
+
evt.target_name,
|
|
123
|
+
evt.corpse_name,
|
|
124
|
+
evt.task_name,
|
|
125
|
+
].filter(Boolean);
|
|
126
|
+
const disc = parts.length > 0 ? parts.join('|') : '';
|
|
127
|
+
return `${type}@${tick}#${disc}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface EventClassification {
|
|
131
|
+
notable: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function classifyEvent(evt: GameEvent, _youName: string | null = null): EventClassification {
|
|
135
|
+
const notable = !!(evt.type && NOTABLE_EVENT_TYPES.has(evt.type));
|
|
136
|
+
return { notable };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function monitorEventPriority(event: GameEvent): number {
|
|
140
|
+
return MONITOR_EVENT_CONFIG[event.type]?.priority ?? DEFAULT_MONITOR_EVENT_PRIORITY;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface RouteRule {
|
|
144
|
+
reason: string;
|
|
145
|
+
match: (triggers: string[]) => boolean;
|
|
146
|
+
nextStep: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ROUTING_RULES: RouteRule[] = [
|
|
150
|
+
{ reason: 'speech_your_turn', match: (t) => t.includes('speech_your_turn'), nextStep: 'It is YOUR turn to speak. Submit `ccl do -s "<draft>"` immediately. Server skips you after 45s.' },
|
|
151
|
+
{ reason: 'role_assigned', match: (t) => t.includes('role_assigned'), nextStep: 'Tell user your role, faction, win condition, and first plan.' },
|
|
152
|
+
{ reason: 'vote_cast', match: (t) => t.includes('vote_cast'), nextStep: 'A player just cast their vote. Read events[] to track who voted. No `ccl` action needed — cast your own vote immediately when vote phase starts.' },
|
|
153
|
+
{ reason: 'match_start', match: (t) => t.includes('match_start'), nextStep: 'Matchmaking queue entered. The game runtime is live and the stream is attached. Chat with the user while waiting for allocation.' },
|
|
154
|
+
{ reason: 'match_waiting', match: (t) => t.includes('match_waiting'), nextStep: 'Still in queue (see `events[].waited_secs`). Keep chatting with the user; no tactical action required.' },
|
|
155
|
+
{ reason: 'match_timeout', match: (t) => t.includes('match_timeout'), nextStep: `Cumulative wait reached ${Math.round(DEFAULT_MATCH_TIMEOUT_MS / 60_000)} min (see \`events[].waited_secs\`). The stream will exit — discuss with the user: launch a fresh \`ccl game start\` to retry, or call it a session.` },
|
|
156
|
+
{ reason: 'game_over', match: (t) => t.includes('game_over'), nextStep: 'Game ended. The current ccl game start process is exiting automatically. Review the result with the user; start another match only if the user asks.' },
|
|
157
|
+
{ reason: 'strategy_alert', match: (t) => t.includes('strategy_alert'), nextStep: 'Your automation just made a decisive autonomous move and is briefing you (read events[].message). Lock your story to it before you next speak or vote — e.g. if it reported a corpse that is likely your own kill, present yourself as the proactive reporter, steer suspicion onto the bystanders, and do NOT confess.' },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const DEFAULT_NEXT_STEP = 'React to summary fields. No relaunch needed — the stream stays attached.';
|
|
161
|
+
|
|
162
|
+
function routeTriggers(triggers: string[]): { exitReason: string; nextStep: string } {
|
|
163
|
+
const rule = ROUTING_RULES.find((r) => r.match(triggers));
|
|
164
|
+
return {
|
|
165
|
+
exitReason: rule?.reason ?? triggers[0],
|
|
166
|
+
nextStep: rule?.nextStep ?? DEFAULT_NEXT_STEP,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function nextStepFor(reason: string): string {
|
|
171
|
+
return routeTriggers([reason]).nextStep;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function nextStepForTriggers(triggers: string[]): string {
|
|
175
|
+
return routeTriggers(triggers).nextStep;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function sortEventsForMonitor(events: GameEvent[]): GameEvent[] {
|
|
179
|
+
return events
|
|
180
|
+
.map((event, index) => ({
|
|
181
|
+
event,
|
|
182
|
+
index,
|
|
183
|
+
priority: monitorEventPriority(event),
|
|
184
|
+
}))
|
|
185
|
+
.sort((a, b) => (b.priority - a.priority) || (a.index - b.index))
|
|
186
|
+
.map(({ event }) => event);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readSeatMapForMonitor(sessionPath: string | null): Record<string, number> {
|
|
190
|
+
if (!sessionPath) return {};
|
|
191
|
+
try {
|
|
192
|
+
return PlayerHistoryStore.forSession(sessionPath).read()?.seats ?? {};
|
|
193
|
+
} catch {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function cleanObject<T extends Record<string, any>>(obj: T): T {
|
|
199
|
+
for (const key of Object.keys(obj)) {
|
|
200
|
+
if (obj[key] === undefined) delete obj[key];
|
|
201
|
+
}
|
|
202
|
+
return obj;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function compactEventForMonitor(event: GameEvent): Record<string, any> {
|
|
206
|
+
return compactEventForEvents(event);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function summarizeFeed(feed: any): any | null {
|
|
210
|
+
if (!feed || typeof feed !== 'object') return null;
|
|
211
|
+
const summary: any = {
|
|
212
|
+
phase: feed.phase,
|
|
213
|
+
you: {
|
|
214
|
+
name: feed.you?.name,
|
|
215
|
+
seat: feed.you?.seat,
|
|
216
|
+
role: feed.you?.role,
|
|
217
|
+
role_display: feed.you?.role_display,
|
|
218
|
+
faction: feed.you?.faction,
|
|
219
|
+
alive: feed.you?.alive,
|
|
220
|
+
x: feed.you?.x,
|
|
221
|
+
y: feed.you?.y,
|
|
222
|
+
currently_moving: feed.you?.currently_moving,
|
|
223
|
+
doing_task: feed.you?.doing_task,
|
|
224
|
+
kill_cooldown_secs: feed.you?.kill_cooldown_secs,
|
|
225
|
+
kills_remaining: feed.you?.kills_remaining,
|
|
226
|
+
},
|
|
227
|
+
game: feed.game,
|
|
228
|
+
urgent: feed.urgent,
|
|
229
|
+
meeting: feed.meeting,
|
|
230
|
+
};
|
|
231
|
+
if (feed.automation) summary.automation = feed.automation;
|
|
232
|
+
return summary;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function readFeedSummary(feedPath: string): any | null {
|
|
236
|
+
try {
|
|
237
|
+
const feed = JSON.parse(readFileSync(feedPath, 'utf8'));
|
|
238
|
+
return summarizeFeed(feed);
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function compactSummaryForMonitor(summary: any | null, triggers: string[]): any | null {
|
|
245
|
+
if (!summary || summary.phase !== 'meeting') return summary;
|
|
246
|
+
const oldMeeting = summary.meeting ?? {};
|
|
247
|
+
const oldYou = summary.you ?? {};
|
|
248
|
+
const isVotePhase = triggers.includes('vote_phase_start')
|
|
249
|
+
|| (!triggers.includes('speech_your_turn') && oldMeeting.sub_phase === 'vote');
|
|
250
|
+
const meeting: Record<string, any> = {
|
|
251
|
+
caller: oldMeeting.caller,
|
|
252
|
+
sub_phase: triggers.includes('vote_phase_start') ? 'vote' : oldMeeting.sub_phase,
|
|
253
|
+
alive_players: oldMeeting.alive_players,
|
|
254
|
+
};
|
|
255
|
+
if (triggers.includes('speech_your_turn')) {
|
|
256
|
+
meeting.sub_phase = 'speech';
|
|
257
|
+
meeting.current_speaker = oldYou.name;
|
|
258
|
+
meeting.is_my_turn = true;
|
|
259
|
+
} else if (!isVotePhase) {
|
|
260
|
+
meeting.current_speaker = oldMeeting.current_speaker;
|
|
261
|
+
meeting.is_my_turn = oldMeeting.is_my_turn;
|
|
262
|
+
}
|
|
263
|
+
if (isVotePhase) {
|
|
264
|
+
meeting.votes_submitted_count = Array.isArray(oldMeeting.votes_submitted)
|
|
265
|
+
? oldMeeting.votes_submitted.length
|
|
266
|
+
: 0;
|
|
267
|
+
}
|
|
268
|
+
return cleanObject({
|
|
269
|
+
phase: summary.phase,
|
|
270
|
+
you: cleanObject({
|
|
271
|
+
name: oldYou.name,
|
|
272
|
+
role: oldYou.role,
|
|
273
|
+
faction: oldYou.faction,
|
|
274
|
+
}),
|
|
275
|
+
game: summary.game,
|
|
276
|
+
meeting: cleanObject(meeting),
|
|
277
|
+
automation: summary.automation,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface RunStreamingOptions {
|
|
282
|
+
feedPath: string;
|
|
283
|
+
sessionPath: string | null;
|
|
284
|
+
readSummary?: () => any | null;
|
|
285
|
+
/** Optional dynamic resolver — called each poll so the stream can follow session rotation across games. */
|
|
286
|
+
getSessionPath?: () => string | null;
|
|
287
|
+
stdout: (line: string) => void;
|
|
288
|
+
signal?: AbortSignal;
|
|
289
|
+
skipFeedWait?: boolean;
|
|
290
|
+
pollIntervalMs?: number;
|
|
291
|
+
/** Emit a `heartbeat` NDJSON line after this many ms of silence so consumers don't see the stream as hung. Default 30s. Pass 0 to disable. */
|
|
292
|
+
heartbeatIntervalMs?: number;
|
|
293
|
+
/** Max ms to wait for feed.json when launching alongside `game start`. Default 30s. */
|
|
294
|
+
runtimeWaitMs?: number;
|
|
295
|
+
/**
|
|
296
|
+
* Per-event-type delay overrides for the delay buffer (see DELAYABLE_EVENT_CONFIG).
|
|
297
|
+
* Pass `{ kill: 0 }` to disable the kill delay in tests.
|
|
298
|
+
* Values from DELAYABLE_EVENT_CONFIG are used for any type not overridden here.
|
|
299
|
+
*/
|
|
300
|
+
delayableEventMs?: Record<string, number>;
|
|
301
|
+
/**
|
|
302
|
+
* Event types to exclude from the initial `caught_up` backlog (useful when the
|
|
303
|
+
* caller already emitted them live before chaining into runStreaming, e.g.
|
|
304
|
+
* `ccl game start` emits `match_waiting` / `match_timeout` during its polling
|
|
305
|
+
* phase). Does NOT affect ongoing event tailing — only the one-time pre-attach
|
|
306
|
+
* backlog drain.
|
|
307
|
+
*/
|
|
308
|
+
skipBacklogTypes?: string[];
|
|
309
|
+
/**
|
|
310
|
+
* Emit a first `game_start` beat even when no backlog is present. `ccl game start`
|
|
311
|
+
* uses this to tell the agent the stream is attached without carrying a huge
|
|
312
|
+
* `initial_payload`.
|
|
313
|
+
*/
|
|
314
|
+
emitGameStart?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* When set, attached to the `game_over` emission (as a `hub_reminder` field
|
|
317
|
+
* and appended to `next_step`) so the agent sees the nudge in the same beat it
|
|
318
|
+
* starts the post-game debrief. Callers compute it via `hubReminder()`; pass
|
|
319
|
+
* nothing once the account has adopted a hub or custom strategy.
|
|
320
|
+
*/
|
|
321
|
+
hubReminder?: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function runStreaming(opts: RunStreamingOptions): Promise<void> {
|
|
325
|
+
if (!opts.skipFeedWait) {
|
|
326
|
+
const feedReady = await waitForFeed(opts.feedPath, {
|
|
327
|
+
signal: opts.signal,
|
|
328
|
+
timeoutMs: opts.runtimeWaitMs ?? RUNTIME_WAIT_MS,
|
|
329
|
+
pollMs: opts.pollIntervalMs ?? POLL_INTERVAL_MS,
|
|
330
|
+
});
|
|
331
|
+
if (!feedReady) {
|
|
332
|
+
throw new WatchNotReadyError(
|
|
333
|
+
'Game runtime not ready (no feed.json). Run `ccl game start` to start or recover the game stream.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
338
|
+
const heartbeatInterval = opts.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
339
|
+
const resolvedDelays: Record<string, number> = { ...DELAYABLE_EVENT_CONFIG, ...(opts.delayableEventMs ?? {}) };
|
|
340
|
+
let lastEmitMs = Date.now();
|
|
341
|
+
const emit = (obj: any): void => {
|
|
342
|
+
opts.stdout(JSON.stringify(obj) + '\n');
|
|
343
|
+
lastEmitMs = Date.now();
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Initial player name from owner snapshot or legacy feed file (best-effort).
|
|
347
|
+
let youName: string | null = null;
|
|
348
|
+
try {
|
|
349
|
+
youName = opts.readSummary?.()?.you?.name
|
|
350
|
+
?? JSON.parse(readFileSync(opts.feedPath, 'utf8'))?.you?.name
|
|
351
|
+
?? null;
|
|
352
|
+
} catch {}
|
|
353
|
+
|
|
354
|
+
// Tail state for the .jsonl
|
|
355
|
+
const seenKeys = new Set<string>();
|
|
356
|
+
let filePos = 0;
|
|
357
|
+
let fileIno = 0;
|
|
358
|
+
let lineBuffer = '';
|
|
359
|
+
let currentSessionPath: string | null = opts.sessionPath;
|
|
360
|
+
|
|
361
|
+
if (currentSessionPath && existsSync(currentSessionPath)) {
|
|
362
|
+
filePos = 0;
|
|
363
|
+
fileIno = statSync(currentSessionPath).ino;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Cross-game session rotation: the runtime may create a new events.jsonl when a new
|
|
367
|
+
// game starts. If the caller provided a dynamic resolver, poll it and switch
|
|
368
|
+
// tail state to the new file. Without this, the stream would silently follow the
|
|
369
|
+
// previous game's file forever and look like a huge event delay.
|
|
370
|
+
const refreshSessionPath = (): void => {
|
|
371
|
+
if (!opts.getSessionPath) return;
|
|
372
|
+
const latest = opts.getSessionPath();
|
|
373
|
+
if (!latest || latest === currentSessionPath) return;
|
|
374
|
+
currentSessionPath = latest;
|
|
375
|
+
filePos = 0;
|
|
376
|
+
lineBuffer = '';
|
|
377
|
+
try { fileIno = existsSync(latest) ? statSync(latest).ino : 0; } catch { fileIno = 0; }
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const drainNewEvents = (): GameEvent[] => {
|
|
381
|
+
if (!currentSessionPath || !existsSync(currentSessionPath)) return [];
|
|
382
|
+
let stat;
|
|
383
|
+
try { stat = statSync(currentSessionPath); } catch { return []; }
|
|
384
|
+
if (stat.ino !== fileIno || stat.size < filePos) {
|
|
385
|
+
filePos = 0; fileIno = stat.ino; lineBuffer = '';
|
|
386
|
+
}
|
|
387
|
+
if (stat.size <= filePos) return [];
|
|
388
|
+
let fd: number;
|
|
389
|
+
try { fd = openSync(currentSessionPath, 'r'); } catch { return []; }
|
|
390
|
+
const buf = Buffer.alloc(stat.size - filePos);
|
|
391
|
+
readSync(fd, buf, 0, buf.length, filePos);
|
|
392
|
+
closeSync(fd);
|
|
393
|
+
filePos = stat.size;
|
|
394
|
+
lineBuffer += buf.toString('utf8');
|
|
395
|
+
const lastNl = lineBuffer.lastIndexOf('\n');
|
|
396
|
+
if (lastNl < 0) return [];
|
|
397
|
+
const chunk = lineBuffer.slice(0, lastNl);
|
|
398
|
+
lineBuffer = lineBuffer.slice(lastNl + 1);
|
|
399
|
+
const events: GameEvent[] = [];
|
|
400
|
+
for (const line of chunk.split('\n')) {
|
|
401
|
+
if (!line) continue;
|
|
402
|
+
try {
|
|
403
|
+
const evt = JSON.parse(line);
|
|
404
|
+
if (!isLocalControlEvent(evt)) events.push(evt);
|
|
405
|
+
for (const nested of extractNewEvents(evt) as GameEvent[]) {
|
|
406
|
+
if (!isLocalControlEvent(nested)) events.push(nested);
|
|
407
|
+
}
|
|
408
|
+
} catch {}
|
|
409
|
+
}
|
|
410
|
+
return events;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// Pre-attach backlog: drain once, classify, hold for first emit.
|
|
414
|
+
const backlogEvents = drainNewEvents();
|
|
415
|
+
const skipSet = new Set(opts.skipBacklogTypes ?? []);
|
|
416
|
+
const backlogNotable = backlogEvents.filter((e) => {
|
|
417
|
+
if (e.type && skipSet.has(e.type as string)) return false;
|
|
418
|
+
return classifyEvent(e, youName).notable;
|
|
419
|
+
});
|
|
420
|
+
let caughtUpEvents: GameEvent[] = backlogNotable.slice(-20);
|
|
421
|
+
for (const e of backlogEvents) seenKeys.add(eventKey(e));
|
|
422
|
+
|
|
423
|
+
const buildOut = (
|
|
424
|
+
triggers: string[],
|
|
425
|
+
_nextStep: string,
|
|
426
|
+
events: GameEvent[] = [],
|
|
427
|
+
): any => {
|
|
428
|
+
const rawSummary = opts.readSummary?.() ?? readFeedSummary(opts.feedPath);
|
|
429
|
+
const summary = compactSummaryForMonitor(rawSummary, triggers);
|
|
430
|
+
// Server pushes `meeting_briefing` without `speech_order` (that field only lives in
|
|
431
|
+
// meeting state, which the runtime projects onto the owner snapshot).
|
|
432
|
+
// Inject it onto the briefing event here so consumers can read it from events[]
|
|
433
|
+
// directly. Guarded by caller match so a stale speech_order from a previous
|
|
434
|
+
// meeting isn't ever attached.
|
|
435
|
+
const enrichMeetingBriefing = (e: GameEvent): GameEvent => {
|
|
436
|
+
if (e.type !== 'meeting_briefing') return e;
|
|
437
|
+
if (e.speech_order) return e;
|
|
438
|
+
const so = rawSummary?.meeting?.speech_order;
|
|
439
|
+
if (!Array.isArray(so)) return e;
|
|
440
|
+
const sumCaller = rawSummary?.meeting?.caller;
|
|
441
|
+
if (sumCaller && e.caller && sumCaller !== e.caller) return e;
|
|
442
|
+
return { ...e, speech_order: so };
|
|
443
|
+
};
|
|
444
|
+
const sourceEvents = events.length > 0
|
|
445
|
+
? sortEventsForMonitor(events).map(enrichMeetingBriefing)
|
|
446
|
+
: triggers.map((type) => ({ type }));
|
|
447
|
+
const context = {
|
|
448
|
+
summary,
|
|
449
|
+
seats: readSeatMapForMonitor(currentSessionPath),
|
|
450
|
+
maxTextLength: 90,
|
|
451
|
+
};
|
|
452
|
+
return {
|
|
453
|
+
events: sourceEvents.map((event) => event.type),
|
|
454
|
+
messages: sourceEvents.map((event) => formatEventMessage(event, context)),
|
|
455
|
+
state: shortStateText(summary),
|
|
456
|
+
};
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const fireTrigger = (
|
|
460
|
+
triggers: string[],
|
|
461
|
+
triggeredEvents: GameEvent[],
|
|
462
|
+
): void => {
|
|
463
|
+
if (triggers.length === 0) return;
|
|
464
|
+
const { nextStep } = routeTriggers(triggers);
|
|
465
|
+
const out = buildOut(triggers, nextStep, triggeredEvents.slice(-10));
|
|
466
|
+
if (opts.hubReminder && triggers.includes('game_over')) {
|
|
467
|
+
out.hub_reminder = opts.hubReminder;
|
|
468
|
+
}
|
|
469
|
+
emit(out);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// Always emit an attached first beat when there is a pre-attach backlog OR
|
|
473
|
+
// the caller explicitly requests one.
|
|
474
|
+
if (caughtUpEvents.length > 0 || opts.emitGameStart) {
|
|
475
|
+
const hasBacklog = caughtUpEvents.length > 0;
|
|
476
|
+
const nextStep = opts.emitGameStart
|
|
477
|
+
? hasBacklog
|
|
478
|
+
? 'Game stream attached. Read summary and caught_up.notable_events for opening events; current strategy appears in summary.automation.strategy.'
|
|
479
|
+
: 'Game stream attached. Read summary and wait for role_assigned; current strategy appears in summary.automation.strategy.'
|
|
480
|
+
: 'Catch-up complete. Read summary, then continue play.';
|
|
481
|
+
const attachedEvents = hasBacklog ? caughtUpEvents : [{ type: 'game_start' }];
|
|
482
|
+
const attachedOut = buildOut(attachedEvents.map((event) => event.type), nextStep, attachedEvents);
|
|
483
|
+
caughtUpEvents = [];
|
|
484
|
+
emit(attachedOut);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Delay buffer: merge rapid-fire wandering-phase events into one notification
|
|
488
|
+
let delayActive = false;
|
|
489
|
+
let delayDeadline = 0;
|
|
490
|
+
const delayBuffer: { triggers: Set<string>; events: GameEvent[] } = {
|
|
491
|
+
triggers: new Set(),
|
|
492
|
+
events: [],
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
while (!opts.signal?.aborted) {
|
|
496
|
+
refreshSessionPath();
|
|
497
|
+
|
|
498
|
+
const triggers: string[] = [];
|
|
499
|
+
const triggeredEvents: GameEvent[] = [];
|
|
500
|
+
|
|
501
|
+
const newEvents = drainNewEvents();
|
|
502
|
+
for (const evt of newEvents) {
|
|
503
|
+
const cls = classifyEvent(evt, youName);
|
|
504
|
+
if (!cls.notable) continue;
|
|
505
|
+
const key = eventKey(evt);
|
|
506
|
+
if (seenKeys.has(key)) continue;
|
|
507
|
+
seenKeys.add(key);
|
|
508
|
+
triggeredEvents.push(evt);
|
|
509
|
+
if (evt.type && !triggers.includes(evt.type)) {
|
|
510
|
+
triggers.push(evt.type);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Delay buffer: merge rapid-fire wandering-phase events ──
|
|
515
|
+
// Only active during wandering; meeting-phase events (my_turn, speech, etc.)
|
|
516
|
+
// are not in DELAYABLE_EVENT_CONFIG and won't start a delay.
|
|
517
|
+
if (delayActive) {
|
|
518
|
+
if (Date.now() < delayDeadline) {
|
|
519
|
+
// Still within delay window — buffer everything and wait
|
|
520
|
+
for (const t of triggers) delayBuffer.triggers.add(t);
|
|
521
|
+
for (const e of triggeredEvents) delayBuffer.events.push(e);
|
|
522
|
+
triggers.length = 0;
|
|
523
|
+
triggeredEvents.length = 0;
|
|
524
|
+
} else {
|
|
525
|
+
// Delay expired — flush buffer in front of current batch
|
|
526
|
+
for (const t of delayBuffer.triggers) {
|
|
527
|
+
if (!triggers.includes(t)) triggers.unshift(t);
|
|
528
|
+
}
|
|
529
|
+
triggeredEvents.unshift(...delayBuffer.events);
|
|
530
|
+
delayBuffer.triggers.clear();
|
|
531
|
+
delayBuffer.events.length = 0;
|
|
532
|
+
delayActive = false;
|
|
533
|
+
}
|
|
534
|
+
} else if (triggers.length > 0) {
|
|
535
|
+
// Check if any trigger is delayable; start a delay if so
|
|
536
|
+
let delayMs = 0;
|
|
537
|
+
for (const t of triggers) {
|
|
538
|
+
const ms = resolvedDelays[t];
|
|
539
|
+
if (ms !== undefined && ms > delayMs) delayMs = ms;
|
|
540
|
+
}
|
|
541
|
+
if (delayMs > 0) {
|
|
542
|
+
delayActive = true;
|
|
543
|
+
delayDeadline = Date.now() + delayMs;
|
|
544
|
+
for (const t of triggers) delayBuffer.triggers.add(t);
|
|
545
|
+
for (const e of triggeredEvents) delayBuffer.events.push(e);
|
|
546
|
+
triggers.length = 0;
|
|
547
|
+
triggeredEvents.length = 0;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (triggers.length > 0) {
|
|
552
|
+
fireTrigger(triggers, triggeredEvents);
|
|
553
|
+
if (triggers.includes('game_over')) return;
|
|
554
|
+
} else if (!delayActive && heartbeatInterval > 0 && Date.now() - lastEmitMs >= heartbeatInterval) {
|
|
555
|
+
// Idle keepalive — prevents downstream adapters from interpreting a quiet
|
|
556
|
+
// wandering/transit period as a hung subprocess and killing it on their
|
|
557
|
+
// own default timeout (commonly 60s).
|
|
558
|
+
emit(buildOut(
|
|
559
|
+
['heartbeat'],
|
|
560
|
+
'Stream is alive; no notable events. Continue waiting — no action required.',
|
|
561
|
+
));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (opts.signal?.aborted) break;
|
|
565
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export interface SnapshotOnceOptions {
|
|
570
|
+
feedPath: string;
|
|
571
|
+
stdout: (line: string) => void;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function snapshotOnce(opts: SnapshotOnceOptions): void {
|
|
575
|
+
if (!existsSync(opts.feedPath)) {
|
|
576
|
+
throw new Error('ccl game runtime is not running. Start it first with `ccl game start`.');
|
|
577
|
+
}
|
|
578
|
+
const summary = readFeedSummary(opts.feedPath);
|
|
579
|
+
opts.stdout(JSON.stringify({
|
|
580
|
+
events: ['snapshot'],
|
|
581
|
+
messages: [formatEventMessage({ type: 'snapshot' })],
|
|
582
|
+
state: shortStateText(summary),
|
|
583
|
+
}) + '\n');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function buildErrorLine(err: any, opts?: { notReadyNextStep?: string }): string {
|
|
587
|
+
const msg = err?.message ?? String(err);
|
|
588
|
+
const notReady = err?.name === 'WatchNotReadyError' || /not ready|feed\.json/i.test(msg);
|
|
589
|
+
const reason = notReady ? 'not_ready' : 'error';
|
|
590
|
+
const event = {
|
|
591
|
+
type: reason,
|
|
592
|
+
message: notReady
|
|
593
|
+
? (opts?.notReadyNextStep ?? 'Game runtime not running. Run `ccl game start` to begin a new session.')
|
|
594
|
+
: msg,
|
|
595
|
+
};
|
|
596
|
+
return JSON.stringify({
|
|
597
|
+
events: [reason],
|
|
598
|
+
messages: [formatEventMessage(event)],
|
|
599
|
+
state: 'state unavailable',
|
|
600
|
+
error: msg,
|
|
601
|
+
}) + '\n';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function createWatchCommand(): Command {
|
|
605
|
+
const cmd = new Command('watch')
|
|
606
|
+
.description('Stream notable game events as NDJSON. Exits on game_over. For a one-shot snapshot use `ccl peek`.')
|
|
607
|
+
.action(async () => {
|
|
608
|
+
const store = new AuthStore();
|
|
609
|
+
const profile = store.getActive();
|
|
610
|
+
if (!profile) {
|
|
611
|
+
process.stdout.write(JSON.stringify({ exit_reason: ['not_logged_in'], error: 'Not logged in' }) + '\n');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const stateDir = getProfileStateDir(profile);
|
|
615
|
+
const feedPath = join(stateDir, 'feed.json');
|
|
616
|
+
const sessionPath = EventStore.latestSessionPath();
|
|
617
|
+
const ctrl = new AbortController();
|
|
618
|
+
process.on('SIGINT', () => ctrl.abort());
|
|
619
|
+
process.on('SIGTERM', () => ctrl.abort());
|
|
620
|
+
let summaryCache: any | null = null;
|
|
621
|
+
let summaryPoll: ReturnType<typeof setInterval> | null = null;
|
|
622
|
+
try {
|
|
623
|
+
const snapshot = await sendOwnerControlRequest(stateDir, 'snapshot');
|
|
624
|
+
if (!snapshot?.ok) {
|
|
625
|
+
throw new WatchNotReadyError(
|
|
626
|
+
'Game runtime not ready (no active ccl game start owner). Run `ccl game start` to start or recover the game stream.',
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
summaryCache = snapshot.summary ?? null;
|
|
630
|
+
summaryPoll = setInterval(() => {
|
|
631
|
+
void sendOwnerControlRequest(stateDir, 'snapshot')
|
|
632
|
+
.then((response) => {
|
|
633
|
+
if (response?.ok) summaryCache = response.summary ?? null;
|
|
634
|
+
})
|
|
635
|
+
.catch(() => {});
|
|
636
|
+
}, 1000);
|
|
637
|
+
await runStreaming({
|
|
638
|
+
feedPath,
|
|
639
|
+
sessionPath,
|
|
640
|
+
getSessionPath: () => EventStore.latestSessionPath(),
|
|
641
|
+
stdout: (s) => process.stdout.write(s),
|
|
642
|
+
signal: ctrl.signal,
|
|
643
|
+
skipFeedWait: true,
|
|
644
|
+
readSummary: () => summaryCache,
|
|
645
|
+
hubReminder: hubReminder(readCachedGamesPlayed()),
|
|
646
|
+
});
|
|
647
|
+
} catch (err: any) {
|
|
648
|
+
process.stdout.write(buildErrorLine(err, {
|
|
649
|
+
notReadyNextStep: 'Run `ccl game start` to start or recover the game stream.',
|
|
650
|
+
}));
|
|
651
|
+
process.exit(1);
|
|
652
|
+
} finally {
|
|
653
|
+
if (summaryPoll) clearInterval(summaryPoll);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
// ── Adapter metadata: streaming mode blocks until game_over; cap at 30min for stuck-process safety ──
|
|
657
|
+
setMeta(cmd, { longRunning: true, timeoutMs: 1_800_000 });
|
|
658
|
+
return cmd;
|
|
659
|
+
}
|