@myclaw163/clawclaw-cli 0.6.56 → 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 (50) hide show
  1. package/bin/clawclaw-cli.mjs +3 -3
  2. package/package.json +1 -1
  3. package/scripts/sync-bundled-skill.mjs +1 -1
  4. package/skills/clawclaw/SKILL.md +7 -3
  5. package/skills/clawclaw/references/GAME-MECHANICS.md +5 -5
  6. package/skills/clawclaw/references/STREAM.md +4 -3
  7. package/src/cli.ts +0 -43
  8. package/src/commands/config.ts +30 -30
  9. package/src/commands/game-start-plan.test.ts +10 -68
  10. package/src/commands/game.ts +318 -173
  11. package/src/commands/history.ts +1 -1
  12. package/src/commands/peek.ts +16 -9
  13. package/src/commands/setup/codex.ts +248 -248
  14. package/src/commands/setup/hermes.test.ts +96 -96
  15. package/src/commands/setup/hermes.ts +76 -76
  16. package/src/commands/setup/index.ts +13 -13
  17. package/src/commands/setup/openclaw.test.ts +114 -114
  18. package/src/commands/setup/openclaw.ts +147 -147
  19. package/src/commands/strategy.ts +29 -38
  20. package/src/commands/watch.test.ts +7 -11
  21. package/src/commands/watch.ts +77 -66
  22. package/src/lib/game-client.ts +3 -3
  23. package/src/lib/host-config-patcher.test.ts +130 -130
  24. package/src/lib/host-config-patcher.ts +151 -151
  25. package/src/lib/hub-reminder.ts +19 -19
  26. package/src/lib/strategy-export.test.ts +1 -1
  27. package/src/runtime/event-daemon.test.ts +81 -2
  28. package/src/runtime/event-daemon.ts +325 -287
  29. package/src/runtime/owner-control.ts +150 -0
  30. package/src/runtime/runtime-logger.ts +11 -3
  31. package/src/runtime/ws-client.test.ts +57 -0
  32. package/src/runtime/ws-client.ts +2 -2
  33. package/src/sdk/index.ts +4 -4
  34. package/src/strategies/game-utils.test.ts +27 -1
  35. package/src/strategies/game-utils.ts +27 -4
  36. package/src/strategies/goals/crab-octopus-reflexes.ts +4 -4
  37. package/src/strategies/goals/crab-sabotage-top.ts +1 -1
  38. package/src/strategies/goals/keep-away-goal.ts +10 -7
  39. package/src/strategies/goals/kill-frenzy-top.ts +5 -5
  40. package/src/strategies/goals/kill-target-goal.ts +2 -2
  41. package/src/strategies/goals/kill-target-top.ts +2 -2
  42. package/src/strategies/goals/lone-kill-core.ts +3 -3
  43. package/src/strategies/goals/lone-kill-task-top.ts +1 -1
  44. package/src/strategies/goals/task-kill-report-top.ts +3 -3
  45. package/src/strategies/goals/warrior-shrimp-top.ts +2 -2
  46. package/src/strategies/pathfind/escape-planner.ts +10 -3
  47. package/src/strategies/spawn.ts +16 -5
  48. package/src/strategies/strategy-loop.ts +9 -3
  49. package/src/runtime/daemon.ts +0 -100
  50. package/src/runtime/opening-mover.ts +0 -303
@@ -1,16 +1,25 @@
1
1
  import { Command } from 'commander';
2
2
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
3
3
  import { join } from 'path';
4
+ import { spawnSync, type ChildProcess } from 'child_process';
4
5
  import { AuthStore } from '../lib/auth.js';
5
6
  import { ApiError, GameClient } from '../lib/game-client.js';
6
7
  import { getProfileStateDir } from '../lib/init-command.js';
7
- import { spawnDaemon, getRunningDaemonPid, waitDaemonExit } from '../runtime/daemon.js';
8
8
  import { EventStore } from '../pipeline/event-store.js';
9
- import { spawnOpeningMover, stopOpeningMoverIfRunning } from '../runtime/opening-mover.js';
10
9
  import { spawnStrategyLoop } from '../strategies/spawn.js';
10
+ import { stopStrategyIfRunning } from '../strategies/strategy-loop.js';
11
11
  import { setMeta } from '../lib/command-meta.js';
12
- import { runStreaming, buildErrorLine, readFeedSummary, nextStepFor } from './watch.js';
12
+ import { runStreaming, buildErrorLine, summarizeFeed, nextStepFor } from './watch.js';
13
13
  import { hubReminder, readCachedGamesPlayed } from '../lib/hub-reminder.js';
