gitdone-agent 0.7.5 → 0.7.7

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 +183 -21
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -13,6 +13,7 @@ import { execSync, execFileSync, spawn } from 'node:child_process'
13
13
  import {
14
14
  existsSync, writeFileSync, readFileSync, unlinkSync, renameSync,
15
15
  mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync, statSync,
16
+ openSync, writeSync, fsyncSync, closeSync,
16
17
  } from 'node:fs'
17
18
  import { resolve, join } from 'node:path'
18
19
  import { homedir, hostname, tmpdir } from 'node:os'
@@ -27,10 +28,12 @@ import { randomUUID, createHash } from 'node:crypto'
27
28
  // Reported to the server on every sync so the web UI can flag outdated agents.
28
29
  // Keep in lockstep with packages/agent/package.json "version" AND
29
30
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
30
- const AGENT_VERSION = '0.7.5'
31
+ const AGENT_VERSION = '0.7.7'
31
32
 
32
33
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
34
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
35
+ // Last-known-good copy of config.json, refreshed once per successful start.
36
+ const CONFIG_BAK_PATH = CONFIG_PATH + '.bak'
34
37
  const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
35
38
  const LOG_PATH = join(AGENT_DIR, 'agent.log')
36
39
 
@@ -80,20 +83,98 @@ function survive(kind, err) {
80
83
  process.on('uncaughtException', (err) => survive('uncaught exception', err))
81
84
  process.on('unhandledRejection', (err) => survive('unhandled rejection', err))
82
85
 
83
- function readConfig() {
84
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
86
+ function parseConfigFile(path) {
87
+ try {
88
+ // Strip a UTF-8 BOM: we never write one, but a config repaired by hand in an
89
+ // editor that adds one would otherwise be unparsable — and "I fixed the file
90
+ // and it still says no config" is the worst possible dead end (gd-478).
91
+ const cfg = JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''))
92
+ return cfg && typeof cfg === 'object' && cfg.key ? cfg : null
93
+ } catch { return null }
94
+ }
95
+
96
+ // Rebuild the bare minimum from ai-mcp-config.json — last resort when both
97
+ // config.json and its backup are gone. It is written from cfg.key/cfg.machineId
98
+ // on every AI session, so it carries the two values that cannot be regenerated
99
+ // locally (a new machineId would show up as a SECOND machine on the server, and
100
+ // there is no way to invent the API key). Roots are lost, but an agent that is
101
+ // online with no roots can be fixed from the web UI; an offline one cannot.
102
+ function recoverConfigFromAiMcp() {
103
+ try {
104
+ const mcp = JSON.parse(readFileSync(join(AGENT_DIR, 'ai-mcp-config.json'), 'utf8'))
105
+ const server = mcp?.mcpServers?.gitdone
106
+ const key = server?.headers?.Authorization?.replace(/^Bearer\s+/, '')
107
+ const machineId = server?.headers?.['X-Gitdone-Machine-Id']
108
+ if (!key || !machineId || !server.url) return null
109
+ return {
110
+ key,
111
+ url: server.url.replace(/\/api\/mcp\/?$/, ''),
112
+ interval: 30,
113
+ machineId,
114
+ hostname: hostname(),
115
+ roots: [],
116
+ }
117
+ } catch { return null }
85
118
  }
86
119
 
