gitdone-agent 0.8.10 → 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.
- package/index.js +206 -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.
|
|
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
|
|
|
@@ -832,7 +835,7 @@ function authUrl(auth) {
|
|
|
832
835
|
// A push rejected because the remote branch moved ahead of us (someone else
|
|
833
836
|
// pushed in the meantime). Git prints "non-fast-forward" / "fetch first" /
|
|
834
837
|
// "tip of your current branch is behind". We recover by pulling+rebasing.
|
|
835
|
-
const NON_FAST_FORWARD = /non-fast-forward|fetch first|tip of your current branch is behind|
|
|
838
|
+
const NON_FAST_FORWARD = /non-fast-forward|fetch first|tip of your current branch is behind|updates were rejected because the remote contains work|remote contains work that you do not have locally/i
|
|
836
839
|
|
|
837
840
|
// Point the local remote-tracking ref (e.g. refs/remotes/origin/master) at the
|
|
838
841
|
// commit we just synced. We push/pull through the ad-hoc token URL, and — unlike
|
|
@@ -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)
|