gitdone-agent 0.8.12 → 0.8.14
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 +217 -2
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -32,7 +32,7 @@ import { randomUUID, createHash } from 'node:crypto'
|
|
|
32
32
|
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
33
33
|
// Keep in lockstep with packages/agent/package.json. The server's offline
|
|
34
34
|
// fallback is bumped only after this release has actually reached npm.
|
|
35
|
-
const AGENT_VERSION = '0.8.
|
|
35
|
+
const AGENT_VERSION = '0.8.14'
|
|
36
36
|
|
|
37
37
|
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
38
38
|
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
@@ -41,6 +41,7 @@ const CONFIG_BAK_PATH = CONFIG_PATH + '.bak'
|
|
|
41
41
|
const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
|
|
42
42
|
const LOG_PATH = join(AGENT_DIR, 'agent.log')
|
|
43
43
|
const GAME_MONITOR_PATH = join(AGENT_DIR, 'game-monitor.ps1')
|
|
44
|
+
const GAME_OVERLAY_STATE_PATH = join(AGENT_DIR, 'game-overlay-state.json')
|
|
44
45
|
|
|
45
46
|
function ensureAgentDir() {
|
|
46
47
|
if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
|
|
@@ -1221,6 +1222,7 @@ async function pushWowProgress(cfg) {
|
|
|
1221
1222
|
const GAME_PROFILES = [
|
|
1222
1223
|
{
|
|
1223
1224
|
id: 'ac-black-flag-resynced',
|
|
1225
|
+
shortName: 'Black Flag Resynced',
|
|
1224
1226
|
processNames: ['ACBlackFlag', 'ACBlackFlag_Plus'],
|
|
1225
1227
|
sha256: 'EE40622D0F25126A11BF05DBEEC9C128B9B3A67373BC1FA5692E4C4C2D4665E3',
|
|
1226
1228
|
heartbeatRvas: [0x0CCF9590, 0x0CCF9098],
|
|
@@ -1231,11 +1233,96 @@ const GAME_PROFILES = [
|
|
|
1231
1233
|
const GAME_REPORT_HEARTBEAT_MS = 5_000
|
|
1232
1234
|
const gameLastReport = new Map()
|
|
1233
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
|
+
// Keep the clock on-screen for the whole time the game is running. During
|
|
1284
|
+
// pause/menu states it stays visible but frozen; only active gameplay adds
|
|
1285
|
+
// time. This also prevents brief detector transitions from blinking it.
|
|
1286
|
+
visible: gameOverlaySettings.enabled && runtime?.running === true,
|
|
1287
|
+
text: profile ? `${profile.shortName} · Днес ${formatGameClock(todayMs)}` : '',
|
|
1288
|
+
}
|
|
1289
|
+
const json = JSON.stringify(state)
|
|
1290
|
+
if (json === lastOverlayStateJson) return
|
|
1291
|
+
lastOverlayStateJson = json
|
|
1292
|
+
try {
|
|
1293
|
+
ensureAgentDir()
|
|
1294
|
+
const temp = GAME_OVERLAY_STATE_PATH + '.tmp'
|
|
1295
|
+
writeFileSync(temp, json, 'utf8')
|
|
1296
|
+
renameSync(temp, GAME_OVERLAY_STATE_PATH)
|
|
1297
|
+
} catch (err) {
|
|
1298
|
+
log(`✗ game overlay state write failed: ${err.message}`)
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function observeGameActivity(gameId, state, observedAt) {
|
|
1303
|
+
const at = Date.parse(observedAt) || Date.now()
|
|
1304
|
+
const playing = state === 'playing'
|
|
1305
|
+
const running = state === 'playing' || state === 'paused'
|
|
1306
|
+
if (!activeGameOverlay || activeGameOverlay.gameId !== gameId) {
|
|
1307
|
+
if (running) activeGameOverlay = { gameId, playing, running: true, todayMs: 0, asOf: at }
|
|
1308
|
+
} else if (activeGameOverlay.playing !== playing) {
|
|
1309
|
+
if (activeGameOverlay.playing) {
|
|
1310
|
+
activeGameOverlay.todayMs += Math.max(0, at - activeGameOverlay.asOf)
|
|
1311
|
+
}
|
|
1312
|
+
activeGameOverlay.playing = playing
|
|
1313
|
+
activeGameOverlay.asOf = at
|
|
1314
|
+
}
|
|
1315
|
+
if (activeGameOverlay?.gameId === gameId) activeGameOverlay.running = running
|
|
1316
|
+
writeGameOverlayState()
|
|
1317
|
+
}
|
|
1234
1318
|
|
|
1235
1319
|
function gameMonitorScript() {
|
|
1236
1320
|
const profiles = Buffer.from(JSON.stringify(GAME_PROFILES), 'utf8').toString('base64')
|
|
1321
|
+
const overlayStatePath = Buffer.from(GAME_OVERLAY_STATE_PATH, 'utf8').toString('base64')
|
|
1237
1322
|
return String.raw`
|
|
1238
1323
|
$ErrorActionPreference = 'Stop'
|
|
1324
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
1325
|
+
Add-Type -AssemblyName System.Drawing
|
|
1239
1326
|
Add-Type -TypeDefinition @'
|
|
1240
1327
|
using System;
|
|
1241
1328
|
using System.Runtime.InteropServices;
|
|
@@ -1246,6 +1333,12 @@ public static class GitDoneGameMemory {
|
|
|
1246
1333
|
public static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer, int size, out UIntPtr read);
|
|
1247
1334
|
[DllImport("kernel32.dll")]
|
|
1248
1335
|
public static extern bool CloseHandle(IntPtr handle);
|
|
1336
|
+
[DllImport("user32.dll", SetLastError=true)]
|
|
1337
|
+
public static extern int GetWindowLong(IntPtr window, int index);
|
|
1338
|
+
[DllImport("user32.dll", SetLastError=true)]
|
|
1339
|
+
public static extern int SetWindowLong(IntPtr window, int index, int value);
|
|
1340
|
+
[DllImport("user32.dll", SetLastError=true)]
|
|
1341
|
+
public static extern bool SetWindowPos(IntPtr window, IntPtr insertAfter, int x, int y, int width, int height, uint flags);
|
|
1249
1342
|
public static UInt32 ReadUInt32(IntPtr process, UInt64 address) {
|
|
1250
1343
|
var bytes = new byte[4]; UIntPtr read;
|
|
1251
1344
|
if (!ReadProcessMemory(process, new IntPtr(unchecked((long)address)), bytes, 4, out read) || read.ToUInt64() != 4)
|
|
@@ -1256,8 +1349,103 @@ public static class GitDoneGameMemory {
|
|
|
1256
1349
|
'@
|
|
1257
1350
|
$profilesJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${profiles}'))
|
|
1258
1351
|
$profiles = @($profilesJson | ConvertFrom-Json)
|
|
1352
|
+
$overlayStatePath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${overlayStatePath}'))
|
|
1259
1353
|
$attached = @{}
|
|
1260
1354
|
|
|
1355
|
+
$form = New-Object System.Windows.Forms.Form
|
|
1356
|
+
$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
|
|
1357
|
+
$form.ShowInTaskbar = $false
|
|
1358
|
+
$form.TopMost = $true
|
|
1359
|
+
$form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
|
|
1360
|
+
$form.BackColor = [System.Drawing.Color]::Black
|
|
1361
|
+
$form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::Dpi
|
|
1362
|
+
$label = New-Object System.Windows.Forms.Label
|
|
1363
|
+
$label.AutoSize = $true
|
|
1364
|
+
$label.ForeColor = [System.Drawing.Color]::White
|
|
1365
|
+
$label.BackColor = [System.Drawing.Color]::Transparent
|
|
1366
|
+
$label.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
|
1367
|
+
$form.Controls.Add($label)
|
|
1368
|
+
$null = $form.Handle
|
|
1369
|
+
$extendedStyle = [GitDoneGameMemory]::GetWindowLong($form.Handle, -20)
|
|
1370
|
+
# WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE: never steal a click,
|
|
1371
|
+
# never take keyboard focus from the game, and stay out of Alt+Tab.
|
|
1372
|
+
[GitDoneGameMemory]::SetWindowLong($form.Handle, -20, $extendedStyle -bor 0x20 -bor 0x80 -bor 0x08000000) | Out-Null
|
|
1373
|
+
$form.Hide()
|
|
1374
|
+
$lastOverlayWrite = [DateTime]::MinValue
|
|
1375
|
+
$overlayWasVisible = $false
|
|
1376
|
+
|
|
1377
|
+
$screens = @([System.Windows.Forms.Screen]::AllScreens)
|
|
1378
|
+
$displayRows = @()
|
|
1379
|
+
for ($index = 0; $index -lt $screens.Count; $index++) {
|
|
1380
|
+
$screen = $screens[$index]
|
|
1381
|
+
$displayRows += [pscustomobject]@{
|
|
1382
|
+
index = $index
|
|
1383
|
+
name = $screen.DeviceName
|
|
1384
|
+
primary = $screen.Primary
|
|
1385
|
+
width = $screen.Bounds.Width
|
|
1386
|
+
height = $screen.Bounds.Height
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
[pscustomobject]@{ type='displays'; displays=$displayRows } | ConvertTo-Json -Depth 4 -Compress
|
|
1390
|
+
[Console]::Out.Flush()
|
|
1391
|
+
|
|
1392
|
+
function Update-Overlay {
|
|
1393
|
+
try {
|
|
1394
|
+
if (!(Test-Path -LiteralPath $overlayStatePath)) { $form.Hide(); $script:overlayWasVisible = $false; return }
|
|
1395
|
+
$write = (Get-Item -LiteralPath $overlayStatePath).LastWriteTimeUtc
|
|
1396
|
+
if ($write -eq $lastOverlayWrite) { return }
|
|
1397
|
+
$script:lastOverlayWrite = $write
|
|
1398
|
+
$state = Get-Content -LiteralPath $overlayStatePath -Raw | ConvertFrom-Json
|
|
1399
|
+
if (!$state.enabled -or !$state.visible -or [string]::IsNullOrWhiteSpace($state.text)) {
|
|
1400
|
+
$form.Hide()
|
|
1401
|
+
$script:overlayWasVisible = $false
|
|
1402
|
+
return
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
$screenIndex = [Math]::Max(0, [Math]::Min($screens.Count - 1, [int]$state.monitor))
|
|
1406
|
+
$screen = $screens[$screenIndex]
|
|
1407
|
+
$label.Text = [string]$state.text
|
|
1408
|
+
$label.Font = [System.Drawing.Font]::new([string]$state.fontFamily, [single]$state.fontSize, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
|
|
1409
|
+
$paddingX = if ($state.transparent) { 3 } else { 12 }
|
|
1410
|
+
$paddingY = if ($state.transparent) { 2 } else { 7 }
|
|
1411
|
+
$label.Location = [System.Drawing.Point]::new([int]$paddingX, [int]$paddingY)
|
|
1412
|
+
$form.ClientSize = [System.Drawing.Size]::new([int]($label.PreferredWidth + 2 * $paddingX), [int]($label.PreferredHeight + 2 * $paddingY))
|
|
1413
|
+
if ($state.transparent) {
|
|
1414
|
+
$form.BackColor = [System.Drawing.Color]::Magenta
|
|
1415
|
+
$form.TransparencyKey = [System.Drawing.Color]::Magenta
|
|
1416
|
+
} else {
|
|
1417
|
+
$form.TransparencyKey = [System.Drawing.Color]::Empty
|
|
1418
|
+
$form.BackColor = [System.Drawing.Color]::FromArgb(22, 27, 38)
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
$margin = 24
|
|
1422
|
+
$area = $screen.WorkingArea
|
|
1423
|
+
$x = switch ([string]$state.position) {
|
|
1424
|
+
'top-left' { $area.Left + $margin; break }
|
|
1425
|
+
'bottom-left' { $area.Left + $margin; break }
|
|
1426
|
+
'top-center' { $area.Left + [Math]::Floor(($area.Width - $form.Width) / 2); break }
|
|
1427
|
+
'bottom-center' { $area.Left + [Math]::Floor(($area.Width - $form.Width) / 2); break }
|
|
1428
|
+
default { $area.Right - $form.Width - $margin }
|
|
1429
|
+
}
|
|
1430
|
+
$y = if ([string]$state.position -like 'bottom-*') { $area.Bottom - $form.Height - $margin } else { $area.Top + $margin }
|
|
1431
|
+
$form.Location = [System.Drawing.Point]::new([int]$x, [int]$y)
|
|
1432
|
+
if (!$form.Visible) { $form.Show() }
|
|
1433
|
+
# Reassert HWND_TOPMOST after showing. Games can reorder top-level windows
|
|
1434
|
+
# when their DX swap chain becomes active, especially in fullscreen modes.
|
|
1435
|
+
[GitDoneGameMemory]::SetWindowPos($form.Handle, [IntPtr](-1), $form.Left, $form.Top, $form.Width, $form.Height, 0x0010 -bor 0x0040) | Out-Null
|
|
1436
|
+
if (!$script:overlayWasVisible -and $form.Visible) {
|
|
1437
|
+
[pscustomobject]@{ type='overlay'; visible=$true; monitor=$screenIndex; position=[string]$state.position } | ConvertTo-Json -Compress
|
|
1438
|
+
[Console]::Out.Flush()
|
|
1439
|
+
$script:overlayWasVisible = $true
|
|
1440
|
+
}
|
|
1441
|
+
} catch {
|
|
1442
|
+
$form.Hide()
|
|
1443
|
+
$script:overlayWasVisible = $false
|
|
1444
|
+
[pscustomobject]@{ type='overlay'; visible=$false; error=$_.Exception.Message } | ConvertTo-Json -Compress
|
|
1445
|
+
[Console]::Out.Flush()
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1261
1449
|
function Emit-State($profile, $state, $detail) {
|
|
1262
1450
|
[pscustomobject]@{
|
|
1263
1451
|
gameId = $profile.id
|
|
@@ -1269,6 +1457,8 @@ function Emit-State($profile, $state, $detail) {
|
|
|
1269
1457
|
}
|
|
1270
1458
|
|
|
1271
1459
|
while ($true) {
|
|
1460
|
+
[System.Windows.Forms.Application]::DoEvents()
|
|
1461
|
+
Update-Overlay
|
|
1272
1462
|
foreach ($profile in $profiles) {
|
|
1273
1463
|
$process = $null
|
|
1274
1464
|
foreach ($processName in @($profile.processNames)) {
|
|
@@ -1352,6 +1542,7 @@ function queueGameReport(cfg, reading) {
|
|
|
1352
1542
|
if (!GAME_PROFILES.some((profile) => profile.id === reading?.gameId)) return
|
|
1353
1543
|
const state = reading.state === 'detecting' ? 'paused' : reading.state
|
|
1354
1544
|
if (!['playing', 'paused', 'not_running', 'unsupported', 'error'].includes(state)) return
|
|
1545
|
+
observeGameActivity(reading.gameId, state, reading.observedAt)
|
|
1355
1546
|
|
|
1356
1547
|
const now = Date.now()
|
|
1357
1548
|
const previous = gameLastReport.get(reading.gameId)
|
|
@@ -1360,12 +1551,21 @@ function queueGameReport(cfg, reading) {
|
|
|
1360
1551
|
|
|
1361
1552
|
gameReportQueue = gameReportQueue
|
|
1362
1553
|
.then(async () => {
|
|
1363
|
-
await api(cfg, '/api/v1/games/activity', {
|
|
1554
|
+
const result = await api(cfg, '/api/v1/games/activity', {
|
|
1364
1555
|
machineId: cfg.machineId,
|
|
1365
1556
|
gameId: reading.gameId,
|
|
1366
1557
|
state,
|
|
1367
1558
|
observedAt: reading.observedAt || new Date().toISOString(),
|
|
1559
|
+
dayStart: localDayStartIso(),
|
|
1368
1560
|
})
|
|
1561
|
+
applyGameOverlaySettings(result.gameOverlay)
|
|
1562
|
+
if (activeGameOverlay?.gameId === reading.gameId &&
|
|
1563
|
+
activeGameOverlay.playing === (state === 'playing') &&
|
|
1564
|
+
typeof result.todayMs === 'number') {
|
|
1565
|
+
activeGameOverlay.todayMs = Math.max(0, result.todayMs)
|
|
1566
|
+
activeGameOverlay.asOf = Date.parse(result.asOf) || Date.now()
|
|
1567
|
+
}
|
|
1568
|
+
writeGameOverlayState()
|
|
1369
1569
|
if (previous?.state !== state) log(`🎮 ${reading.gameId}: ${state}`)
|
|
1370
1570
|
})
|
|
1371
1571
|
.catch((err) => log(`✗ game activity sync failed: ${err.message}`))
|
|
@@ -1388,6 +1588,16 @@ async function startGameActivityMonitor(cfg, printOnly = false) {
|
|
|
1388
1588
|
lines.on('line', (line) => {
|
|
1389
1589
|
try {
|
|
1390
1590
|
const reading = JSON.parse(line)
|
|
1591
|
+
if (reading.type === 'displays' && Array.isArray(reading.displays)) {
|
|
1592
|
+
gameDisplays = reading.displays
|
|
1593
|
+
if (printOnly) console.log(`Displays: ${JSON.stringify(gameDisplays)}`)
|
|
1594
|
+
return
|
|
1595
|
+
}
|
|
1596
|
+
if (reading.type === 'overlay') {
|
|
1597
|
+
if (printOnly) console.log(reading.error ? `Overlay error: ${reading.error}` : `Overlay: ${reading.visible ? 'visible' : 'hidden'} on monitor ${Number(reading.monitor) + 1}`)
|
|
1598
|
+
else if (reading.error) log(`✗ game overlay error: ${reading.error}`)
|
|
1599
|
+
return
|
|
1600
|
+
}
|
|
1391
1601
|
if (printOnly) console.log(`${reading.observedAt} ${reading.gameId}: ${reading.state} — ${reading.detail}`)
|
|
1392
1602
|
else queueGameReport(cfg, reading)
|
|
1393
1603
|
} catch { /* ignore non-JSON PowerShell host noise */ }
|
|
@@ -3220,16 +3430,19 @@ async function sync(cfg, discovered) {
|
|
|
3220
3430
|
agentVersion: AGENT_VERSION,
|
|
3221
3431
|
roots: cfg.roots,
|
|
3222
3432
|
repos: full ? discovered : [],
|
|
3433
|
+
gameDisplays,
|
|
3223
3434
|
...(usage ? { usage } : {}),
|
|
3224
3435
|
...(codexUsage ? { codexUsage } : {}),
|
|
3225
3436
|
})
|
|
3226
3437
|
if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
|
|
3227
3438
|
else syncFullCountdown--
|
|
3439
|
+
applyGameOverlaySettings(data.gameOverlay)
|
|
3228
3440
|
return {
|
|
3229
3441
|
tracked: data.tracked ?? [],
|
|
3230
3442
|
latestVersion: data.latestVersion,
|
|
3231
3443
|
downloadUrl: data.downloadUrl,
|
|
3232
3444
|
downloadSha256: data.downloadSha256,
|
|
3445
|
+
gameOverlay: data.gameOverlay,
|
|
3233
3446
|
}
|
|
3234
3447
|
}
|
|
3235
3448
|
|
|
@@ -3429,6 +3642,8 @@ async function runLoop(cfg) {
|
|
|
3429
3642
|
// Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
|
|
3430
3643
|
// its own reconnect loop and never rejects.
|
|
3431
3644
|
streamCommands(cfg)
|
|
3645
|
+
writeGameOverlayState()
|
|
3646
|
+
setInterval(writeGameOverlayState, 1000)
|
|
3432
3647
|
startGameActivityMonitor(cfg)
|
|
3433
3648
|
|
|
3434
3649
|
await tick()
|