14
+ import { EventRuntime } from '../runtime/event-daemon.js';
15
+ import {
16
+ gameStartRuntimePath,
17
+ readGameStartRuntime,
18
+ sendOwnerControlRequest,
19
+ startOwnerControlServer,
20
+ type OwnerControlInfo,
21
+ type OwnerControlServer,
22
+ } from '../runtime/owner-control.js';
14
23
  import {
15
24
  startMatch,
16
25
  endMatch,
@@ -21,10 +30,6 @@ import {
21
30
  hasMatchTimedOut,
22
31
  } from '../lib/match-state.js';
23
32
 
24
- function writeControl(stateDir: string, command: 'stop'): void {
25
- writeFileSync(join(stateDir, 'control.json'), JSON.stringify({ command }));
26
- }
27
-
28
33
  function sleep(ms: number): Promise<void> {
29
34
  return new Promise(r => setTimeout(r, ms));
30
35
  }
@@ -39,52 +44,141 @@ function queueStatus(result: any): string | undefined {
39
44
  }
40
45
 
41
46
  const DEFAULT_QUEUE_WAIT_TIMEOUT_SECS = 30;
42
- const GAME_START_RUNTIME_FILE = 'game-start.json';
43
47
 
44
48
  function isPidAlive(pid: number): boolean {
45
49
  if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
46
50
  try { process.kill(pid, 0); return true; } catch { return false; }
47
51
  }
48
52
 
49
- function gameStartRuntimePath(stateDir: string): string {
50
- return join(stateDir, GAME_START_RUNTIME_FILE);
51
- }
52
-
53
53
  function getRunningGameStartPid(stateDir: string): number | null {
54
- const runtimePath = gameStartRuntimePath(stateDir);
55
- if (!existsSync(runtimePath)) return null;
56
- try {
57
- const info = JSON.parse(readFileSync(runtimePath, 'utf8'));
58
- const pid = Number(info?.pid);
59
- if (isPidAlive(pid)) return pid;
60
- } catch {}
61
- try { unlinkSync(runtimePath); } catch {}
54
+ const info = readGameStartRuntime(stateDir);
55
+ if (!info) return null;
56
+ const pid = Number(info?.owner_pid ?? info?.pid);
57
+ if (isPidAlive(pid)) return pid;
58
+ try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
62
59
  return null;
63
60
  }
64
61
 
65
- function writeGameStartRuntime(stateDir: string, mode: GameStartPlanKind): void {
62
+ function writeGameStartRuntime(
63
+ stateDir: string,
64
+ mode: GameStartPlanKind,
65
+ phase?: string,
66
+ controlOverride?: { control: OwnerControlInfo; token: string },
67
+ ): void {
66
68
  mkdirSync(stateDir, { recursive: true });
69
+ let startedAt = new Date().toISOString();
70
+ let existingControl: OwnerControlInfo | undefined;
71
+ let existingToken: string | undefined;
72
+ try {
73
+ const existing = JSON.parse(readFileSync(gameStartRuntimePath(stateDir), 'utf8'));
74
+ const existingPid = Number(existing?.owner_pid ?? existing?.pid);
75
+ if (existingPid === process.pid && typeof existing?.started_at === 'string') {
76
+ startedAt = existing.started_at;
77
+ if (existing?.control?.path) existingControl = existing.control;
78
+ if (typeof existing?.control_token === 'string') existingToken = existing.control_token;
79
+ }
80
+ } catch {}
81
+ const control = controlOverride?.control ?? existingControl;
82
+ const controlToken = controlOverride?.token ?? existingToken;
67
83
  writeFileSync(gameStartRuntimePath(stateDir), JSON.stringify({
84
+ schema: 3,
85
+ owner_pid: process.pid,
68
86
  pid: process.pid,
69
- started_at: new Date().toISOString(),
87
+ started_at: startedAt,
88
+ heartbeat_at: new Date().toISOString(),
70
89
  mode,
71
- }));
90
+ ...(control ? { control } : {}),
91
+ ...(controlToken ? { control_token: controlToken } : {}),
92
+ ...(phase ? { phase } : {}),
93
+ }, null, 2));
72
94
  }
73
95
 
