gitdone-agent 0.8.11 → 0.8.13

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 (2) hide show
  1. package/index.js +410 -1
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // npx gitdone-agent --key=gdo_xxx --root=C:\path\to\projects --install
7
7
  // Optional WoW progress sync:
8
8
  // npx gitdone-agent --wow="C:\Program Files (x86)\World of Warcraft\_retail_" --install
9
+ // Supported-game activity tracking is automatic on Windows.
9
10
  //
10
11
  // Then pick which discovered repos to track from gitdone.eu/github. Add more
11
12
  // roots anytime with --root (repeatable). The running agent reads its config
@@ -31,7 +32,7 @@ import { randomUUID, createHash } from 'node:crypto'
31
32
  // Reported to the server on every sync so the web UI can flag outdated agents.
32
33
  // Keep in lockstep with packages/agent/package.json. The server's offline
33
34
  // fallback is bumped only after this release has actually reached npm.
34
- const AGENT_VERSION = '0.8.11'
35
+ const AGENT_VERSION = '0.8.13'
35
36
 
36
37
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
37
38
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -39,6 +40,8 @@ const CONFIG_PATH = join(AGENT_DIR, 'config.json')
39
40
  const CONFIG_BAK_PATH = CONFIG_PATH + '.bak'
40
41
  const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
41
42
  const LOG_PATH = join(AGENT_DIR, 'agent.log')
43
+ const GAME_MONITOR_PATH = join(AGENT_DIR, 'game-monitor.ps1')
44
+ const GAME_OVERLAY_STATE_PATH = join(AGENT_DIR, 'game-overlay-state.json')
42
45
 
43
46
  function ensureAgentDir() {
44
47
  if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
@@ -203,6 +206,7 @@ function parseArgs() {
203
206
  uninstall: 'uninstall' in flags,
204
207
  doctor: 'doctor' in flags,
205
208
  testWow: flags['test-wow'] ? resolve(flags['test-wow']) : undefined,
209
+ testGames: 'test-games' in flags,
206
210
  }
207
211
  }
208
212
 