120
+ // True when the running config had to be rebuilt — surfaced by --doctor.
121
+ let configWasRecovered = null
122
+
123
+ // An unclean shutdown (power loss, BSOD) can leave config.json committed by the
124
+ // filesystem as a NUL-filled file of the right length: intact on disk, garbage
125
+ // to JSON.parse. Before gd-478 that bricked the agent for good — every autostart
126
+ // exited with "no config found" and the supervisor just respawned it every 10s,
127
+ // so the machine stayed offline until somebody hand-edited the file. A user
128
+ // without filesystem access had no way out at all. So never trust the primary
129
+ // copy blindly: fall back to the last-known-good backup, then to the AI MCP
130
+ // config, and put whichever survived back in place.
131
+ function readConfig() {
132
+ const primary = parseConfigFile(CONFIG_PATH)
133
+ if (primary) return primary
134
+ // Nothing to recover from on a fresh machine (install path) — stay quiet.
135
+ if (!existsSync(CONFIG_PATH) && !existsSync(CONFIG_BAK_PATH)) return null
136
+
137
+ for (const [source, cfg] of [
138
+ ['config.json.bak', parseConfigFile(CONFIG_BAK_PATH)],
139
+ ['ai-mcp-config.json', recoverConfigFromAiMcp()],
140
+ ]) {
141
+ if (!cfg) continue
142
+ configWasRecovered = source
143
+ log(`✗ config.json е повреден (най-вероятно от неочаквано изключване) — възстановен от ${source}`)
144
+ try { writeConfig(cfg) } catch (err) { log(`✗ записът на възстановения config не успя: ${err.message}`) }
145
+ return cfg
146
+ }
147
+ log('✗ config.json е повреден и няма от какво да се възстанови — пусни gitdone-agent --key=... --root=... --install')
148
+ return null
149
+ }
150
+
151
+ // Atomic AND durable: write to a temp file, force it to the platter, and only
152
+ // then rename over the real one. The fsync is the part that matters — without
153
+ // it the rename can be committed while the data is still in the page cache, and
154
+ // a power cut in that window is exactly what produced the NUL-filled config in
155
+ // gd-478. Losing the rename is harmless (the old config survives); losing the
156
+ // data is not.
87
157
  function writeConfig(cfg) {
88
158
  ensureAgentDir()
89
- // Write-then-rename so a crash / reboot mid-write can never leave a truncated
90
- // config.json — a corrupt config makes every subsequent autostart exit
91
- // immediately, which reads as "агентът умря и рестартът не помага" (gd-403).
92
159
  const tmp = CONFIG_PATH + '.tmp'
93
- writeFileSync(tmp, JSON.stringify(cfg, null, 2), 'utf8')
160
+ const fd = openSync(tmp, 'w')
161
+ try {
162
+ writeSync(fd, JSON.stringify(cfg, null, 2), null, 'utf8')
163
+ fsyncSync(fd)
164
+ } finally {
165
+ closeSync(fd)
166
+ }
94
167
  renameSync(tmp, CONFIG_PATH)
95
168
  }
96
169
 
170
+ // Refresh the last-known-good copy. Called once per successful start, so the
171
+ // backup is always a config that actually booted the agent — never a half-baked
172
+ // one. Best-effort: a failed backup must not stop the agent from running.
173
+ function backupConfig(cfg) {
174
+ try { writeFileSync(CONFIG_BAK_PATH, JSON.stringify(cfg, null, 2), 'utf8') }
175
+ catch (err) { log(`✗ backup на config.json не успя: ${err.message}`) }
176
+ }
177
+
97
178
  // ─── Arg parsing ─────────────────────────────────────────────────────────────
98
179
 