74
- function cleanupGameStartRuntime(stateDir: string): void {
96
+ function startGameStartHeartbeat(stateDir: string, mode: GameStartPlanKind): ReturnType<typeof setInterval> {
97
+ return setInterval(() => {
98
+ try {
99
+ writeGameStartRuntime(stateDir, mode);
100
+ } catch {}
101
+ }, 5000);
102
+ }
103
+
104
+ function cleanupGameStartRuntime(stateDir: string, opts: { removeFeed?: boolean; controlPath?: string } = {}): void {
75
105
  const runtimePath = gameStartRuntimePath(stateDir);
76
106
  try {
77
107
  const info = JSON.parse(readFileSync(runtimePath, 'utf8'));
78
- if (Number(info?.pid) !== process.pid) return;
108
+ const pid = Number(info?.owner_pid ?? info?.pid);
109
+ if (pid !== process.pid) return;
79
110
  } catch {}
80
111
  try { unlinkSync(runtimePath); } catch {}
112
+ if (opts.removeFeed) {
113
+ try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
114
+ }
115
+ if (opts.controlPath && process.platform !== 'win32') {
116
+ try { unlinkSync(opts.controlPath); } catch {}
117
+ }
118
+ }
119
+
120
+ function terminateProcessTree(pid: number, signal: NodeJS.Signals = 'SIGTERM'): boolean {
121
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
122
+ if (process.platform === 'win32') {
123
+ const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
124
+ return result.status === 0;
125
+ }
126
+ const pkillSignal = signal === 'SIGKILL' ? '-KILL' : '-TERM';
127
+ try { spawnSync('pkill', [pkillSignal, '-P', String(pid)], { stdio: 'ignore' }); } catch {}
128
+ try {
129
+ process.kill(pid, signal);
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ async function waitPidExit(pid: number, timeoutMs = 5000): Promise<boolean> {
137
+ const deadline = Date.now() + timeoutMs;
138
+ while (Date.now() < deadline) {
139
+ if (!isPidAlive(pid)) return true;
140
+ await sleep(200);
141
+ }
142
+ return !isPidAlive(pid);
143
+ }
144
+
145
+ async function stopOwnerIfRunning(stateDir: string, timeoutMs = 5000): Promise<{ pid: number | null; stopped: boolean }> {
146
+ return stopOwnerWithCommand(stateDir, 'stop', timeoutMs);
147
+ }
148
+
149
+ async function stopOwnerWithCommand(
150
+ stateDir: string,
151
+ command: 'stop' | 'quit' | 'leave',
152
+ timeoutMs = 5000,
153
+ ): Promise<{ pid: number | null; stopped: boolean }> {
154
+ const pid = getRunningGameStartPid(stateDir);
155
+ if (!pid) {
156
+ try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
157
+ try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
158
+ return { pid: null, stopped: false };
159
+ }
160
+ try {
161
+ const response = await sendOwnerControlRequest(stateDir, command);
162
+ if (response?.ok) {
163
+ const exited = await waitPidExit(pid, timeoutMs);
164
+ if (exited) return { pid, stopped: true };
165
+ }
166
+ } catch {}
167
+ terminateProcessTree(pid, 'SIGTERM');
168
+ const exited = await waitPidExit(pid, timeoutMs);
169
+ if (!exited) {
170
+ terminateProcessTree(pid, 'SIGKILL');
171
+ await waitPidExit(pid, 2000);
172
+ }
173
+ try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
174
+ try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
175
+ return { pid, stopped: true };
81
176
  }
82
177
 
83
178
  type QueueStatus = 'allocated' | 'queued' | 'not_in_queue' | string | undefined;
84
179
 
85
180
  export type GameStartPlan =
86
181
  | { kind: 'already_running'; pid: number }
87
- | { kind: 'attach_daemon' }
88
182
  | { kind: 'resume_queue' }
89
183
  | { kind: 'resume_allocated' }
90
184
  | { kind: 'fresh_start' };
@@ -93,21 +187,14 @@ type GameStartPlanKind = GameStartPlan['kind'];
93
187
 
94
188
  export function planGameStartAction(input: {
95
189
  gameStartPid?: number | null;
96
- daemonPid?: number | null;
97
190
  hasMatchState: boolean;
98
191
  queueStatus?: QueueStatus;
99
- feedPhase?: string | null;
100
192
  }): GameStartPlan {
101
193
  if (input.gameStartPid) return { kind: 'already_running', pid: input.gameStartPid };
102
194
 
103
- if (input.daemonPid) {
104
- if (input.hasMatchState || input.feedPhase === 'matching') return { kind: 'resume_queue' };
105
- if (input.feedPhase === 'lobby') return { kind: 'fresh_start' };
106
- return { kind: 'attach_daemon' };
107
- }
108
-
109
195
  if (input.queueStatus === 'allocated') return { kind: 'resume_allocated' };
110
196
  if (input.queueStatus === 'queued' || input.queueStatus === 'already_in_queue') return { kind: 'resume_queue' };
197
+ if (input.hasMatchState && input.queueStatus !== 'not_in_queue') return { kind: 'resume_queue' };
111
198
  return { kind: 'fresh_start' };
112
199
  }
113
200
 
@@ -127,15 +214,6 @@ function ensureEventSession(source: string): EventStore {
127
214
  return events;
128
215
  }
129
216
 
130
- function readFeedPhase(feedPath: string): string | null {
131
- try {
132
- const phase = JSON.parse(readFileSync(feedPath, 'utf8'))?.phase;
133
- return typeof phase === 'string' ? phase : null;
134
- } catch {
135
- return null;
136
- }
137
- }
138
-
139
217
  function nonEmptyString(value: unknown): string | undefined {
140
218
  return typeof value === 'string' && value.length > 0 ? value : undefined;
141
219
  }
@@ -150,15 +228,6 @@ export interface GameStrategyIdentity {
150
228
  alive: boolean | null;
151
229
  }
152
230
 
153
- export interface StrategyRuntimeInfo {
154
- strategy?: string;
155
- pid?: number;
156
- source?: string;
157
- gameId?: string;
158
- role?: string;
159
- running: boolean;
160
- }
161
-
162
231
  export function gameStrategyIdentity(stateData: any, roleData: any): GameStrategyIdentity {
163
232
  const state = unwrapData(stateData);
164
233
  const role = unwrapData(roleData);
@@ -171,33 +240,6 @@ export function gameStrategyIdentity(stateData: any, roleData: any): GameStrateg
171
240
  };
172
241
  }
173
242
 
174
- export function strategyRuntimeMatchesIdentity(
175
- runtime: StrategyRuntimeInfo | null,
176
- identity: GameStrategyIdentity,
177
- ): boolean {
178
- if (!runtime?.running) return false;
179
- if (!runtime.gameId || !identity.gameId || runtime.gameId !== identity.gameId) return false;
180
- if (runtime.role && identity.role && runtime.role !== identity.role) return false;
181
- return true;
182
- }
183
-
184
- function readStrategyRuntimeInfo(stateDir: string): StrategyRuntimeInfo | null {
185
- try {
186
- const raw = JSON.parse(readFileSync(join(stateDir, 'auto.json'), 'utf8'));
187
- const pid = Number(raw?.pid);
188
- return {
189
- strategy: nonEmptyString(raw?.strategy),
190
- pid: Number.isFinite(pid) ? pid : undefined,
191
- source: nonEmptyString(raw?.source),
192
- gameId: nonEmptyString(raw?.game_id) ?? nonEmptyString(raw?.gameId),
193
- role: nonEmptyString(raw?.role),
194
- running: Number.isFinite(pid) && pid > 0 ? isPidAlive(pid) : false,
195
- };
196
- } catch {
197
- return null;
198
- }
199
- }
200
-
201
243
  function commandError(command: string, err: unknown): { command: string; error: string } {
202
244
  const message = err instanceof ApiError
203
245
  ? `${err.status}: ${err.body}`
@@ -294,7 +336,7 @@ const ROLE_DEFAULT_STRATEGY: Record<string, string> = {
294
336
  'neutral_octopus': 'lone-kill-task',
295
337
  };
296
338
 
297
- function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { strategy: string; pid: number | undefined } | null {
339
+ function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { strategy: string; pid: number | undefined; child: ChildProcess } | null {
298
340
  const roleId: string | undefined = roleData?.data?.role ?? roleData?.role;
299
341
  if (!roleId) return null;
300
342
  const strategyId = ROLE_DEFAULT_STRATEGY[roleId];
@@ -303,8 +345,10 @@ function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { s
303
345
  source: 'auto_start',
304
346
  gameId: gameId ?? gameStrategyIdentity(stateData, roleData).gameId,
305
347
  role: roleId,
348
+ detached: false,
349
+ writeRuntimeFiles: false,
306
350
  });
307
- return { strategy: strategyId, pid: child.pid };
351
+ return { strategy: strategyId, pid: child.pid, child };
308
352
  }
309
353
 
310
354
  async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<void> {
@@ -323,6 +367,58 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
323
367
  const stateDir = getProfileStateDir(profile);
324
368
  const feedPath = join(stateDir, 'feed.json');
325
369
  const client = GameClient.fromAuth();
370
+ let eventRuntime: EventRuntime | undefined;
371
+ let streamAbortController: AbortController | null = null;
372
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
373
+ let ownerControl: OwnerControlServer | null = null;
374
+ let ownerFeed: any = {
375
+ ts: new Date().toISOString(),
376
+ phase: 'matching',
377
+ terminal: false,
378
+ you: { name: profile.agentName },
379
+ game: {},
380
+ urgent: {},
381
+ meeting: null,
382
+ recent_events: [],
383
+ };
384
+ let strategyChild: ChildProcess | null = null;
385
+ let currentStrategy: string | null = null;
386
+ const currentAutomationSummary = (): Record<string, any> | undefined => {
387
+ if (!currentStrategy) return undefined;
388
+ const pid = strategyChild?.pid;
389
+ return { strategy: currentStrategy, running: !!(pid && isPidAlive(pid)) };
390
+ };
391
+ const currentSummary = (): any | null => {
392
+ const runtimeFeed = eventRuntime?.snapshot();
393
+ const feed = runtimeFeed && runtimeFeed.phase !== 'lobby' ? runtimeFeed : ownerFeed;
394
+ const automation = currentAutomationSummary();
395
+ return summarizeFeed(automation ? { ...feed, automation } : feed);
396
+ };
397
+ let manualExitEmitted = false;
398
+ let ownerExitRequested = false;
399
+ const stopOwnedStrategy = (): void => {
400
+ const child = strategyChild;
401
+ strategyChild = null;
402
+ currentStrategy = null;
403
+ if (child?.pid && isPidAlive(child.pid)) {
404
+ try { child.kill('SIGTERM'); } catch {}
405
+ setTimeout(() => {
406
+ if (child.pid && isPidAlive(child.pid)) {
407
+ try { child.kill('SIGKILL'); } catch {}
408
+ }
409
+ }, 1000).unref();
410
+ }
411
+ eventRuntime?.refreshFeed();
412
+ };
413
+ const onOwnerSignal = (): void => {
414
+ streamAbortController?.abort();
415
+ eventRuntime?.stop('SIGTERM');
416
+ stopOwnedStrategy();
417
+ cleanupGameStartRuntime(stateDir, { removeFeed: true, controlPath: ownerControl?.control.path });
418
+ process.exit(130);
419
+ };
420
+ process.on('SIGINT', onOwnerSignal);
421
+ process.on('SIGTERM', onOwnerSignal);
326
422
  const emit = (obj: Record<string, any>): void => {
327
423
  process.stdout.write(JSON.stringify(obj) + '\n');
328
424
  };
@@ -336,28 +432,53 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
336
432
  exit_reason: [reason],
337
433
  next_step: nextStepOverride ?? nextStepFor(reason),
338
434
  events: [event],
339
- summary: readFeedSummary(feedPath),
435
+ summary: currentSummary(),
436
+ });
437
+ };
438
+ const emitOwnerExit = (kind: 'stop' | 'quit' | 'leave', command: string): void => {
439
+ if (manualExitEmitted) return;
440
+ manualExitEmitted = true;
441
+ const eventType = kind === 'leave' ? 'stop' : kind;
442
+ emit({
443
+ exit_reason: [eventType],
444
+ next_step: `Received ${command}. The current ccl game start process is exiting now.`,
445
+ events: [{ type: eventType, command }],
446
+ summary: { phase: 'stopped' },
340
447
  });
341
448
  };
449
+ const requestOwnerExit = (kind: 'stop' | 'quit' | 'leave', command: string): void => {
450
+ ownerExitRequested = true;
451
+ emitOwnerExit(kind, command);
452
+ streamAbortController?.abort();
453
+ eventRuntime?.stop('manual');
454
+ stopOwnedStrategy();
455
+ };
342
456
  const emitMatchEvent = (evt: Record<string, any>): void => {
343
457
  try {
344
458
  const store = EventStore.forActiveAccount();
345
459
  store.append({ ts: new Date().toISOString(), ...evt });
346
460
  } catch {}
347
461
  };
348
- const ensureDaemonRunning = (): void => {
349
- if (getRunningDaemonPid(stateDir)) return;
350
- try {
351
- spawnDaemon();
352
- } catch (err) {
353
- const msg = err instanceof Error ? err.message : String(err);
354
- if (!msg.includes('Daemon already running')) throw err;
355
- }
462
+ const ensureEventRuntime = async (): Promise<void> => {
463
+ if (eventRuntime) return;
464
+ eventRuntime = new EventRuntime({
465
+ authStore,
466
+ getAutomation: currentAutomationSummary,
467
+ onStop: (stop) => {
468
+ if (stop.reason === 'game_over') return;
469
+ streamAbortController?.abort();
470
+ },
471
+ });
472
+ await eventRuntime.start();
356
473
  };
357
474
  const streamGame = async (): Promise<void> => {
358
475
  const sessionPath = EventStore.latestSessionPath();
359
476
  const ctrl = new AbortController();
360
- const onSignal = (): void => ctrl.abort();
477
+ streamAbortController = ctrl;
478
+ const onSignal = (): void => {
479
+ ctrl.abort();
480
+ eventRuntime?.stop('SIGTERM');
481
+ };
361
482
  process.on('SIGINT', onSignal);
362
483
  process.on('SIGTERM', onSignal);
363
484
  try {
@@ -367,6 +488,8 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
367
488
  getSessionPath: () => EventStore.latestSessionPath(),
368
489
  stdout: (s) => process.stdout.write(s),
369
490
  signal: ctrl.signal,
491
+ skipFeedWait: true,
492
+ readSummary: currentSummary,
370
493
  skipBacklogTypes: ['match_waiting', 'match_timeout'],
371
494
  emitGameStart: true,
372
495
  hubReminder: hubReminder(readCachedGamesPlayed()),
@@ -377,6 +500,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
377
500
  } finally {
378
501
  process.off('SIGINT', onSignal);
379
502
  process.off('SIGTERM', onSignal);
503
+ streamAbortController = null;
380
504
  }
381
505
  };
382
506
  const handleAllocated = async (queue: any, preserveStrategy: boolean): Promise<void> => {
@@ -390,21 +514,31 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
390
514
  if (!identity.gameId && queueGameId) {
391
515
  identity.gameId = queueGameId;
392
516
  }
517
+ const role = unwrapData(context.role);
518
+ ownerFeed = {
519
+ ...ownerFeed,
520
+ ts: new Date().toISOString(),
521
+ phase: 'allocated',
522
+ you: {
523
+ ...ownerFeed.you,
524
+ role: nonEmptyString(role?.role) ?? identity.role,
525
+ role_display: nonEmptyString(role?.role_display_name) ?? nonEmptyString(role?.role_display),
526
+ faction: nonEmptyString(role?.faction),
527
+ alive: identity.alive,
528
+ },
529
+ game: {
530
+ ...ownerFeed.game,
531
+ game_id: identity.gameId,
532
+ },
533
+ };
393
534
  let strategyInfo: { strategy: string; pid: number | undefined } | null = null;
394
- if (identity.alive !== false) {
395
- if (preserveStrategy) {
396
- const { isStrategyRunning, stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
397
- if (isStrategyRunning()) {
398
- const runtime = readStrategyRuntimeInfo(stateDir);
399
- if (identity.gameId && !strategyRuntimeMatchesIdentity(runtime, identity)) {
400
- stopStrategyIfRunning();
401
- strategyInfo = autoStartStrategy(context.role, context.state, identity.gameId);
402
- }
403
- } else {
404
- strategyInfo = autoStartStrategy(context.role, context.state, identity.gameId);
405
- }
406
- } else {
407
- strategyInfo = autoStartStrategy(context.role, context.state, identity.gameId);
535
+ if (opts.watch && identity.alive !== false) {
536
+ stopOwnedStrategy();
537
+ const started = autoStartStrategy(context.role, context.state, identity.gameId);
538
+ if (started) {
539
+ strategyChild = started.child;
540
+ currentStrategy = started.strategy;
541
+ strategyInfo = { strategy: started.strategy, pid: started.pid };
408
542
  }
409
543
  }
410
544
  const allocationPayload = {
@@ -416,9 +550,10 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
416
550
  };
417
551
  if (!opts.watch) {
418
552
  emitLifecycle('allocated', allocationPayload,
419
- 'Match secured. Read role/map/tasks from `events[0]`. Default strategy is active when `default_strategy` is present or summary.automation shows one. Move into Preparing phase.');
553
+ 'Match secured. Read role/map/tasks from `events[0]`, then launch `ccl game start` without --no-watch to attach the owner stream and automation.');
420
554
  return;
421
555
  }
556
+ await ensureEventRuntime();
422
557
  await streamGame();
423
558
  };
424
559
  const pollQueue = async (preserveStrategy: boolean): Promise<void> => {
@@ -428,7 +563,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
428
563
  let lastHeartbeat = Date.now();
429
564
  let consecutiveFailures = 0;
430
565
 
431
- while (true) {
566
+ while (!ownerExitRequested) {
432
567
  let queue: any;
433
568
  try {
434
569
  queue = await client.getQueueStatus('clawclaw');
@@ -496,8 +631,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
496
631
  const resumeQueue = async (source: string): Promise<void> => {
497
632
  ensureEventSession(source);
498
633
  if (!readMatchState(stateDir)) startMatch(stateDir);
499
- ensureDaemonRunning();
500
- spawnOpeningMover();
634
+ await ensureEventRuntime();
501
635
  await pollQueue(true);
502
636
  };
503
637
  const recoverAlreadyInGame = async (): Promise<boolean> => {
@@ -510,8 +644,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
510
644
  const status = queueStatus(queue);
511
645
  if (status === 'allocated') {
512
646
  ensureEventSession('game_start_already_in_game');
513
- ensureDaemonRunning();
514
- spawnOpeningMover();
647
+ await ensureEventRuntime();
515
648
  await handleAllocated(queue, true);
516
649
  return true;
517
650
  }
@@ -525,40 +658,71 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
525
658
  const runningGameStartPid = getRunningGameStartPid(stateDir);
526
659
  if (runningGameStartPid) {
527
660
  if (opts.force) {
528
- try { process.kill(runningGameStartPid, 'SIGKILL'); } catch {}
661
+ terminateProcessTree(runningGameStartPid, 'SIGKILL');
529
662
  try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
663
+ try { unlinkSync(feedPath); } catch {}
530
664
  } else {
531
665
  emitLifecycle('already_running', { pid: runningGameStartPid },
532
666
  `A ccl game start stream is already running (pid ${runningGameStartPid}). To replace it, re-run with --force or stop it manually: taskkill /F /PID ${runningGameStartPid}`);
533
667
  return;
534
668
  }
535
669
  }
536
-
537
670
  let initialQueue: any;
538
671
  try {
539
672
  initialQueue = await client.getQueueStatus('clawclaw');
540
673
  } catch {}
541
674
  const plan = planGameStartAction({
542
675
  gameStartPid: null,
543
- daemonPid: getRunningDaemonPid(stateDir),
544
676
  hasMatchState: readMatchState(stateDir) !== null,
545
677
  queueStatus: queueStatus(initialQueue),
546
- feedPhase: readFeedPhase(feedPath),
547
678
  });
548
679
 
549
- writeGameStartRuntime(stateDir, plan.kind);
550
- try {
551
- if (plan.kind === 'attach_daemon') {
552
- ensureEventSession('game_start_attach_daemon');
553
- if (!opts.watch) {
554
- emitLifecycle('game_start', { mode: plan.kind },
555
- 'Game stream is active. Read summary; no additional events will follow from this --no-watch invocation.');
556
- return;
557
- }
558
- await streamGame();
559
- return;
680
+ ownerFeed = {
681
+ ...ownerFeed,
682
+ ts: new Date().toISOString(),
683
+ phase: plan.kind === 'resume_allocated' ? 'allocated' : 'matching',
684
+ };
685
+ ownerControl = await startOwnerControlServer(stateDir, async (request) => {
686
+ if (request.type === 'snapshot') {
687
+ return { ok: true, type: 'snapshot', summary: currentSummary() };
560
688
  }
561
-
689
+ if (request.type === 'stop') {
690
+ requestOwnerExit('stop', 'ccl game stop');
691
+ return { ok: true, type: 'stop' };
692
+ }
693
+ if (request.type === 'quit') {
694
+ requestOwnerExit('quit', 'ccl game quit');
695
+ return { ok: true, type: 'quit' };
696
+ }
697
+ if (request.type === 'leave') {
698
+ requestOwnerExit('leave', 'ccl game leave');
699
+ return { ok: true, type: 'leave' };
700
+ }
701
+ if (request.type === 'stop_strategy') {
702
+ stopOwnedStrategy();
703
+ return { ok: true, type: 'stop_strategy' };
704
+ }
705
+ if (request.type === 'switch_strategy') {
706
+ if (!request.strategy) return { ok: false, error: 'missing_strategy' };
707
+ stopOwnedStrategy();
708
+ const child = spawnStrategyLoop(request.strategy, request.args, {
709
+ source: 'manual',
710
+ detached: false,
711
+ writeRuntimeFiles: false,
712
+ });
713
+ strategyChild = child;
714
+ currentStrategy = request.strategy;
715
+ eventRuntime?.refreshFeed();
716
+ return { ok: true, type: 'switch_strategy', strategy: request.strategy, pid: child.pid };
717
+ }
718
+ return { ok: false, error: 'unsupported_owner_control_request' };
719
+ });
720
+ writeGameStartRuntime(stateDir, plan.kind, undefined, {
721
+ control: ownerControl.control,
722
+ token: ownerControl.token,
723
+ });
724
+ heartbeatTimer = startGameStartHeartbeat(stateDir, plan.kind);
725
+ try {
562
726
  if (plan.kind === 'resume_queue') {
563
727
  await resumeQueue('game_start_resume_queue');
564
728
  return;
@@ -566,8 +730,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
566
730
 
567
731
  if (plan.kind === 'resume_allocated') {
568
732
  ensureEventSession('game_start_resume_allocated');
569
- ensureDaemonRunning();
570
- spawnOpeningMover();
733
+ await ensureEventRuntime();
571
734
  await handleAllocated(initialQueue, true);
572
735
  return;
573
736
  }
@@ -590,24 +753,23 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
590
753
  return;
591
754
  }
592
755
 
593
- const { stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
594
- stopStrategyIfRunning();
595
- stopOpeningMoverIfRunning();
756
+ stopOwnedStrategy();
596
757
 
597
758
  const events = EventStore.createSessionForActiveAccount();
598
759
  events.append({ type: 'session_started', source: 'game_start' });
599
760
  emitLifecycle('joined', joinResult,
600
- 'Match join request acknowledged. Spectate URL is in `events[0].url`; share it with the user. Daemon spawns next.');
761
+ 'Match join request acknowledged. Spectate URL is in `events[0].url`; share it with the user. Game runtime is attached.');
601
762
  startMatch(stateDir);
602
- if (getRunningDaemonPid(stateDir)) {
603
- writeControl(stateDir, 'stop');
604
- await waitDaemonExit(stateDir);
605
- }
606
- spawnDaemon();
607
- spawnOpeningMover();
763
+ await ensureEventRuntime();
608
764
  await pollQueue(false);
609
765
  } finally {
610
- cleanupGameStartRuntime(stateDir);
766
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
767
+ process.off('SIGINT', onOwnerSignal);
768
+ process.off('SIGTERM', onOwnerSignal);
769
+ eventRuntime?.stop('manual');
770
+ stopOwnedStrategy();
771
+ if (ownerControl) await ownerControl.close();
772
+ cleanupGameStartRuntime(stateDir, { removeFeed: true, controlPath: ownerControl?.control.path });
611
773
  }
612
774
  }
613
775
 
@@ -623,9 +785,7 @@ export function createGameCommand(): Command {
623
785
  const authStore = new AuthStore();
624
786
  const profile = authStore.getActive();
625
787
  if (!profile) throw new Error('Not logged in.');
626
- const { stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
627
788
  stopStrategyIfRunning();
628
- stopOpeningMoverIfRunning();
629
789
 
630
790
  const client = GameClient.fromAuth();
631
791
  const result = await client.joinQueue('clawclaw');
@@ -637,19 +797,12 @@ export function createGameCommand(): Command {
637
797
  // can compute `waited_secs` from a known anchor and emit the
638
798
  // match_waiting / match_timeout synthetic events.
639
799
  startMatch(stateDir);
640
- if (getRunningDaemonPid(stateDir)) {
641
- writeControl(stateDir, 'stop');
642
- await waitDaemonExit(stateDir);
643
- }
644
- const { pid } = spawnDaemon();
645
- console.log(JSON.stringify({ message: 'Event daemon started', pid }, null, 2));
646
- spawnOpeningMover();
647
800
  });
648
801
 
649
802
  game
650
803
  .command('start')
651
804
  .alias('s')
652
- .description('Attach to an active game stream or join queue, then stream events as NDJSON until game_over. Pass --no-watch to exit after allocation. Pass --force to replace an orphaned game-start stream.')
805
+ .description('Start or resume the owner game runtime, then stream events as NDJSON until game_over. Pass --no-watch to exit after allocation. Pass --force to replace an orphaned game-start runtime.')
653
806
  .option('--no-watch', 'exit after allocation instead of streaming events through game_over')
654
807
  .option('--force', 'force restart: kill any lingering game-start stream process and start fresh')
655
808
  .action(runGameStart);
@@ -711,7 +864,7 @@ export function createGameCommand(): Command {
711
864
  queue,
712
865
  waited_secs: waitedSecs,
713
866
  ...context,
714
- next_step: 'Game allocated. Stop the queue loop. The game start stream (already running since `game join`) will surface subsequent gameplay events. Narrate the role / map / opening plan to the user.',
867
+ next_step: 'Game allocated. Launch `ccl game start` to attach the owner stream if it is not already running, then narrate the role / map / opening plan to the user.',
715
868
  }, null, 2));
716
869
  return;
717
870
  }
@@ -755,18 +908,13 @@ export function createGameCommand(): Command {
755
908
  .alias('l')
756
909
  .description('Leave queue')
757
910
  .action(async () => {
758
- stopOpeningMoverIfRunning();
759
911
  const authStore = new AuthStore();
760
912
  const profile = authStore.getActive();
761
913
  if (profile) {
762
914
  const stateDir = getProfileStateDir(profile);
763
915
  endMatch(stateDir);
764
- if (getRunningDaemonPid(stateDir)) {
765
- writeControl(stateDir, 'stop');
766
- await waitDaemonExit(stateDir);
767
- }
916
+ await stopOwnerWithCommand(stateDir, 'leave');
768
917
  }
769
- const { stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
770
918
  stopStrategyIfRunning();
771
919
  const client = GameClient.fromAuth();
772
920
  const result = await client.leaveQueue('clawclaw');
@@ -777,26 +925,27 @@ export function createGameCommand(): Command {
777
925
  game
778
926
  .command('stop')
779
927
  .alias('x')
780
- .description('Stop event daemon')
928
+ .description('Stop local game runtime')
781
929
  .action(async () => {
782
930
  const authStore = new AuthStore();
783
931
  const profile = authStore.getActive();
784
932
  if (!profile) throw new Error('Not logged in.');
785
933
  const stateDir = getProfileStateDir(profile);
786
- const pid = getRunningDaemonPid(stateDir);
787
- stopOpeningMoverIfRunning();
788
- if (!pid) {
789
- console.log(JSON.stringify({ message: 'No daemon running.' }, null, 2));
934
+ const owner = await stopOwnerIfRunning(stateDir);
935
+ stopStrategyIfRunning();
936
+ if (!owner.stopped) {
937
+ console.log(JSON.stringify({ message: 'No game runtime running.' }, null, 2));
790
938
  return;
791
939
  }
792
- writeControl(stateDir, 'stop');
793
- await waitDaemonExit(stateDir);
794
- console.log(JSON.stringify({ message: 'Daemon stopped.' }, null, 2));
940
+ console.log(JSON.stringify({
941
+ message: 'Game runtime stopped.',
942
+ ...(owner.pid ? { owner_pid: owner.pid } : {}),
943
+ }, null, 2));
795
944
  });
796
945
 
797
946
  game
798
947
  .command('quit')
799
- .description('Leave active game and stop daemon')
948
+ .description('Leave active game and stop local runtime')
800
949
  .action(async () => {
801
950
  const authStore = new AuthStore();
802
951
  const profile = authStore.getActive();
@@ -806,16 +955,11 @@ export function createGameCommand(): Command {
806
955
  try {
807
956
  result = await client.leaveGame();
808
957
  } catch (err: any) {
809
- throw new Error(err?.message ?? String(err));
958
+ result = { error: err?.message ?? String(err) };
810
959
  }
811
960
  const stateDir = getProfileStateDir(profile);
812
961
  endMatch(stateDir);
813
- if (getRunningDaemonPid(stateDir)) {
814
- writeControl(stateDir, 'stop');
815
- await waitDaemonExit(stateDir);
816
- }
817
- stopOpeningMoverIfRunning();
818
- const { stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
962
+ const owner = await stopOwnerWithCommand(stateDir, 'quit');
819
963
  stopStrategyIfRunning();
820
964
  const reminder = hubReminder(readCachedGamesPlayed());
821
965
  const out: Record<string, any> = (result && typeof result === 'object' && !Array.isArray(result))
@@ -825,6 +969,7 @@ export function createGameCommand(): Command {
825
969
  out.hub_reminder = reminder;
826
970
  out.next_step = `Left the game. ${reminder}`;
827
971
  }
972
+ if (owner.pid) out.owner_pid = owner.pid;
828
973
  console.log(JSON.stringify(out, null, 2));
829
974
  });
830
975