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.
Files changed (2) hide show
  1. package/index.js +217 -2
  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.12'
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.12",
3
+ "version": "0.8.14",
4
4
  "description": "Local gitDone companion for repository and optional World of Warcraft progress sync",
5
5
  "type": "module",
6
6
  "files": [