@@ -1212,6 +1216,399 @@ async function pushWowProgress(cfg) {
1212
1216
  // deadline, a proxy/network connection that never completes can block the
1213
1217
  // event-posting loop forever, so no heartbeat reaches the server and the
1214
1218
  // watchdog incorrectly declares a live AI turn stalled.
1219
+ // Each detector is locked to a verified executable hash and only opens the
1220
+ // process with query/read rights. The first profile reuses gd-657's live-tested
1221
+ // heartbeat. Future games are added here and to the website catalogue.
1222
+ const GAME_PROFILES = [
1223
+ {
1224
+ id: 'ac-black-flag-resynced',
1225
+ shortName: 'Black Flag Resynced',
1226
+ processNames: ['ACBlackFlag', 'ACBlackFlag_Plus'],
1227
+ sha256: 'EE40622D0F25126A11BF05DBEEC9C128B9B3A67373BC1FA5692E4C4C2D4665E3',
1228
+ heartbeatRvas: [0x0CCF9590, 0x0CCF9098],
1229
+ pauseDelayMs: 1350,
1230
+ },
1231
+ ]
1232
+
1233
+ const GAME_REPORT_HEARTBEAT_MS = 5_000
1234
+ const gameLastReport = new Map()
1235
+ let gameReportQueue = Promise.resolve()
1236
+ let gameDisplays = []
1237
+ let gameOverlaySettings = {
1238
+ enabled: false,
1239
+ monitor: 0,
1240
+ position: 'top-right',
1241
+ fontFamily: 'Segoe UI',
1242
+ fontSize: 24,
1243
+ transparent: true,
1244
+ }
1245
+ let activeGameOverlay = null
1246
+ let lastOverlayStateJson = ''
1247
+
1248
+ function applyGameOverlaySettings(value) {
1249
+ if (!value || typeof value !== 'object') return
1250
+ gameOverlaySettings = {
1251
+ enabled: value.enabled === true,
1252
+ monitor: Number.isInteger(value.monitor) ? Math.max(0, Math.min(15, value.monitor)) : 0,
1253
+ position: ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(value.position) ? value.position : 'top-right',
1254
+ fontFamily: typeof value.fontFamily === 'string' ? value.fontFamily.slice(0, 64) : 'Segoe UI',
1255
+ fontSize: Number.isInteger(value.fontSize) ? Math.max(14, Math.min(72, value.fontSize)) : 24,
1256
+ transparent: value.transparent !== false,
1257
+ }
1258
+ }
1259
+
1260
+ function localDayStartIso() {
1261
+ const day = new Date()
1262
+ day.setHours(0, 0, 0, 0)
1263
+ return day.toISOString()
1264
+ }
1265
+
1266
+ function formatGameClock(milliseconds) {
1267
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000))
1268
+ const hh = String(Math.floor(seconds / 3600)).padStart(2, '0')
1269
+ const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0')
1270
+ const ss = String(seconds % 60).padStart(2, '0')
1271
+ return `${hh}:${mm}:${ss}`
1272
+ }
1273
+
1274
+ function writeGameOverlayState() {
1275
+ const now = Date.now()
1276
+ const runtime = activeGameOverlay
1277
+ const todayMs = runtime
1278
+ ? runtime.todayMs + (runtime.playing ? Math.max(0, now - runtime.asOf) : 0)
1279
+ : 0
1280
+ const profile = runtime ? GAME_PROFILES.find((item) => item.id === runtime.gameId) : null
1281
+ const state = {
1282
+ ...gameOverlaySettings,
1283
+ visible: gameOverlaySettings.enabled && runtime?.playing === true,
1284
+ text: profile ? `${profile.shortName} · Днес ${formatGameClock(todayMs)}` : '',
1285
+ }
1286
+ const json = JSON.stringify(state)
1287
+ if (json === lastOverlayStateJson) return
1288
+ lastOverlayStateJson = json
1289
+ try {
1290
+ ensureAgentDir()
1291
+ const temp = GAME_OVERLAY_STATE_PATH + '.tmp'
1292
+ writeFileSync(temp, json, 'utf8')
1293
+ renameSync(temp, GAME_OVERLAY_STATE_PATH)
1294
+ } catch (err) {
1295
+ log(`✗ game overlay state write failed: ${err.message}`)
1296
+ }
1297
+ }
1298
+
1299
+ function observeGameActivity(gameId, state, observedAt) {
1300
+ const at = Date.parse(observedAt) || Date.now()
1301
+ const playing = state === 'playing'
1302
+ if (!activeGameOverlay || activeGameOverlay.gameId !== gameId) {
1303
+ if (playing) activeGameOverlay = { gameId, playing: true, todayMs: 0, asOf: at }
1304
+ } else if (activeGameOverlay.playing !== playing) {
1305
+ if (activeGameOverlay.playing) {
1306
+ activeGameOverlay.todayMs += Math.max(0, at - activeGameOverlay.asOf)
1307
+ }
1308
+ activeGameOverlay.playing = playing
1309
+ activeGameOverlay.asOf = at
1310
+ }
1311
+ writeGameOverlayState()
1312
+ }
1313
+
1314
+ function gameMonitorScript() {
1315
+ const profiles = Buffer.from(JSON.stringify(GAME_PROFILES), 'utf8').toString('base64')
1316
+ const overlayStatePath = Buffer.from(GAME_OVERLAY_STATE_PATH, 'utf8').toString('base64')
1317
+ return String.raw`
1318
+ $ErrorActionPreference = 'Stop'
1319
+ Add-Type -AssemblyName System.Windows.Forms
1320
+ Add-Type -AssemblyName System.Drawing
1321
+ Add-Type -TypeDefinition @'
1322
+ using System;
1323
+ using System.Runtime.InteropServices;
1324
+ public static class GitDoneGameMemory {
1325
+ [DllImport("kernel32.dll", SetLastError=true)]
1326
+ public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
1327
+ [DllImport("kernel32.dll", SetLastError=true)]
1328
+ public static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer, int size, out UIntPtr read);
1329
+ [DllImport("kernel32.dll")]
1330
+ public static extern bool CloseHandle(IntPtr handle);
1331
+ [DllImport("user32.dll", SetLastError=true)]
1332
+ public static extern int GetWindowLong(IntPtr window, int index);
1333
+ [DllImport("user32.dll", SetLastError=true)]
1334
+ public static extern int SetWindowLong(IntPtr window, int index, int value);
1335
+ public static UInt32 ReadUInt32(IntPtr process, UInt64 address) {
1336
+ var bytes = new byte[4]; UIntPtr read;
1337
+ if (!ReadProcessMemory(process, new IntPtr(unchecked((long)address)), bytes, 4, out read) || read.ToUInt64() != 4)
1338
+ throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
1339
+ return BitConverter.ToUInt32(bytes, 0);
1340
+ }
1341
+ }
1342
+ '@
1343
+ $profilesJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${profiles}'))
1344
+ $profiles = @($profilesJson | ConvertFrom-Json)
1345
+ $overlayStatePath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${overlayStatePath}'))
1346
+ $attached = @{}
1347
+
1348
+ $form = New-Object System.Windows.Forms.Form
1349
+ $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
1350
+ $form.ShowInTaskbar = $false
1351
+ $form.TopMost = $true
1352
+ $form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
1353
+ $form.BackColor = [System.Drawing.Color]::Black
1354
+ $form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::Dpi
1355
+ $label = New-Object System.Windows.Forms.Label
1356
+ $label.AutoSize = $true
1357
+ $label.ForeColor = [System.Drawing.Color]::White
1358
+ $label.BackColor = [System.Drawing.Color]::Transparent
1359
+ $label.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
1360
+ $form.Controls.Add($label)
1361
+ $null = $form.Handle
1362
+ $extendedStyle = [GitDoneGameMemory]::GetWindowLong($form.Handle, -20)
1363
+ # WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE: never steal a click,
1364
+ # never take keyboard focus from the game, and stay out of Alt+Tab.
1365
+ [GitDoneGameMemory]::SetWindowLong($form.Handle, -20, $extendedStyle -bor 0x20 -bor 0x80 -bor 0x08000000) | Out-Null
1366
+ $form.Hide()
1367
+ $lastOverlayWrite = [DateTime]::MinValue
1368
+ $overlayWasVisible = $false
1369
+
1370
+ $screens = @([System.Windows.Forms.Screen]::AllScreens)
1371
+ $displayRows = @()
1372
+ for ($index = 0; $index -lt $screens.Count; $index++) {
1373
+ $screen = $screens[$index]
1374
+ $displayRows += [pscustomobject]@{
1375
+ index = $index
1376
+ name = $screen.DeviceName
1377
+ primary = $screen.Primary
1378
+ width = $screen.Bounds.Width
1379
+ height = $screen.Bounds.Height
1380
+ }
1381
+ }
1382
+ [pscustomobject]@{ type='displays'; displays=$displayRows } | ConvertTo-Json -Depth 4 -Compress
1383
+ [Console]::Out.Flush()
1384
+
1385
+ function Update-Overlay {
1386
+ try {
1387
+ if (!(Test-Path -LiteralPath $overlayStatePath)) { $form.Hide(); $script:overlayWasVisible = $false; return }
1388
+ $write = (Get-Item -LiteralPath $overlayStatePath).LastWriteTimeUtc
1389
+ if ($write -eq $lastOverlayWrite) { return }
1390
+ $script:lastOverlayWrite = $write
1391
+ $state = Get-Content -LiteralPath $overlayStatePath -Raw | ConvertFrom-Json
1392
+ if (!$state.enabled -or !$state.visible -or [string]::IsNullOrWhiteSpace($state.text)) {
1393
+ $form.Hide()
1394
+ $script:overlayWasVisible = $false
1395
+ return
1396
+ }
1397
+
1398
+ $screenIndex = [Math]::Max(0, [Math]::Min($screens.Count - 1, [int]$state.monitor))
1399
+ $screen = $screens[$screenIndex]
1400
+ $label.Text = [string]$state.text
1401
+ $label.Font = [System.Drawing.Font]::new([string]$state.fontFamily, [single]$state.fontSize, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
1402
+ $paddingX = if ($state.transparent) { 3 } else { 12 }
1403
+ $paddingY = if ($state.transparent) { 2 } else { 7 }
1404
+ $label.Location = [System.Drawing.Point]::new([int]$paddingX, [int]$paddingY)
1405
+ $form.ClientSize = [System.Drawing.Size]::new([int]($label.PreferredWidth + 2 * $paddingX), [int]($label.PreferredHeight + 2 * $paddingY))
1406
+ if ($state.transparent) {
1407
+ $form.BackColor = [System.Drawing.Color]::Magenta
1408
+ $form.TransparencyKey = [System.Drawing.Color]::Magenta
1409
+ } else {
1410
+ $form.TransparencyKey = [System.Drawing.Color]::Empty
1411
+ $form.BackColor = [System.Drawing.Color]::FromArgb(22, 27, 38)
1412
+ }
1413
+
1414
+ $margin = 24
1415
+ $area = $screen.WorkingArea
1416
+ $x = switch ([string]$state.position) {
1417
+ 'top-left' { $area.Left + $margin; break }
1418
+ 'bottom-left' { $area.Left + $margin; break }
1419
+ 'top-center' { $area.Left + [Math]::Floor(($area.Width - $form.Width) / 2); break }
1420
+ 'bottom-center' { $area.Left + [Math]::Floor(($area.Width - $form.Width) / 2); break }
1421
+ default { $area.Right - $form.Width - $margin }
1422
+ }
1423
+ $y = if ([string]$state.position -like 'bottom-*') { $area.Bottom - $form.Height - $margin } else { $area.Top + $margin }
1424
+ $form.Location = [System.Drawing.Point]::new([int]$x, [int]$y)
1425
+ if (!$form.Visible) { $form.Show() }
1426
+ if (!$script:overlayWasVisible -and $form.Visible) {
1427
+ [pscustomobject]@{ type='overlay'; visible=$true; monitor=$screenIndex; position=[string]$state.position } | ConvertTo-Json -Compress
1428
+ [Console]::Out.Flush()
1429
+ $script:overlayWasVisible = $true
1430
+ }
1431
+ } catch {
1432
+ $form.Hide()
1433
+ $script:overlayWasVisible = $false
1434
+ [pscustomobject]@{ type='overlay'; visible=$false; error=$_.Exception.Message } | ConvertTo-Json -Compress
1435
+ [Console]::Out.Flush()
1436
+ }
1437
+ }
1438
+
1439
+ function Emit-State($profile, $state, $detail) {
1440
+ [pscustomobject]@{
1441
+ gameId = $profile.id
1442
+ state = $state
1443
+ detail = $detail
1444
+ observedAt = [DateTime]::UtcNow.ToString('o')
1445
+ } | ConvertTo-Json -Compress
1446
+ [Console]::Out.Flush()
1447
+ }
1448
+
1449
+ while ($true) {
1450
+ [System.Windows.Forms.Application]::DoEvents()
1451
+ Update-Overlay
1452
+ foreach ($profile in $profiles) {
1453
+ $process = $null
1454
+ foreach ($processName in @($profile.processNames)) {
1455
+ $process = Get-Process -Name $processName -ErrorAction SilentlyContinue | Select-Object -First 1
1456
+ if ($null -ne $process) { break }
1457
+ }
1458
+
1459
+ $entry = $attached[$profile.id]
1460
+ if ($null -eq $process) {
1461
+ if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
1462
+ $attached.Remove($profile.id)
1463
+ Emit-State $profile 'not_running' 'process not found'
1464
+ continue
1465
+ }
1466
+
1467
+ try {
1468
+ if ($null -eq $entry -or $entry.Pid -ne $process.Id) {
1469
+ if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
1470
+ $path = $process.MainModule.FileName
1471
+ $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
1472
+ if ($hash -ne $profile.sha256) {
1473
+ $entry = [pscustomobject]@{ Pid=$process.Id; Handle=[IntPtr]::Zero; Unsupported=$true }
1474
+ $attached[$profile.id] = $entry
1475
+ Emit-State $profile 'unsupported' 'executable hash does not match a verified profile'
1476
+ continue
1477
+ }
1478
+ $handle = [GitDoneGameMemory]::OpenProcess(0x1010, $false, $process.Id)
1479
+ if ($handle -eq [IntPtr]::Zero) { throw 'OpenProcess failed' }
1480
+ $entry = [pscustomobject]@{
1481
+ Pid = $process.Id
1482
+ Handle = $handle
1483
+ Unsupported = $false
1484
+ Base = [UInt64]$process.MainModule.BaseAddress.ToInt64()
1485
+ LastHeartbeat = $null
1486
+ LastChangeAt = [DateTime]::UtcNow
1487
+ ObservedChange = $false
1488
+ }
1489
+ $attached[$profile.id] = $entry
1490
+ }
1491
+ if ($entry.Unsupported) {
1492
+ Emit-State $profile 'unsupported' 'executable hash does not match a verified profile'
1493
+ continue
1494
+ }
1495
+
1496
+ $heartbeat = [UInt64]0
1497
+ $shift = 0
1498
+ foreach ($rva in @($profile.heartbeatRvas)) {
1499
+ $part = [GitDoneGameMemory]::ReadUInt32($entry.Handle, $entry.Base + [UInt64]$rva)
1500
+ $heartbeat = $heartbeat -bxor ([UInt64]$part -shl $shift)
1501
+ $shift = ($shift + 32) % 64
1502
+ }
1503
+ $now = [DateTime]::UtcNow
1504
+ if ($null -eq $entry.LastHeartbeat) {
1505
+ $entry.LastHeartbeat = $heartbeat
1506
+ $entry.LastChangeAt = $now
1507
+ Emit-State $profile 'detecting' 'waiting for a game heartbeat change'
1508
+ } elseif ($entry.LastHeartbeat -ne $heartbeat) {
1509
+ $entry.LastHeartbeat = $heartbeat
1510
+ $entry.LastChangeAt = $now
1511
+ $entry.ObservedChange = $true
1512
+ Emit-State $profile 'playing' 'game world is updating'
1513
+ } elseif (($now - $entry.LastChangeAt).TotalMilliseconds -ge [int]$profile.pauseDelayMs) {
1514
+ Emit-State $profile 'paused' 'game world is paused or in a menu'
1515
+ } elseif ($entry.ObservedChange) {
1516
+ Emit-State $profile 'playing' 'game world is updating'
1517
+ } else {
1518
+ Emit-State $profile 'detecting' 'waiting for a game heartbeat change'
1519
+ }
1520
+ } catch {
1521
+ if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
1522
+ $attached.Remove($profile.id)
1523
+ Emit-State $profile 'error' $_.Exception.Message
1524
+ }
1525
+ }
1526
+ Start-Sleep -Milliseconds 250
1527
+ }
1528
+ `
1529
+ }
1530
+
1531
+ function queueGameReport(cfg, reading) {
1532
+ if (!GAME_PROFILES.some((profile) => profile.id === reading?.gameId)) return
1533
+ const state = reading.state === 'detecting' ? 'paused' : reading.state
1534
+ if (!['playing', 'paused', 'not_running', 'unsupported', 'error'].includes(state)) return
1535
+ observeGameActivity(reading.gameId, state, reading.observedAt)
1536
+
1537
+ const now = Date.now()
1538
+ const previous = gameLastReport.get(reading.gameId)
1539
+ if (previous?.state === state && (state !== 'playing' || now - previous.at < GAME_REPORT_HEARTBEAT_MS)) return
1540
+ gameLastReport.set(reading.gameId, { state, at: now })
1541
+
1542
+ gameReportQueue = gameReportQueue
1543
+ .then(async () => {
1544
+ const result = await api(cfg, '/api/v1/games/activity', {
1545
+ machineId: cfg.machineId,
1546
+ gameId: reading.gameId,
1547
+ state,
1548
+ observedAt: reading.observedAt || new Date().toISOString(),
1549
+ dayStart: localDayStartIso(),
1550
+ })
1551
+ applyGameOverlaySettings(result.gameOverlay)
1552
+ if (activeGameOverlay?.gameId === reading.gameId &&
1553
+ activeGameOverlay.playing === (state === 'playing') &&
1554
+ typeof result.todayMs === 'number') {
1555
+ activeGameOverlay.todayMs = Math.max(0, result.todayMs)
1556
+ activeGameOverlay.asOf = Date.parse(result.asOf) || Date.now()
1557
+ }
1558
+ writeGameOverlayState()
1559
+ if (previous?.state !== state) log(`🎮 ${reading.gameId}: ${state}`)
1560
+ })
1561
+ .catch((err) => log(`✗ game activity sync failed: ${err.message}`))
1562
+ }
1563
+
1564
+ async function startGameActivityMonitor(cfg, printOnly = false) {
1565
+ if (process.platform !== 'win32') {
1566
+ if (printOnly) console.log('Game activity detection is currently available on Windows only.')
1567
+ return
1568
+ }
1569
+ for (;;) {
1570
+ try {
1571
+ ensureAgentDir()
1572
+ writeFileSync(GAME_MONITOR_PATH, gameMonitorScript(), 'utf8')
1573
+ const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', GAME_MONITOR_PATH], {
1574
+ windowsHide: true,
1575
+ stdio: ['ignore', 'pipe', 'pipe'],
1576
+ })
1577
+ const lines = readline.createInterface({ input: child.stdout })
1578
+ lines.on('line', (line) => {
1579
+ try {
1580
+ const reading = JSON.parse(line)
1581
+ if (reading.type === 'displays' && Array.isArray(reading.displays)) {
1582
+ gameDisplays = reading.displays
1583
+ if (printOnly) console.log(`Displays: ${JSON.stringify(gameDisplays)}`)
1584
+ return
1585
+ }
1586
+ if (reading.type === 'overlay') {
1587
+ if (printOnly) console.log(reading.error ? `Overlay error: ${reading.error}` : `Overlay: ${reading.visible ? 'visible' : 'hidden'} on monitor ${Number(reading.monitor) + 1}`)
1588
+ else if (reading.error) log(`✗ game overlay error: ${reading.error}`)
1589
+ return
1590
+ }
1591
+ if (printOnly) console.log(`${reading.observedAt} ${reading.gameId}: ${reading.state} — ${reading.detail}`)
1592
+ else queueGameReport(cfg, reading)
1593
+ } catch { /* ignore non-JSON PowerShell host noise */ }
1594
+ })
1595
+ let stderr = ''
1596
+ child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) })
1597
+ const exitCode = await new Promise((resolveExit) => child.once('exit', resolveExit))
1598
+ lines.close()
1599
+ for (const profile of GAME_PROFILES) {
1600
+ const reading = { gameId: profile.id, state: 'error', observedAt: new Date().toISOString() }
1601
+ if (printOnly) console.error(`${reading.observedAt} ${profile.id}: monitor stopped`)
1602
+ else queueGameReport(cfg, reading)
1603
+ }
1604
+ log(`… game monitor stopped (${exitCode}${stderr ? `: ${stderr.trim()}` : ''}); restarting`)
1605
+ } catch (err) {
1606
+ log(`… game monitor unavailable (${err.message}); retrying`)
1607
+ }
1608
+ await new Promise((resolveWait) => setTimeout(resolveWait, 3000))
1609
+ }
1610
+ }
1611
+
1215
1612
  const API_REQUEST_TIMEOUT_MS = 10_000
