gitdone-agent 0.8.5 → 0.8.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 +175 -34
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync, statSync,
17
17
  openSync, writeSync, fsyncSync, closeSync, readSync,
18
18
  } from 'node:fs'
19
- import { resolve, join } from 'node:path'
19
+ import { resolve, join, extname } from 'node:path'
20
20
  import { homedir, hostname, tmpdir } from 'node:os'
21
21
  import { randomUUID, createHash } from 'node:crypto'
22
22
 
@@ -27,9 +27,9 @@ import { randomUUID, createHash } from 'node:crypto'
27
27
  // when the agent runs in a hidden window).
28
28
 
29
29
  // Reported to the server on every sync so the web UI can flag outdated agents.
30
- // Keep in lockstep with packages/agent/package.json "version" AND
31
- // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
32
- const AGENT_VERSION = '0.8.5'
30
+ // Keep in lockstep with packages/agent/package.json. The server's offline
31
+ // fallback is bumped only after this release has actually reached npm.
32
+ const AGENT_VERSION = '0.8.7'
33
33
 
34
34
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
35
35
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -547,6 +547,77 @@ function uninstallStartup() {
547
547
 
548
548
  // ─── Doctor ────────────────────────────────────────────────────────────────────
549
549
 
550
+ // Each sync heartbeat returns the version, URL and SHA-256 of the exact agent
551
+ // script deployed on the server. A supervised stable install downloads it,
552
+ // verifies both checksum and embedded version, atomically replaces itself, and
553
+ // exits; run-agent.cmd then starts the new version within about ten seconds.
554
+ function compareVersions(a, b) {
555
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0)
556
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0)
557
+ const len = Math.max(pa.length, pb.length)
558
+ for (let i = 0; i < len; i++) {
559
+ const difference = (pa[i] ?? 0) - (pb[i] ?? 0)
560
+ if (difference !== 0) return difference > 0 ? 1 : -1
561
+ }
562
+ return 0
563
+ }
564
+
565
+ const UPDATE_RETRY_MS = 60 * 60 * 1000
566
+ let updateInFlight = false
567
+ let lastUpdateAttempt = { version: null, at: 0 }
568
+
569
+ async function selfUpdate(cfg, latestVersion, downloadUrl, expectedSha256) {
570
+ if (updateInFlight || !latestVersion || !downloadUrl) return
571
+ if (compareVersions(latestVersion, AGENT_VERSION) <= 0) return
572
+ if (lastUpdateAttempt.version === latestVersion && Date.now() - lastUpdateAttempt.at < UPDATE_RETRY_MS) return
573
+
574
+ // A foreground/dev invocation has no supervisor to bring it back after the
575
+ // swap. Announce the update there, but only self-replace a stable install.
576
+ const runningStable = resolve(process.argv[1] || '') === STABLE_AGENT
577
+ const supervised = existsSync(join(AGENT_DIR, 'run-agent.cmd'))
578
+ if (!runningStable || !supervised) {
579
+ log(`↑ налична е нова версия ${latestVersion} (текуща ${AGENT_VERSION}) — пусни „gitdone-agent --install“ еднократно, за да включиш автообновяването`)
580
+ lastUpdateAttempt = { version: latestVersion, at: Date.now() }
581
+ return
582
+ }
583
+
584
+ updateInFlight = true
585
+ lastUpdateAttempt = { version: latestVersion, at: Date.now() }
586
+ const url = downloadUrl.startsWith('http') ? downloadUrl : `${cfg.url}${downloadUrl}`
587
+ const tmp = STABLE_AGENT + '.next'
588
+ log(`↑ автообновяване ${AGENT_VERSION} → ${latestVersion}`)
589
+ try {
590
+ if (!/^[a-f0-9]{64}$/i.test(String(expectedSha256 || ''))) {
591
+ throw new Error('сървърът не върна валиден sha256')
592
+ }
593
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${cfg.key}` } })
594
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
595
+ const buffer = Buffer.from(await response.arrayBuffer())
596
+ if (buffer.length < 1000) throw new Error(`подозрително малък файл (${buffer.length} B)`)
597
+
598
+ const actualSha256 = createHash('sha256').update(buffer).digest('hex')
599
+ if (actualSha256.toLowerCase() !== String(expectedSha256).toLowerCase()) {
600
+ throw new Error(`sha256 несъвпадение (очаквано ${expectedSha256}, получено ${actualSha256})`)
601
+ }
602
+ const embeddedVersion = buffer
603
+ .subarray(0, 12_000)
604
+ .toString('utf8')
605
+ .match(/const AGENT_VERSION\s*=\s*['"]([^'"]+)['"]/)?.[1]
606
+ if (embeddedVersion !== latestVersion) {
607
+ throw new Error(`файлът е v${embeddedVersion || '?'}, а сървърът обяви v${latestVersion}`)
608
+ }
609
+
610
+ writeFileSync(tmp, buffer)
611
+ renameSync(tmp, STABLE_AGENT)
612
+ log(`✓ обновено до ${latestVersion} — supervisor-ът ще рестартира агента`)
613
+ process.exit(0)
614
+ } catch (err) {
615
+ updateInFlight = false
616
+ try { if (existsSync(tmp)) unlinkSync(tmp) } catch { /* best-effort cleanup */ }
617
+ log(`✗ автообновяването се провали: ${err?.message || err} — оставам на ${AGENT_VERSION}, ще опитам пак до 1 час`)
618
+ }
619
+ }
620
+
550
621
  function runDoctor() {
551
622
  const cfg = readConfig()
552
623
  console.log('gitdone-agent doctor')
@@ -1853,39 +1924,99 @@ function runAiCommand(cfg, cmd, repoPath) {
1853
1924
  })
1854
1925
  }
1855
1926
 
1856
- // Download the images attached to a chat turn to a per-session temp dir and
1857
- // return their local paths. `claude -p` can't take image URLs on stdin, so we
1858
- // fetch them locally and point Claude at the files (its Read tool renders
1859
- // images). Best-effort: a failed download is skipped, not fatal.
1860
- async function downloadSessionImages(sessionId, urls) {
1927
+ const ATTACHMENT_EXTENSIONS = new Map([
1928
+ ['application/pdf', '.pdf'],
1929
+ ['application/json', '.json'],
1930
+ ['application/ld+json', '.json'],
1931
+ ['application/rtf', '.rtf'],
1932
+ ['application/msword', '.doc'],
1933
+ ['application/vnd.ms-excel', '.xls'],
1934
+ ['application/vnd.ms-powerpoint', '.ppt'],
1935
+ ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.docx'],
1936
+ ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.xlsx'],
1937
+ ['application/vnd.openxmlformats-officedocument.presentationml.presentation', '.pptx'],
1938
+ ['application/vnd.oasis.opendocument.text', '.odt'],
1939
+ ['application/vnd.oasis.opendocument.spreadsheet', '.ods'],
1940
+ ['application/epub+zip', '.epub'],
1941
+ ['application/zip', '.zip'],
1942
+ ['application/x-7z-compressed', '.7z'],
1943
+ ['application/gzip', '.gz'],
1944
+ ['text/plain', '.txt'],
1945
+ ['text/csv', '.csv'],
1946
+ ['text/markdown', '.md'],
1947
+ ['text/html', '.html'],
1948
+ ['text/xml', '.xml'],
1949
+ ['application/xml', '.xml'],
1950
+ ['application/yaml', '.yaml'],
1951
+ ['text/yaml', '.yaml'],
1952
+ ['image/png', '.png'],
1953
+ ['image/jpeg', '.jpg'],
1954
+ ['image/gif', '.gif'],
1955
+ ['image/webp', '.webp'],
1956
+ ['image/bmp', '.bmp'],
1957
+ ['image/svg+xml', '.svg'],
1958
+ ])
1959
+
1960
+ function safeAttachmentName(value, contentType, index) {
1961
+ let name = typeof value === 'string' ? value.trim() : ''
1962
+ try { name = decodeURIComponent(name) } catch { /* keep the original */ }
1963
+ name = name.replace(/\\/g, '/').split('/').pop()
1964
+ name = name
1965
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-')
1966
+ .replace(/^\.+/, '')
1967
+ .trim()
1968
+ .slice(0, 140)
1969
+ if (!name) name = `file-${index + 1}`
1970
+ if (!extname(name)) name += ATTACHMENT_EXTENSIONS.get(contentType?.split(';', 1)[0].toLowerCase()) ?? '.bin'
1971
+ return `${index + 1}-${name}`
1972
+ }
1973
+
1974
+ function attachmentUrlInfo(rawUrl) {
1975
+ try {
1976
+ const parsed = new URL(rawUrl)
1977
+ const name = new URLSearchParams(parsed.hash.slice(1)).get('gd-name') || ''
1978
+ parsed.hash = ''
1979
+ return { downloadUrl: parsed.toString(), name }
1980
+ } catch {
1981
+ return { downloadUrl: rawUrl.split('#', 1)[0], name: '' }
1982
+ }
1983
+ }
1984
+
1985
+ // Download files attached to a chat turn to a per-session temp dir and return
1986
+ // their local paths. Neither AI CLI consumes remote attachment URLs directly,
1987
+ // so the prompt points it at these files. Best-effort: one failed download does
1988
+ // not prevent the remaining attachments or message from reaching the AI.
1989
+ async function downloadSessionAttachments(sessionId, urls) {
1861
1990
  const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
1862
1991
  mkdirSync(dir, { recursive: true })
1863
1992
  const paths = []
1864
1993
  for (let i = 0; i < urls.length; i++) {
1865
1994
  try {
1866
- const res = await fetch(urls[i])
1867
- if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
1995
+ const info = attachmentUrlInfo(urls[i])
1996
+ const res = await fetch(info.downloadUrl)
1997
+ if (!res.ok) { log(`✗ attachment download HTTP ${res.status}: ${info.downloadUrl}`); continue }
1868
1998
  const buf = Buffer.from(await res.arrayBuffer())
1869
- const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
1870
- const ext = m ? m[1].toLowerCase() : 'png'
1871
- const file = join(dir, `image-${i + 1}.${ext}`)
1999
+ let sourceName = info.name
2000
+ if (!sourceName) {
2001
+ try { sourceName = new URL(info.downloadUrl).pathname.split('/').pop() || '' } catch { /* use fallback */ }
2002
+ }
2003
+ const file = join(dir, safeAttachmentName(sourceName, res.headers.get('content-type'), i))
1872
2004
  writeFileSync(file, buf)
1873
2005
  paths.push(file)
1874
2006
  } catch (e) {
1875
- log(`✗ image download failed (${urls[i]}): ${e.message}`)
2007
+ log(`✗ attachment download failed (${urls[i]}): ${e.message}`)
1876
2008
  }
1877
2009
  }
1878
2010
  return { dir, paths }
1879
2011
  }
1880
2012
 
1881
- // Point the AI at the images the user attached: neither CLI renders an image URL
1882
- // off stdin, so they're downloaded locally and referenced by path for its Read
1883
- // tool. Returns the prompt unchanged when nothing was attached (or every
1884
- // download failed), so an image-only turn still says something.
1885
- function withImageNote(prompt, paths) {
2013
+ // Point the AI at every locally downloaded attachment. The exact analysis tool
2014
+ // depends on the file type (Read for text/images/PDFs, or another available
2015
+ // local tool for structured/binary documents).
2016
+ function withAttachmentNote(prompt, paths) {
1886
2017
  if (!paths || paths.length === 0) return prompt
1887
2018
  const list = paths.map((p) => `- ${p}`).join('\n')
1888
- const note = `Потребителят прикачи ${paths.length} изображени${paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
2019
+ const note = `Потребителят прикачи ${paths.length} файл${paths.length === 1 ? '' : 'а'}. Прегледай и анализирай прикачванията с наличните ти инструменти. Локални пътища:\n${list}`
1889
2020
  return prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1890
2021
  }
1891
2022
 
@@ -2226,14 +2357,15 @@ function runCodexChatTurn(cfg, cmd, repoPath, opts) {
2226
2357
  async function runAiChat(cfg, cmd, repoPath) {
2227
2358
  const sessionId = cmd.payload?.sessionId
2228
2359
  const prompt = cmd.payload?.prompt ?? ''
2229
- const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
2360
+ const attachmentPayload = Array.isArray(cmd.payload?.attachments) ? cmd.payload.attachments : cmd.payload?.images
2361
+ const attachments = Array.isArray(attachmentPayload) ? attachmentPayload.filter((u) => typeof u === 'string' && u) : []
2230
2362
  const claudeSessionId = cmd.payload?.claudeSessionId || null
2231
2363
  const allowCommit = cmd.payload?.allowCommit === true
2232
2364
  const model = aiModelArg(cmd.payload?.model)
2233
2365
  const effort = aiEffortArg(cmd.payload?.effort)
2234
2366
  const provider = aiProviderArg(cmd.payload?.provider)
2235
2367
  // A turn may be text-only, image-only, or both — but needs at least one.
2236
- if (!sessionId || (!prompt && images.length === 0)) {
2368
+ if (!sessionId || (!prompt && attachments.length === 0)) {
2237
2369
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
2238
2370
  return
2239
2371
  }
@@ -2255,13 +2387,13 @@ async function runAiChat(cfg, cmd, repoPath) {
2255
2387
  }
2256
2388
 
2257
2389
  // Codex (gd-491): one process per turn, prompt on stdin at spawn — so the
2258
- // images have to be on disk BEFORE we start it, unlike the Claude path where
2390
+ // Attachments have to be on disk BEFORE we start it, unlike the Claude path where
2259
2391
  // the message is written into an already-running process.
2260
2392
  if (provider === 'codex') {
2261
- const dl = images.length > 0 ? await downloadSessionImages(sessionId, images) : null
2393
+ const dl = attachments.length > 0 ? await downloadSessionAttachments(sessionId, attachments) : null
2262
2394
  runCodexChatTurn(cfg, cmd, repoPath, {
2263
2395
  sessionId,
2264
- prompt: withImageNote(prompt, dl?.paths ?? []),
2396
+ prompt: withAttachmentNote(prompt, dl?.paths ?? []),
2265
2397
  imgDir: dl?.dir ?? null,
2266
2398
  resumeId: claudeSessionId,
2267
2399
  model,
@@ -2294,14 +2426,14 @@ async function runAiChat(cfg, cmd, repoPath) {
2294
2426
  log(`▶ ai_chat proc за ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
2295
2427
  }
2296
2428
 
2297
- // Fetch any attached images locally and fold their paths into the prompt so
2298
- // the AI reads them with its Read tool (URLs on stdin don't render).
2429
+ // Fetch attachments locally and fold their paths into the prompt; remote URLs
2430
+ // on stdin are not enough for either CLI to inspect the file contents.
2299
2431
  let imgDir = null
2300
2432
  let fullPrompt = prompt
2301
- if (images.length > 0) {
2302
- const dl = await downloadSessionImages(sessionId, images)
2433
+ if (attachments.length > 0) {
2434
+ const dl = await downloadSessionAttachments(sessionId, attachments)
2303
2435
  imgDir = dl.dir
2304
- fullPrompt = withImageNote(prompt, dl.paths)
2436
+ fullPrompt = withAttachmentNote(prompt, dl.paths)
2305
2437
  }
2306
2438
 
2307
2439
  // Open the turn BEFORE writing the message, so even the earliest output
@@ -2328,7 +2460,7 @@ async function runAiChat(cfg, cmd, repoPath) {
2328
2460
  await finishChatTurn(cfg, entry, { ok: false, code: `stdin: ${err.message}` })
2329
2461
  return
2330
2462
  }
2331
- log(`▶ ai_chat ход ${sessionId} (images=${images.length})`)
2463
+ log(`▶ ai_chat ход ${sessionId} (attachments=${attachments.length})`)
2332
2464
  }
2333
2465
 
2334
2466
  // ai_chat_stop — interrupt a running chat turn the user asked to stop (gd-303).
@@ -2734,7 +2866,12 @@ async function sync(cfg, discovered) {
2734
2866
  })
2735
2867
  if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
2736
2868
  else syncFullCountdown--
2737
- return data.tracked ?? []
2869
+ return {
2870
+ tracked: data.tracked ?? [],
2871
+ latestVersion: data.latestVersion,
2872
+ downloadUrl: data.downloadUrl,
2873
+ downloadSha256: data.downloadSha256,
2874
+ }
2738
2875
  }
2739
2876
 
2740
2877
  // Per-repo cache of the last snapshot we actually SENT, so an idle repo whose
@@ -2908,7 +3045,11 @@ async function runLoop(cfg) {
2908
3045
  async function tick() {
2909
3046
  try {
2910
3047
  const discovered = scanRepos(cfg.roots)
2911
- const tracked = await sync(cfg, discovered)
3048
+ const state = await sync(cfg, discovered)
3049
+ // A successful update exits here; the supervisor starts the new stable
3050
+ // script before any more repo work is performed.
3051
+ await selfUpdate(cfg, state.latestVersion, state.downloadUrl, state.downloadSha256)
3052
+ const tracked = state.tracked
2912
3053
  let pushed = 0
2913
3054
  let skipped = 0
2914
3055
  for (const repo of tracked) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.5",
3
+ "version": "0.8.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": {