gitdone-agent 0.7.4 → 0.7.6

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 +143 -21
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from 'node:fs'
17
17
  import { resolve, join } from 'node:path'
18
18
  import { homedir, hostname, tmpdir } from 'node:os'
19
- import { randomUUID } from 'node:crypto'
19
+ import { randomUUID, createHash } from 'node:crypto'
20
20
 
21
21
  // ─── Stable agent dir, config + logging ────────────────────────────────────────
22
22
  // Everything persistent lives here: a stable copy of the agent script (so
@@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto'
27
27
  // Reported to the server on every sync so the web UI can flag outdated agents.
28
28
  // Keep in lockstep with packages/agent/package.json "version" AND
29
29
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
30
- const AGENT_VERSION = '0.7.4'
30
+ const AGENT_VERSION = '0.7.6'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -909,7 +909,7 @@ async function reportCommandResult(cfg, id, status, result) {
909
909
  // how many tokens the run cost. `streamingText` / `activityText` (gd-419) carry
910
910
  // the live "being typed" preview and the latest thinking snippet — explicit ''
911
911
  // clears them; `undefined` leaves them untouched.
912
- async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText) {
912
+ async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText, extra) {
913
913
  await api(cfg, '/api/v1/agent/ai-run/events', {
914
914
  runId,
915
915
  events,
@@ -918,9 +918,32 @@ async function postRunEvents(cfg, runId, events, status, result, usage, streamin
918
918
  ...(usage ? { usage } : {}),
919
919
  ...(streamingText !== undefined ? { streamingText } : {}),
920
920
  ...(activityText !== undefined ? { activityText } : {}),
921
+ // gd-466: claude's own session id (for --resume) + precise stop cause.
922
+ ...(extra?.claudeSessionId ? { claudeSessionId: extra.claudeSessionId } : {}),
923
+ ...(extra?.stopReason ? { stopReason: extra.stopReason } : {}),
924
+ ...(typeof extra?.resetAt === 'number' ? { resetAt: extra.resetAt } : {}),
921
925
  }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
922
926
  }
923
927
 
928
+ // gd-466: parse a hard usage-limit stop out of claude's headless output. On the
929
+ // limit, the CLI surfaces "Claude AI usage limit reached|<epoch_seconds>" (in
930
+ // the result text and/or stderr); the epoch is the window reset. Returns the
931
+ // reset in ms when found, else { limited } with a null resetAt (telemetry is
932
+ // then the backstop), else not limited.
933
+ function detectUsageLimit(text) {
934
+ const hay = String(text || '')
935
+ const withEpoch = hay.match(/usage limit reached\s*\|\s*(\d{9,13})/i)
936
+ if (withEpoch) {
937
+ let n = Number(withEpoch[1])
938
+ if (n < 1e12) n *= 1000 // seconds → ms
939
+ return { limited: true, resetAt: n }
940
+ }
941
+ if (/usage limit reached|reached your usage limit|rate limit(?:ed)?|exceeded your.*\blimit\b/i.test(hay)) {
942
+ return { limited: true, resetAt: null }
943
+ }
944
+ return { limited: false, resetAt: null }
945
+ }
946
+
924
947
  // Post transcript lines (and optional turn status / Claude session id) for an
925
948
  // interactive AiSession chat turn.
926
949
  async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText, activityText) {
@@ -991,6 +1014,11 @@ function parseStreamLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
991
1014
  }
992
1015
  if (ev.type === 'result') {
993
1016
  if (ev.subtype && ev.subtype !== 'success') push('SYSTEM', `Резултат: ${ev.subtype}`)
1017
+ // gd-466: surface the final result text + subtype so the caller can detect a
1018
+ // hard usage-limit stop (the "usage limit reached|<epoch>" lands here).
1019
+ if (typeof onMeta === 'function' && (ev.subtype || typeof ev.result === 'string')) {
1020
+ onMeta({ subtype: ev.subtype, resultText: typeof ev.result === 'string' ? ev.result : undefined })
1021
+ }
994
1022
  // claude's final result carries cumulative token usage + its own cost.
995
1023
  // Prefer `modelUsage` (summed over every model/subagent turn) which is the
996
1024
  // true cumulative; top-level `usage` is often just the last turn. Fall back
@@ -1176,6 +1204,8 @@ function runAiCommand(cfg, cmd, repoPath) {
1176
1204
  const prompt = cmd.payload?.prompt ?? ''
1177
1205
  const allowCommit = cmd.payload?.allowCommit === true
1178
1206
  const model = aiModelArg(cmd.payload?.model)
1207
+ // gd-466: a token-reset resume asks us to continue claude's own conversation.
1208
+ const resumeId = cmd.payload?.resume || null
1179
1209
  if (!runId || !prompt) {
1180
1210
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
1181
1211
  return
@@ -1208,6 +1238,10 @@ function runAiCommand(cfg, cmd, repoPath) {
1208
1238
  // Stream text token-by-token + thinking deltas so the terminal shows the
1209
1239
  // reply being written live, ред по ред, not in whole-block batches (gd-419).
1210
1240
  '--include-partial-messages',
1241
+ // gd-466: resume claude's own prior conversation for this task when we're
1242
+ // continuing after a token reset — it keeps its context/todo. A stale/missing
1243
+ // session errors out fast; the watchdog then re-dispatches fresh (no resume).
1244
+ ...(resumeId ? ['--resume', resumeId] : []),
1211
1245
  // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1212
1246
  ...(model ? ['--model', model] : []),
1213
1247
  '--permission-mode', 'acceptEdits',
@@ -1240,7 +1274,11 @@ function runAiCommand(cfg, cmd, repoPath) {
1240
1274
  const activityText = activityChanged ? liveActivity : undefined
1241
1275
  sentLive = liveText
1242
1276
  sentActivity = liveActivity
1243
- await postRunEvents(cfg, runId, batch, undefined, undefined, undefined, streamingText, activityText)
1277
+ // gd-466: persist claude's session id as soon as we have it (once), so a
1278
+ // resume survives even if the agent dies before the clean close handler.
1279
+ const extra = (!sentSession && capturedSession) ? { claudeSessionId: capturedSession } : undefined
1280
+ if (extra) sentSession = true
1281
+ await postRunEvents(cfg, runId, batch, undefined, undefined, undefined, streamingText, activityText, extra)
1244
1282
  flushing = false
1245
1283
  }
1246
1284
  const push = (kind, text) => {
@@ -1260,14 +1298,25 @@ function runAiCommand(cfg, cmd, repoPath) {
1260
1298
  const timer = setInterval(flush, 500)
1261
1299
 
1262
1300
  // Accumulate model + token usage across the stream (model from init, usage
1263
- // from the final result event) to report once on exit (gd-334).
1264
- const meta = { model: undefined, usage: undefined, costUsd: undefined }
1301
+ // from the final result event) to report once on exit (gd-334). gd-466 also
1302
+ // captures the final result text/subtype (for usage-limit detection).
1303
+ const meta = { model: undefined, usage: undefined, costUsd: undefined, resultText: undefined, subtype: undefined }
1265
1304
  const onMeta = (m) => {
1266
1305
  if (m.model) meta.model = m.model
1267
1306
  if (m.usage) meta.usage = m.usage
1268
1307
  if (typeof m.costUsd === 'number') meta.costUsd = m.costUsd
1308
+ if (m.resultText) meta.resultText = m.resultText
1309
+ if (m.subtype) meta.subtype = m.subtype
1269
1310
  }
1270
1311
 
1312
+ // gd-466: claude's own session id (from its init event) + any stderr — both
1313
+ // feed a token-reset resume: the id lets us --resume, the stderr helps detect
1314
+ // a usage-limit stop.
1315
+ let capturedSession = null
1316
+ let sentSession = false
1317
+ const onInit = (sid) => { if (sid) capturedSession = sid }
1318
+ let stderrBuf = ''
1319
+
1271
1320
  log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
1272
1321
  postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
1273
1322
 
@@ -1296,14 +1345,14 @@ function runAiCommand(cfg, cmd, repoPath) {
1296
1345
  while ((nl = buf.indexOf('\n')) >= 0) {
1297
1346
  const line = buf.slice(0, nl).trim()
1298
1347
  buf = buf.slice(nl + 1)
1299
- if (line) parseStreamLine(line, push, undefined, onDelta, onMeta)
1348
+ if (line) parseStreamLine(line, push, onInit, onDelta, onMeta)
1300
1349
  }
1301
1350
  })
1302
- child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
1351
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) { stderrBuf = (stderrBuf + '\n' + s).slice(-8000); push('SYSTEM', s) } })
1303
1352
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1304
1353
  child.on('close', async (code) => {
1305
1354
  clearInterval(timer)
1306
- if (buf.trim()) parseStreamLine(buf.trim(), push, undefined, onDelta, onMeta)
1355
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta, onMeta)
1307
1356
  liveText = '' // run is over — drop any lingering live preview / thought
1308
1357
  liveActivity = ''
1309
1358
  await flush()
@@ -1312,17 +1361,38 @@ function runAiCommand(cfg, cmd, repoPath) {
1312
1361
  const usage = meta.usage
1313
1362
  ? { model: meta.model, ...meta.usage, ...(typeof meta.costUsd === 'number' ? { costUsd: meta.costUsd } : {}) }
1314
1363
  : undefined
1315
- const events = [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }]
1364
+
1365
+ // gd-466: did we stop on the token limit? Check the final result text, its
1366
+ // subtype, and stderr. A hard hit gives us the precise reset epoch; report
1367
+ // it so the server parks the task for an exact auto-resume.
1368
+ const limit = detectUsageLimit(`${meta.resultText || ''}\n${meta.subtype || ''}\n${stderrBuf}`)
1369
+ const extra = {
1370
+ ...(capturedSession ? { claudeSessionId: capturedSession } : {}),
1371
+ ...(limit.limited ? { stopReason: 'usage_limit' } : {}),
1372
+ ...(limit.limited && typeof limit.resetAt === 'number' ? { resetAt: limit.resetAt } : {}),
1373
+ }
1374
+
1375
+ const events = [{
1376
+ kind: 'SYSTEM',
1377
+ text: limit.limited
1378
+ ? '⏳ Достигнат лимит на токени — спирам. gitDone ще ме продължи автоматично след ресета.'
1379
+ : (ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.`),
1380
+ }]
1316
1381
  if (usage) events.push({ kind: 'SYSTEM', text: `📊 Токени: ${fmtTokens(usage.inputTokens)} вход · ${fmtTokens(usage.outputTokens)} изход${usage.cacheReadTokens ? ` · ${fmtTokens(usage.cacheReadTokens)} кеш` : ''}${typeof usage.costUsd === 'number' ? ` · $${usage.costUsd.toFixed(4)}` : ''}` })
1317
1382
  await postRunEvents(
1318
1383
  cfg, runId,
1319
1384
  events,
1320
- ok ? 'done' : 'error',
1321
- ok ? 'ok' : `exit ${code}`,
1385
+ // A limit stop is not a clean finish — report it as 'error' so the run
1386
+ // ends and the server's park/resume path (not the DONE path) takes over.
1387
+ ok && !limit.limited ? 'done' : 'error',
1388
+ limit.limited ? 'usage_limit' : (ok ? 'ok' : `exit ${code}`),
1322
1389
  usage,
1390
+ undefined,
1391
+ undefined,
1392
+ extra,
1323
1393
  )
1324
- reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
1325
- log(`■ ai_run ${runId} приключи (code ${code})`)
1394
+ reportCommandResult(cfg, cmd.id, ok && !limit.limited ? 'done' : 'error', limit.limited ? 'usage_limit' : `exit ${code}`)
1395
+ log(`■ ai_run ${runId} приключи (code ${code}${limit.limited ? ', usage_limit' : ''})`)
1326
1396
  })
1327
1397
  }
1328
1398
 
@@ -1834,22 +1904,69 @@ async function readClaudeUsage() {
1834
1904
  }
1835
1905
  }
1836
1906
 
1907
+ // The discovered repo list barely ever changes, but re-upserting every repo's
1908
+ // row on the server each tick is pure idle write load at scale. So we ship the
1909
+ // full `repos` list only when the discovered set actually changed, or every
1910
+ // SYNC_FULL_TICKS as a reconciliation (so a repo the user "forgot" server-side
1911
+ // reappears within ~10 min, and a fresh row is recreated if one went missing).
1912
+ // In between we send a liveness-only sync (no repos) — the server still returns
1913
+ // the tracked list from the DB, so nothing downstream notices (gd-459).
1914
+ const SYNC_FULL_TICKS = 20
1915
+ let syncRepoSig = null
1916
+ let syncFullCountdown = 0
1917
+
1837
1918
  async function sync(cfg, discovered) {
1838
1919
  const usage = await readClaudeUsage()
1920
+ const sig = createHash('sha1').update(JSON.stringify(discovered)).digest('hex')
1921
+ const full = sig !== syncRepoSig || syncFullCountdown <= 0
1839
1922
  const data = await api(cfg, '/api/v1/agent/sync', {
1840
1923
  machineId: cfg.machineId,
1841
1924
  hostname: cfg.hostname,
1842
1925
  agentVersion: AGENT_VERSION,
1843
1926
  roots: cfg.roots,
1844
- repos: discovered,
1927
+ repos: full ? discovered : [],
1845
1928
  ...(usage ? { usage } : {}),
1846
1929
  })
1930
+ if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
1931
+ else syncFullCountdown--
1847
1932
  return data.tracked ?? []
1848
1933
  }
1849
1934
 
1850
- // Push one tracked repo's snapshot and run any pending commands for it.
1851
- async function pushSnapshot(cfg, repo) {
1935
+ // Per-repo cache of the last snapshot we actually SENT, so an idle repo whose
1936
+ // git state hasn't moved doesn't re-POST an identical snapshot every 30s tick
1937
+ // (gd-457). At scale that unchanged-snapshot flood is the dominant idle load on
1938
+ // the server; skipping it drops idle traffic by ~an order of magnitude. We still
1939
+ // resend at least every SNAPSHOT_HEARTBEAT_TICKS to refresh the repo's
1940
+ // lastSeenAt (used only for ordering — no tight online threshold reads it) and
1941
+ // as a last-ditch command-drain backstop. Kept long: the SSE stream is the fast
1942
+ // path and it reconnects every ~5 min (re-waking a drain), so the snapshot only
1943
+ // has to backstop the pathological "SSE totally dead" case (gd-459).
1944
+ const SNAPSHOT_HEARTBEAT_TICKS = 40
1945
+ const snapshotSigCache = new Map() // path → { sig, skipped }
1946
+
1947
+ // Cheap content signature of everything the server persists for a repo. Includes
1948
+ // diffs, so an edit to a working-tree file (which leaves modified/staged the
1949
+ // same but changes content) still counts as a change and is re-sent.
1950
+ function snapshotSignature(snapshot) {
1951
+ return createHash('sha1').update(JSON.stringify(snapshot)).digest('hex')
1952
+ }
1953
+
1954
+ // Push one tracked repo's snapshot and run any pending commands for it. `force`
1955
+ // bypasses the unchanged-skip — used right after a command mutates git state, so
1956
+ // the UI reflects it instantly regardless of the signature cache.
1957
+ async function pushSnapshot(cfg, repo, force = false) {
1852
1958
  const snapshot = getSnapshot(repo.path)
1959
+
1960
+ // Skip the network POST (and all the server-side DB work it triggers) when the
1961
+ // repo is byte-for-byte unchanged since we last sent it — unless we're due a
1962
+ // heartbeat resend or the caller forced it (gd-457).
1963
+ const sig = snapshotSignature(snapshot)
1964
+ const cached = snapshotSigCache.get(repo.path)
1965
+ if (!force && cached && cached.sig === sig && cached.skipped < SNAPSHOT_HEARTBEAT_TICKS) {
1966
+ cached.skipped++
1967
+ return false // unchanged — nothing sent
1968
+ }
1969
+
1853
1970
  const data = await api(cfg, '/api/v1/agent/snapshot', {
1854
1971
  machineId: cfg.machineId,
1855
1972
  path: repo.path,
@@ -1859,6 +1976,10 @@ async function pushSnapshot(cfg, repo) {
1859
1976
  hasGithubAuth: !!cfg.auth?.[repo.path],
1860
1977
  ...snapshot,
1861
1978
  })
1979
+ // Sent successfully — remember this signature so identical follow-up ticks are
1980
+ // skipped until the next real change or heartbeat (gd-457). A thrown POST never
1981
+ // reaches here, so a failure just retries next tick.
1982
+ snapshotSigCache.set(repo.path, { sig, skipped: 0 })
1862
1983
  // Server issued a (fresh) push token — cache it on disk so we ask only once.
1863
1984
  if (data.githubAuth?.token && data.githubAuth?.repo) {
1864
1985
  cfg.auth = cfg.auth ?? {}
@@ -1868,7 +1989,7 @@ async function pushSnapshot(cfg, repo) {
1868
1989
  for (const cmd of data.commands ?? []) {
1869
1990
  await executeCommand(cfg, cmd, repo.path)
1870
1991
  }
1871
- return snapshot
1992
+ return true // sent
1872
1993
  }
1873
1994
 
1874
1995
  // ─── Low-latency command channel (gd-274) ───────────────────────────────────────
@@ -1921,7 +2042,7 @@ async function drainCommands(cfg) {
1921
2042
  if (GIT_STATE_CMDS.has(cmd.type)) touched.set(repoPath, cmd.repoName || repoPath)
1922
2043
  }
1923
2044
  for (const [path, name] of touched) {
1924
- try { await pushSnapshot(cfg, { path, name }) }
2045
+ try { await pushSnapshot(cfg, { path, name }, true) }
1925
2046
  catch (err) { log(`✗ post-command snapshot failed @ ${path}: ${err.message}`) }
1926
2047
  }
1927
2048
  } while (drainAgain)
@@ -1981,11 +2102,12 @@ async function runLoop(cfg) {
1981
2102
  const discovered = scanRepos(cfg.roots)
1982
2103
  const tracked = await sync(cfg, discovered)
1983
2104
  let pushed = 0
2105
+ let skipped = 0
1984
2106
  for (const repo of tracked) {
1985
- try { await pushSnapshot(cfg, repo); pushed++ }
2107
+ try { (await pushSnapshot(cfg, repo)) ? pushed++ : skipped++ }
1986
2108
  catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
1987
2109
  }
1988
- log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
2110
+ log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}, unchanged: ${skipped}`)
1989
2111
  } catch (err) {
1990
2112
  log(`✗ sync error: ${err.message}`)
1991
2113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
5
  "type": "module",
6
6
  "bin": {