1216
1613
 
1217
1614
  async function apiOnce(cfg, path, body) {
@@ -3023,16 +3420,19 @@ async function sync(cfg, discovered) {
3023
3420
  agentVersion: AGENT_VERSION,
3024
3421
  roots: cfg.roots,
3025
3422
  repos: full ? discovered : [],
3423
+ gameDisplays,
3026
3424
  ...(usage ? { usage } : {}),
3027
3425
  ...(codexUsage ? { codexUsage } : {}),
3028
3426
  })
3029
3427
  if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
3030
3428
  else syncFullCountdown--
3429
+ applyGameOverlaySettings(data.gameOverlay)
3031
3430
  return {
3032
3431
  tracked: data.tracked ?? [],
3033
3432
  latestVersion: data.latestVersion,
3034
3433
  downloadUrl: data.downloadUrl,
3035
3434
  downloadSha256: data.downloadSha256,
3435
+ gameOverlay: data.gameOverlay,
3036
3436
  }
3037
3437
  }
3038
3438
 
@@ -3232,6 +3632,9 @@ async function runLoop(cfg) {
3232
3632
  // Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
3233
3633
  // its own reconnect loop and never rejects.
3234
3634
  streamCommands(cfg)
3635
+ writeGameOverlayState()
3636
+ setInterval(writeGameOverlayState, 1000)
3637
+ startGameActivityMonitor(cfg)
3235
3638
 
3236
3639
  await tick()
3237
3640
  setInterval(tick, cfg.interval * 1000)
@@ -3246,6 +3649,12 @@ async function main() {
3246
3649
  process.exit(0)
3247
3650
  }
3248
3651
 
3652
+ if (args.testGames) {
3653
+ const cfg = buildConfig(args)
3654
+ await startGameActivityMonitor(cfg, true)
3655
+ return
3656
+ }
3657
+
3249
3658
  if (args.doctor) {
3250
3659
  runDoctor()
3251
3660
  process.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "Local gitDone companion for repository and optional World of Warcraft progress sync",
5
5
  "type": "module",
6
6
  "files": [