99
180
  function parseArgs() {
@@ -458,7 +539,12 @@ function runDoctor() {
458
539
  let hb = '(none yet)'
459
540
  try { hb = `(последен: преди ${Math.round((Date.now() - statSync(HEARTBEAT_PATH).mtimeMs) / 1000)}s)` } catch { /* no beat yet */ }
460
541
  console.log(` heartbeat : ${HEARTBEAT_PATH} ${hb}`)
461
- console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
542
+ // cfg comes from readConfig() above, so a corrupt config.json has already been
543
+ // repaired by the time we print this — say so rather than a bare "(ok)".
544
+ const cfgState = configWasRecovered ? `(ВЪЗСТАНОВЕН от ${configWasRecovered} — беше повреден)`
545
+ : cfg ? '(ok)' : existsSync(CONFIG_PATH) ? '(ПОВРЕДЕН)' : '(MISSING)'
546
+ console.log(` config : ${CONFIG_PATH} ${cfgState}`)
547
+ console.log(` config backup : ${CONFIG_BAK_PATH} ${parseConfigFile(CONFIG_BAK_PATH) ? '(ok)' : '(няма — прави се при първия успешен старт)'}`)
462
548
  console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
463
549
  console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
464
550
  const claude = findClaude()
@@ -909,7 +995,7 @@ async function reportCommandResult(cfg, id, status, result) {
909
995
  // how many tokens the run cost. `streamingText` / `activityText` (gd-419) carry
910
996
  // the live "being typed" preview and the latest thinking snippet — explicit ''
911
997
  // clears them; `undefined` leaves them untouched.
912
- async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText) {
998
+ async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText, extra) {
913
999
  await api(cfg, '/api/v1/agent/ai-run/events', {
914
1000
  runId,
915
1001
  events,
@@ -918,9 +1004,32 @@ async function postRunEvents(cfg, runId, events, status, result, usage, streamin
918
1004
  ...(usage ? { usage } : {}),
919
1005
  ...(streamingText !== undefined ? { streamingText } : {}),
920
1006
  ...(activityText !== undefined ? { activityText } : {}),
1007
+ // gd-466: claude's own session id (for --resume) + precise stop cause.
1008
+ ...(extra?.claudeSessionId ? { claudeSessionId: extra.claudeSessionId } : {}),
1009
+ ...(extra?.stopReason ? { stopReason: extra.stopReason } : {}),
1010
+ ...(typeof extra?.resetAt === 'number' ? { resetAt: extra.resetAt } : {}),
921
1011
  }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
922
1012
  }
923
1013
 
1014
+ // gd-466: parse a hard usage-limit stop out of claude's headless output. On the
1015
+ // limit, the CLI surfaces "Claude AI usage limit reached|<epoch_seconds>" (in
1016
+ // the result text and/or stderr); the epoch is the window reset. Returns the
1017
+ // reset in ms when found, else { limited } with a null resetAt (telemetry is
1018
+ // then the backstop), else not limited.
1019
+ function detectUsageLimit(text) {
1020
+ const hay = String(text || '')
1021
+ const withEpoch = hay.match(/usage limit reached\s*\|\s*(\d{9,13})/i)
1022
+ if (withEpoch) {
1023
+ let n = Number(withEpoch[1])
1024
+ if (n < 1e12) n *= 1000 // seconds → ms
1025
+ return { limited: true, resetAt: n }
1026
+ }
1027
+ if (/usage limit reached|reached your usage limit|rate limit(?:ed)?|exceeded your.*\blimit\b/i.test(hay)) {
1028
+ return { limited: true, resetAt: null }
1029
+ }
1030
+ return { limited: false, resetAt: null }
1031
+ }
1032
+
924
1033
  // Post transcript lines (and optional turn status / Claude session id) for an
925
1034
  // interactive AiSession chat turn.
926
1035
  async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText, activityText) {
@@ -991,6 +1100,11 @@ function parseStreamLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
991
1100
  }
992
1101
  if (ev.type === 'result') {
993
1102
  if (ev.subtype && ev.subtype !== 'success') push('SYSTEM', `Резултат: ${ev.subtype}`)
1103
+ // gd-466: surface the final result text + subtype so the caller can detect a
1104
+ // hard usage-limit stop (the "usage limit reached|<epoch>" lands here).
1105
+ if (typeof onMeta === 'function' && (ev.subtype || typeof ev.result === 'string')) {
1106
+ onMeta({ subtype: ev.subtype, resultText: typeof ev.result === 'string' ? ev.result : undefined })
1107
+ }
994
1108
  // claude's final result carries cumulative token usage + its own cost.
995
1109
  // Prefer `modelUsage` (summed over every model/subagent turn) which is the
996
1110
  // true cumulative; top-level `usage` is often just the last turn. Fall back
@@ -1176,6 +1290,8 @@ function runAiCommand(cfg, cmd, repoPath) {
1176
1290
  const prompt = cmd.payload?.prompt ?? ''
1177
1291
  const allowCommit = cmd.payload?.allowCommit === true
1178
1292
  const model = aiModelArg(cmd.payload?.model)
1293
+ // gd-466: a token-reset resume asks us to continue claude's own conversation.
1294
+ const resumeId = cmd.payload?.resume || null
1179
1295
  if (!runId || !prompt) {
1180
1296
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
1181
1297
  return
@@ -1208,6 +1324,10 @@ function runAiCommand(cfg, cmd, repoPath) {
1208
1324
  // Stream text token-by-token + thinking deltas so the terminal shows the
1209
1325
  // reply being written live, ред по ред, not in whole-block batches (gd-419).
1210
1326
  '--include-partial-messages',
1327
+ // gd-466: resume claude's own prior conversation for this task when we're
1328
+ // continuing after a token reset — it keeps its context/todo. A stale/missing
1329
+ // session errors out fast; the watchdog then re-dispatches fresh (no resume).
1330
+ ...(resumeId ? ['--resume', resumeId] : []),
1211
1331
  // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1212
1332
  ...(model ? ['--model', model] : []),
1213
1333
  '--permission-mode', 'acceptEdits',
@@ -1240,7 +1360,11 @@ function runAiCommand(cfg, cmd, repoPath) {
1240
1360
  const activityText = activityChanged ? liveActivity : undefined
1241
1361
  sentLive = liveText
1242
1362
  sentActivity = liveActivity
1243
- await postRunEvents(cfg, runId, batch, undefined, undefined, undefined, streamingText, activityText)
1363
+ // gd-466: persist claude's session id as soon as we have it (once), so a
1364
+ // resume survives even if the agent dies before the clean close handler.
1365
+ const extra = (!sentSession && capturedSession) ? { claudeSessionId: capturedSession } : undefined
1366
+ if (extra) sentSession = true
1367
+ await postRunEvents(cfg, runId, batch, undefined, undefined, undefined, streamingText, activityText, extra)
1244
1368
  flushing = false
1245
1369
  }
1246
1370
  const push = (kind, text) => {
@@ -1260,14 +1384,25 @@ function runAiCommand(cfg, cmd, repoPath) {
1260
1384
  const timer = setInterval(flush, 500)
1261
1385
 
1262
1386
  // 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 }
1387
+ // from the final result event) to report once on exit (gd-334). gd-466 also
1388
+ // captures the final result text/subtype (for usage-limit detection).
1389
+ const meta = { model: undefined, usage: undefined, costUsd: undefined, resultText: undefined, subtype: undefined }
1265
1390
  const onMeta = (m) => {
1266
1391
  if (m.model) meta.model = m.model
1267
1392
  if (m.usage) meta.usage = m.usage
1268
1393
  if (typeof m.costUsd === 'number') meta.costUsd = m.costUsd
1394
+ if (m.resultText) meta.resultText = m.resultText
1395
+ if (m.subtype) meta.subtype = m.subtype
1269
1396
  }
1270
1397
 
1398
+ // gd-466: claude's own session id (from its init event) + any stderr — both
1399
+ // feed a token-reset resume: the id lets us --resume, the stderr helps detect
1400
+ // a usage-limit stop.
1401
+ let capturedSession = null
1402
+ let sentSession = false
1403
+ const onInit = (sid) => { if (sid) capturedSession = sid }
1404
+ let stderrBuf = ''
1405
+
1271
1406
  log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
1272
1407
  postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
1273
1408
 
@@ -1296,14 +1431,14 @@ function runAiCommand(cfg, cmd, repoPath) {
1296
1431
  while ((nl = buf.indexOf('\n')) >= 0) {
1297
1432
  const line = buf.slice(0, nl).trim()
1298
1433
  buf = buf.slice(nl + 1)
1299
- if (line) parseStreamLine(line, push, undefined, onDelta, onMeta)
1434
+ if (line) parseStreamLine(line, push, onInit, onDelta, onMeta)
1300
1435
  }
1301
1436
  })
1302
- child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
1437
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) { stderrBuf = (stderrBuf + '\n' + s).slice(-8000); push('SYSTEM', s) } })
1303
1438
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1304
1439
  child.on('close', async (code) => {
1305
1440
  clearInterval(timer)
1306
- if (buf.trim()) parseStreamLine(buf.trim(), push, undefined, onDelta, onMeta)
1441
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta, onMeta)
1307
1442
  liveText = '' // run is over — drop any lingering live preview / thought
1308
1443
  liveActivity = ''
1309
1444
  await flush()
@@ -1312,17 +1447,38 @@ function runAiCommand(cfg, cmd, repoPath) {
1312
1447
  const usage = meta.usage
1313
1448
  ? { model: meta.model, ...meta.usage, ...(typeof meta.costUsd === 'number' ? { costUsd: meta.costUsd } : {}) }
1314
1449
  : undefined
1315
- const events = [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }]
1450
+
1451
+ // gd-466: did we stop on the token limit? Check the final result text, its
1452
+ // subtype, and stderr. A hard hit gives us the precise reset epoch; report
1453
+ // it so the server parks the task for an exact auto-resume.
1454
+ const limit = detectUsageLimit(`${meta.resultText || ''}\n${meta.subtype || ''}\n${stderrBuf}`)
1455
+ const extra = {
1456
+ ...(capturedSession ? { claudeSessionId: capturedSession } : {}),
1457
+ ...(limit.limited ? { stopReason: 'usage_limit' } : {}),
1458
+ ...(limit.limited && typeof limit.resetAt === 'number' ? { resetAt: limit.resetAt } : {}),
1459
+ }
1460
+
1461
+ const events = [{
1462
+ kind: 'SYSTEM',
1463
+ text: limit.limited
1464
+ ? '⏳ Достигнат лимит на токени — спирам. gitDone ще ме продължи автоматично след ресета.'
1465
+ : (ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.`),
1466
+ }]
1316
1467
  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
1468
  await postRunEvents(
1318
1469
  cfg, runId,
1319
1470
  events,
1320
- ok ? 'done' : 'error',
1321
- ok ? 'ok' : `exit ${code}`,
1471
+ // A limit stop is not a clean finish — report it as 'error' so the run
1472
+ // ends and the server's park/resume path (not the DONE path) takes over.
1473
+ ok && !limit.limited ? 'done' : 'error',
1474
+ limit.limited ? 'usage_limit' : (ok ? 'ok' : `exit ${code}`),
1322
1475
  usage,
1476
+ undefined,
1477
+ undefined,
1478
+ extra,
1323
1479
  )
1324
- reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
1325
- log(`■ ai_run ${runId} приключи (code ${code})`)
1480
+ reportCommandResult(cfg, cmd.id, ok && !limit.limited ? 'done' : 'error', limit.limited ? 'usage_limit' : `exit ${code}`)
1481
+ log(`■ ai_run ${runId} приключи (code ${code}${limit.limited ? ', usage_limit' : ''})`)
1326
1482
  })
1327
1483
  }
1328
1484
 
@@ -2024,6 +2180,9 @@ async function runLoop(cfg) {
2024
2180
  // start so in-place-updated agents get the watchdog without a re-install.
2025
2181
  startHeartbeat()
2026
2182
  ensureWatchdog()
2183
+ // This config just booted an agent, so it is known-good — snapshot it for
2184
+ // readConfig() to fall back on (gd-478).
2185
+ backupConfig(cfg)
2027
2186
  log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
2028
2187
  log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
2029
2188
 
@@ -2099,7 +2258,10 @@ async function main() {
2099
2258
  // No args → running mode (this is how autostart launches us): read config.
2100
2259
  const cfg = readConfig()
2101
2260
  if (!cfg || !cfg.key) {
2102
- console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
2261
+ // Through agent.log, not just stderr the autostart runs hidden, so the
2262
+ // crash log is the only other place this would land and nobody reads it
2263
+ // until someone already suspects the agent (gd-478).
2264
+ log(`✗ няма използваем config в ${CONFIG_PATH} — пусни gitdone-agent --key=... --root=... --install`)
2103
2265
  process.exit(1)
2104
2266
  }
2105
2267
  await runLoop(cfg)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
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": {