@myclaw163/clawclaw-cli 0.6.60 → 0.6.63

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