gitdone-agent 0.8.11 → 0.8.12

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 +205 -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.12'
35
36
 
36
37
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
37
38
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -39,6 +40,7 @@ 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')
42
44
 
43
45
  function ensureAgentDir() {
44
46
  if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
@@ -203,6 +205,7 @@ function parseArgs() {
203
205
  uninstall: 'uninstall' in flags,
204
206
  doctor: 'doctor' in flags,
205
207
  testWow: flags['test-wow'] ? resolve(flags['test-wow']) : undefined,
208
+ testGames: 'test-games' in flags,
206
209
  }
207
210
  }
208
211
 
@@ -1212,6 +1215,200 @@ async function pushWowProgress(cfg) {
1212
1215
  // deadline, a proxy/network connection that never completes can block the
1213
1216
  // event-posting loop forever, so no heartbeat reaches the server and the
1214
1217
  // watchdog incorrectly declares a live AI turn stalled.
1218
+ // Each detector is locked to a verified executable hash and only opens the
1219
+ // process with query/read rights. The first profile reuses gd-657's live-tested
1220
+ // heartbeat. Future games are added here and to the website catalogue.
1221
+ const GAME_PROFILES = [
1222
+ {
1223
+ id: 'ac-black-flag-resynced',
1224
+ processNames: ['ACBlackFlag', 'ACBlackFlag_Plus'],
1225
+ sha256: 'EE40622D0F25126A11BF05DBEEC9C128B9B3A67373BC1FA5692E4C4C2D4665E3',
1226
+ heartbeatRvas: [0x0CCF9590, 0x0CCF9098],
1227
+ pauseDelayMs: 1350,
1228
+ },
1229
+ ]
1230
+
1231
+ const GAME_REPORT_HEARTBEAT_MS = 5_000
1232
+ const gameLastReport = new Map()
1233
+ let gameReportQueue = Promise.resolve()
1234
+
1235
+ function gameMonitorScript() {
1236
+ const profiles = Buffer.from(JSON.stringify(GAME_PROFILES), 'utf8').toString('base64')
1237
+ return String.raw`
1238
+ $ErrorActionPreference = 'Stop'
1239
+ Add-Type -TypeDefinition @'
1240
+ using System;
1241
+ using System.Runtime.InteropServices;
1242
+ public static class GitDoneGameMemory {
1243
+ [DllImport("kernel32.dll", SetLastError=true)]
1244
+ public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
1245
+ [DllImport("kernel32.dll", SetLastError=true)]
1246
+ public static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer, int size, out UIntPtr read);
1247
+ [DllImport("kernel32.dll")]
1248
+ public static extern bool CloseHandle(IntPtr handle);
1249
+ public static UInt32 ReadUInt32(IntPtr process, UInt64 address) {
1250
+ var bytes = new byte[4]; UIntPtr read;
1251
+ if (!ReadProcessMemory(process, new IntPtr(unchecked((long)address)), bytes, 4, out read) || read.ToUInt64() != 4)
1252
+ throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
1253
+ return BitConverter.ToUInt32(bytes, 0);
1254
+ }
1255
+ }
1256
+ '@
1257
+ $profilesJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${profiles}'))
1258
+ $profiles = @($profilesJson | ConvertFrom-Json)
1259
+ $attached = @{}
1260
+
1261
+ function Emit-State($profile, $state, $detail) {
1262
+ [pscustomobject]@{
1263
+ gameId = $profile.id
1264
+ state = $state
1265
+ detail = $detail
1266
+ observedAt = [DateTime]::UtcNow.ToString('o')
1267
+ } | ConvertTo-Json -Compress
1268
+ [Console]::Out.Flush()
1269
+ }
1270
+
1271
+ while ($true) {
1272
+ foreach ($profile in $profiles) {
1273
+ $process = $null
1274
+ foreach ($processName in @($profile.processNames)) {
1275
+ $process = Get-Process -Name $processName -ErrorAction SilentlyContinue | Select-Object -First 1
1276
+ if ($null -ne $process) { break }
1277
+ }
1278
+
1279
+ $entry = $attached[$profile.id]
1280
+ if ($null -eq $process) {
1281
+ if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
1282
+ $attached.Remove($profile.id)
1283
+ Emit-State $profile 'not_running' 'process not found'
1284
+ continue
1285
+ }
1286
+
1287
+ try {
1288
+ if ($null -eq $entry -or $entry.Pid -ne $process.Id) {
1289
+ if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
1290
+ $path = $process.MainModule.FileName
1291
+ $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
1292
+ if ($hash -ne $profile.sha256) {
1293
+ $entry = [pscustomobject]@{ Pid=$process.Id; Handle=[IntPtr]::Zero; Unsupported=$true }
1294
+ $attached[$profile.id] = $entry
1295
+ Emit-State $profile 'unsupported' 'executable hash does not match a verified profile'
1296
+ continue
1297
+ }
1298
+ $handle = [GitDoneGameMemory]::OpenProcess(0x1010, $false, $process.Id)
1299
+ if ($handle -eq [IntPtr]::Zero) { throw 'OpenProcess failed' }
1300
+ $entry = [pscustomobject]@{
1301
+ Pid = $process.Id
1302
+ Handle = $handle
1303
+ Unsupported = $false
1304
+ Base = [UInt64]$process.MainModule.BaseAddress.ToInt64()
1305
+ LastHeartbeat = $null
1306
+ LastChangeAt = [DateTime]::UtcNow
1307
+ ObservedChange = $false
1308
+ }
1309
+ $attached[$profile.id] = $entry
1310
+ }
1311
+ if ($entry.Unsupported) {
1312
+ Emit-State $profile 'unsupported' 'executable hash does not match a verified profile'
1313
+ continue
1314
+ }
1315
+
1316
+ $heartbeat = [UInt64]0
1317
+ $shift = 0
1318
+ foreach ($rva in @($profile.heartbeatRvas)) {
1319
+ $part = [GitDoneGameMemory]::ReadUInt32($entry.Handle, $entry.Base + [UInt64]$rva)
1320
+ $heartbeat = $heartbeat -bxor ([UInt64]$part -shl $shift)
1321
+ $shift = ($shift + 32) % 64
1322
+ }
1323
+ $now = [DateTime]::UtcNow
1324
+ if ($null -eq $entry.LastHeartbeat) {
1325
+ $entry.LastHeartbeat = $heartbeat
1326
+ $entry.LastChangeAt = $now
1327
+ Emit-State $profile 'detecting' 'waiting for a game heartbeat change'
1328
+ } elseif ($entry.LastHeartbeat -ne $heartbeat) {
1329
+ $entry.LastHeartbeat = $heartbeat
1330
+ $entry.LastChangeAt = $now
1331
+ $entry.ObservedChange = $true
1332
+ Emit-State $profile 'playing' 'game world is updating'
1333
+ } elseif (($now - $entry.LastChangeAt).TotalMilliseconds -ge [int]$profile.pauseDelayMs) {
1334
+ Emit-State $profile 'paused' 'game world is paused or in a menu'
1335
+ } elseif ($entry.ObservedChange) {
1336
+ Emit-State $profile 'playing' 'game world is updating'
1337
+ } else {
1338
+ Emit-State $profile 'detecting' 'waiting for a game heartbeat change'
1339
+ }
1340
+ } catch {
1341
+ if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
1342
+ $attached.Remove($profile.id)
1343
+ Emit-State $profile 'error' $_.Exception.Message
1344
+ }
1345
+ }
1346
+ Start-Sleep -Milliseconds 250
1347
+ }
1348
+ `
1349
+ }
1350
+
1351
+ function queueGameReport(cfg, reading) {
1352
+ if (!GAME_PROFILES.some((profile) => profile.id === reading?.gameId)) return
1353
+ const state = reading.state === 'detecting' ? 'paused' : reading.state
1354
+ if (!['playing', 'paused', 'not_running', 'unsupported', 'error'].includes(state)) return
1355
+
1356
+ const now = Date.now()
1357
+ const previous = gameLastReport.get(reading.gameId)
1358
+ if (previous?.state === state && (state !== 'playing' || now - previous.at < GAME_REPORT_HEARTBEAT_MS)) return
1359
+ gameLastReport.set(reading.gameId, { state, at: now })
1360
+
1361
+ gameReportQueue = gameReportQueue
1362
+ .then(async () => {
1363
+ await api(cfg, '/api/v1/games/activity', {
1364
+ machineId: cfg.machineId,
1365
+ gameId: reading.gameId,
1366
+ state,
1367
+ observedAt: reading.observedAt || new Date().toISOString(),
1368
+ })
1369
+ if (previous?.state !== state) log(`🎮 ${reading.gameId}: ${state}`)
1370
+ })
1371
+ .catch((err) => log(`✗ game activity sync failed: ${err.message}`))
1372
+ }
1373
+
1374
+ async function startGameActivityMonitor(cfg, printOnly = false) {
1375
+ if (process.platform !== 'win32') {
1376
+ if (printOnly) console.log('Game activity detection is currently available on Windows only.')
1377
+ return
1378
+ }
1379
+ for (;;) {
1380
+ try {
1381
+ ensureAgentDir()
1382
+ writeFileSync(GAME_MONITOR_PATH, gameMonitorScript(), 'utf8')
1383
+ const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', GAME_MONITOR_PATH], {
1384
+ windowsHide: true,
1385
+ stdio: ['ignore', 'pipe', 'pipe'],
1386
+ })
1387
+ const lines = readline.createInterface({ input: child.stdout })
1388
+ lines.on('line', (line) => {
1389
+ try {
1390
+ const reading = JSON.parse(line)
1391
+ if (printOnly) console.log(`${reading.observedAt} ${reading.gameId}: ${reading.state} — ${reading.detail}`)
1392
+ else queueGameReport(cfg, reading)
1393
+ } catch { /* ignore non-JSON PowerShell host noise */ }
1394
+ })
1395
+ let stderr = ''
1396
+ child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) })
1397
+ const exitCode = await new Promise((resolveExit) => child.once('exit', resolveExit))
1398
+ lines.close()
1399
+ for (const profile of GAME_PROFILES) {
1400
+ const reading = { gameId: profile.id, state: 'error', observedAt: new Date().toISOString() }
1401
+ if (printOnly) console.error(`${reading.observedAt} ${profile.id}: monitor stopped`)
1402
+ else queueGameReport(cfg, reading)
1403
+ }
1404
+ log(`… game monitor stopped (${exitCode}${stderr ? `: ${stderr.trim()}` : ''}); restarting`)
1405
+ } catch (err) {
1406
+ log(`… game monitor unavailable (${err.message}); retrying`)
1407
+ }
1408
+ await new Promise((resolveWait) => setTimeout(resolveWait, 3000))
1409
+ }
1410
+ }
1411
+
1215
1412
  const API_REQUEST_TIMEOUT_MS = 10_000
1216
1413
 
1217
1414
  async function apiOnce(cfg, path, body) {
@@ -3232,6 +3429,7 @@ async function runLoop(cfg) {
3232
3429
  // Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
3233
3430
  // its own reconnect loop and never rejects.
3234
3431
  streamCommands(cfg)
3432
+ startGameActivityMonitor(cfg)
3235
3433
 
3236
3434
  await tick()
3237
3435
  setInterval(tick, cfg.interval * 1000)
@@ -3246,6 +3444,12 @@ async function main() {
3246
3444
  process.exit(0)
3247
3445
  }
3248
3446
 
3447
+ if (args.testGames) {
3448
+ const cfg = buildConfig(args)
3449
+ await startGameActivityMonitor(cfg, true)
3450
+ return
3451
+ }
3452
+
3249
3453
  if (args.doctor) {
3250
3454
  runDoctor()
3251
3455
  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.12",
4
4
  "description": "Local gitDone companion for repository and optional World of Warcraft progress sync",
5
5
  "type": "module",
6
6
  "files": [