@myclaw163/clawclaw-cli 0.6.57 → 0.6.58

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 (195) hide show
  1. package/README.md +440 -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/postinstall.mjs +20 -20
  9. package/scripts/sync-bundled-skill.mjs +244 -244
  10. package/scripts/sync-bundled-skill.test.mjs +152 -152
  11. package/skills/clawclaw/SKILL.md +244 -244
  12. package/skills/clawclaw/references/CHATTERBOX.md +142 -142
  13. package/skills/clawclaw/references/COMMANDS.md +132 -132
  14. package/skills/clawclaw/references/GAME-MECHANICS.md +186 -186
  15. package/skills/clawclaw/references/HUB.md +48 -48
  16. package/skills/clawclaw/references/KNOWLEDGE.md +43 -43
  17. package/skills/clawclaw/references/STRATEGIES.md +57 -57
  18. package/skills/clawclaw/references/STREAM.md +59 -59
  19. package/skills/clawclaw/references/TACTICS.md +65 -65
  20. package/src/assets/clawclaw-ascii-map.txt +40 -40
  21. package/src/cli.ts +110 -110
  22. package/src/commands/_schema.ts +109 -109
  23. package/src/commands/account.ts +209 -209
  24. package/src/commands/config.ts +30 -30
  25. package/src/commands/do.test.ts +37 -37
  26. package/src/commands/do.ts +95 -95
  27. package/src/commands/events.ts +22 -22
  28. package/src/commands/game-map.test.ts +28 -28
  29. package/src/commands/game-start-plan.test.ts +84 -84
  30. package/src/commands/game.ts +1027 -1027
  31. package/src/commands/history-player.test.ts +102 -102
  32. package/src/commands/history.ts +573 -573
  33. package/src/commands/hub.test.ts +96 -96
  34. package/src/commands/hub.ts +234 -234
  35. package/src/commands/knowledge.test.ts +19 -19
  36. package/src/commands/knowledge.ts +168 -168
  37. package/src/commands/load.test.ts +51 -51
  38. package/src/commands/load.ts +13 -13
  39. package/src/commands/meeting-history.test.ts +106 -106
  40. package/src/commands/memory.ts +40 -40
  41. package/src/commands/peek.ts +45 -45
  42. package/src/commands/persona.ts +57 -57
  43. package/src/commands/setup/codex.ts +248 -248
  44. package/src/commands/setup/hermes.test.ts +96 -96
  45. package/src/commands/setup/hermes.ts +76 -76
  46. package/src/commands/setup/index.ts +13 -13
  47. package/src/commands/setup/openclaw.test.ts +114 -114
  48. package/src/commands/setup/openclaw.ts +147 -147
  49. package/src/commands/skill.ts +128 -128
  50. package/src/commands/state.ts +46 -46
  51. package/src/commands/strategy.test.ts +135 -135
  52. package/src/commands/strategy.ts +180 -180
  53. package/src/commands/tts.ts +128 -128
  54. package/src/commands/upgrade.test.ts +82 -82
  55. package/src/commands/upgrade.ts +148 -148
  56. package/src/commands/watch.test.ts +969 -969
  57. package/src/commands/watch.ts +720 -720
  58. package/src/lib/auth.test.ts +59 -59
  59. package/src/lib/auth.ts +186 -186
  60. package/src/lib/command-meta.ts +37 -37
  61. package/src/lib/game-client.ts +391 -391
  62. package/src/lib/host-config-patcher.test.ts +130 -130
  63. package/src/lib/host-config-patcher.ts +151 -151
  64. package/src/lib/http-keepalive.ts +15 -15
  65. package/src/lib/http-transport.test.ts +42 -42
  66. package/src/lib/http-transport.ts +113 -113
  67. package/src/lib/hub-client.test.ts +56 -56
  68. package/src/lib/hub-client.ts +88 -88
  69. package/src/lib/hub-install.test.ts +98 -98
  70. package/src/lib/hub-install.ts +121 -121
  71. package/src/lib/hub-reminder.ts +56 -56
  72. package/src/lib/hub-unzip.test.ts +69 -69
  73. package/src/lib/hub-unzip.ts +62 -62
  74. package/src/lib/init-command.test.ts +75 -75
  75. package/src/lib/init-command.ts +120 -120
  76. package/src/lib/knowledge-store.test.ts +180 -180
  77. package/src/lib/knowledge-store.ts +374 -374
  78. package/src/lib/load-context.test.ts +52 -52
  79. package/src/lib/load-context.ts +52 -52
  80. package/src/lib/match-state.test.ts +134 -134
  81. package/src/lib/match-state.ts +94 -94
  82. package/src/lib/netease-tts.ts +83 -83
  83. package/src/lib/normalize.ts +42 -42
  84. package/src/lib/persona.test.ts +41 -41
  85. package/src/lib/persona.ts +72 -72
  86. package/src/lib/server-registry.ts +152 -152
  87. package/src/lib/skill-version.test.ts +48 -48
  88. package/src/lib/skill-version.ts +19 -19
  89. package/src/lib/strategy-export.test.ts +232 -232
  90. package/src/lib/strategy-export.ts +242 -242
  91. package/src/lib/tts-keys.ts +7 -7
  92. package/src/lib/tts-speech.test.ts +63 -63
  93. package/src/lib/tts-speech.ts +76 -76
  94. package/src/lib/workspace-argv.test.ts +49 -49
  95. package/src/lib/workspace-argv.ts +44 -44
  96. package/src/perception/player-history-store.test.ts +87 -87
  97. package/src/perception/player-history-store.ts +194 -194
  98. package/src/pipeline/event-store.ts +124 -124
  99. package/src/pipeline/pipeline.ts +35 -35
  100. package/src/runtime/auto-upgrade.test.ts +66 -66
  101. package/src/runtime/auto-upgrade.ts +31 -31
  102. package/src/runtime/event-daemon.test.ts +107 -107
  103. package/src/runtime/event-daemon.ts +409 -409
  104. package/src/runtime/owner-control.ts +150 -150
  105. package/src/runtime/raw-ws-log.test.ts +33 -33
  106. package/src/runtime/raw-ws-log.ts +32 -32
  107. package/src/runtime/runtime-logger.ts +107 -107
  108. package/src/runtime/ws-client.test.ts +104 -104
  109. package/src/runtime/ws-client.ts +272 -272
  110. package/src/sdk/action.ts +166 -166
  111. package/src/sdk/index.ts +110 -110
  112. package/src/sdk/types.ts +146 -146
  113. package/src/strategies/avoid-lone.ts +11 -11
  114. package/src/strategies/avoid-players.knowledge.md +20 -20
  115. package/src/strategies/avoid-players.ts +15 -15
  116. package/src/strategies/corpse-patrol.ts +22 -22
  117. package/src/strategies/crab-sabotage.ts +21 -21
  118. package/src/strategies/custom-module.test.ts +269 -269
  119. package/src/strategies/find-player.ts +16 -16
  120. package/src/strategies/game-utils.test.ts +190 -164
  121. package/src/strategies/game-utils.ts +744 -737
  122. package/src/strategies/goals/avoid-lone-top.ts +168 -168
  123. package/src/strategies/goals/avoid-players-top.test.ts +83 -83
  124. package/src/strategies/goals/avoid-players-top.ts +121 -121
  125. package/src/strategies/goals/conversation-goal.ts +51 -51
  126. package/src/strategies/goals/corpse-patrol-top.ts +91 -91
  127. package/src/strategies/goals/crab-octopus-reflexes.ts +93 -93
  128. package/src/strategies/goals/crab-sabotage-top.ts +197 -197
  129. package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
  130. package/src/strategies/goals/find-player-top.ts +93 -93
  131. package/src/strategies/goals/flee-players-goal.ts +53 -53
  132. package/src/strategies/goals/goal-manager.ts +41 -41
  133. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  134. package/src/strategies/goals/goal.ts +28 -28
  135. package/src/strategies/goals/keep-away-goal.ts +209 -206
  136. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  137. package/src/strategies/goals/kill-lone-top.ts +160 -160
  138. package/src/strategies/goals/kill-target-goal.ts +59 -59
  139. package/src/strategies/goals/kill-target-top.ts +109 -109
  140. package/src/strategies/goals/leaf-goal.ts +25 -25
  141. package/src/strategies/goals/linger-corpse-goal.ts +79 -79
  142. package/src/strategies/goals/lone-kill-core.ts +82 -82
  143. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  144. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  145. package/src/strategies/goals/lone-kill-task-top.ts +86 -86
  146. package/src/strategies/goals/move-room-goal.ts +60 -60
  147. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  148. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  149. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  150. package/src/strategies/goals/paradise-fish-top.ts +219 -219
  151. package/src/strategies/goals/patrol-top.ts +57 -57
  152. package/src/strategies/goals/report-patrol-top.ts +80 -80
  153. package/src/strategies/goals/safe-task-goal.ts +102 -102
  154. package/src/strategies/goals/social-task-top.ts +161 -161
  155. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  156. package/src/strategies/goals/task-only-top.ts +57 -57
  157. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  158. package/src/strategies/goals/task-report-top.ts +57 -57
  159. package/src/strategies/goals/wander-task-goal.ts +33 -33
  160. package/src/strategies/goals/warrior-shrimp-top.test.ts +86 -86
  161. package/src/strategies/goals/warrior-shrimp-top.ts +248 -248
  162. package/src/strategies/greeting.ts +53 -53
  163. package/src/strategies/kill-frenzy.ts +12 -12
  164. package/src/strategies/kill-lone.knowledge.md +20 -20
  165. package/src/strategies/kill-lone.ts +13 -13
  166. package/src/strategies/kill-target.ts +18 -18
  167. package/src/strategies/loader.test.ts +678 -678
  168. package/src/strategies/loader.ts +172 -172
  169. package/src/strategies/lone-kill-task.ts +21 -21
  170. package/src/strategies/meeting-gate.test.ts +59 -59
  171. package/src/strategies/meeting-gate.ts +23 -23
  172. package/src/strategies/move-room.ts +15 -15
  173. package/src/strategies/new-events-backfill.ts +98 -98
  174. package/src/strategies/paradise-fish.knowledge.md +20 -20
  175. package/src/strategies/paradise-fish.ts +25 -25
  176. package/src/strategies/pathfind/distance-field.ts +150 -150
  177. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  178. package/src/strategies/pathfind/escape-planner.ts +355 -348
  179. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  180. package/src/strategies/patrol.ts +11 -11
  181. package/src/strategies/player-targets.ts +13 -13
  182. package/src/strategies/report-patrol.ts +11 -11
  183. package/src/strategies/shrimp-memory.knowledge.md +20 -20
  184. package/src/strategies/shrimp-memory.ts +25 -25
  185. package/src/strategies/social-task.test.ts +28 -28
  186. package/src/strategies/social-task.ts +49 -49
  187. package/src/strategies/spawn.ts +82 -82
  188. package/src/strategies/speech-module.ts +123 -123
  189. package/src/strategies/strategy-loop.ts +763 -763
  190. package/src/strategies/task-kill-report.ts +17 -17
  191. package/src/strategies/task-only.ts +11 -11
  192. package/src/strategies/task-report.ts +22 -22
  193. package/src/strategies/types.ts +96 -96
  194. package/src/strategies/warrior-memory.knowledge.md +20 -20
  195. package/src/strategies/warrior-memory.ts +16 -16
@@ -1,720 +1,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 } 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 } 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
+ }