gitdone-agent 0.8.4 → 0.8.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 +117 -43
  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.4'
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.6'
33
33
 
34
34
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
35
35
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -999,19 +999,32 @@ function scanRepos(roots, maxDepth = 5) {
999
999
 
1000
1000
  // ─── Server sync + commands ─────────────────────────────────────────────────────
1001
1001
 
1002
+ // Keep the deadline below the server-side session stall window. Without a
1003
+ // deadline, a proxy/network connection that never completes can block the
1004
+ // event-posting loop forever, so no heartbeat reaches the server and the
1005
+ // watchdog incorrectly declares a live AI turn stalled.
1006
+ const API_REQUEST_TIMEOUT_MS = 10_000
1007
+
1002
1008
  async function apiOnce(cfg, path, body) {
1003
- const res = await fetch(`${cfg.url}${path}`, {
1004
- method: 'POST',
1005
- headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
1006
- body: JSON.stringify(body),
1007
- })
1008
- if (!res.ok) {
1009
- const text = await res.text().catch(() => '')
1010
- const err = new Error(`HTTP ${res.status} ${path}: ${text}`)
1011
- err.status = res.status
1012
- throw err
1009
+ const controller = new AbortController()
1010
+ const timer = setTimeout(() => controller.abort(), API_REQUEST_TIMEOUT_MS)
1011
+ try {
1012
+ const res = await fetch(`${cfg.url}${path}`, {
1013
+ method: 'POST',
1014
+ headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
1015
+ body: JSON.stringify(body),
1016
+ signal: controller.signal,
1017
+ })
1018
+ if (!res.ok) {
1019
+ const text = await res.text().catch(() => '')
1020
+ const err = new Error(`HTTP ${res.status} ${path}: ${text}`)
1021
+ err.status = res.status
1022
+ throw err
1023
+ }
1024
+ return res.json().catch(() => ({}))
1025
+ } finally {
1026
+ clearTimeout(timer)
1013
1027
  }
1014
- return res.json().catch(() => ({}))
1015
1028
  }
1016
1029
 
1017
1030
  // gd-514: statuses where the app server demonstrably never processed the body,
@@ -1840,39 +1853,99 @@ function runAiCommand(cfg, cmd, repoPath) {
1840
1853
  })
1841
1854
  }
1842
1855
 
1843
- // Download the images attached to a chat turn to a per-session temp dir and
1844
- // return their local paths. `claude -p` can't take image URLs on stdin, so we
1845
- // fetch them locally and point Claude at the files (its Read tool renders
1846
- // images). Best-effort: a failed download is skipped, not fatal.
1847
- async function downloadSessionImages(sessionId, urls) {
1856
+ const ATTACHMENT_EXTENSIONS = new Map([
1857
+ ['application/pdf', '.pdf'],
1858
+ ['application/json', '.json'],
1859
+ ['application/ld+json', '.json'],
1860
+ ['application/rtf', '.rtf'],
1861
+ ['application/msword', '.doc'],
1862
+ ['application/vnd.ms-excel', '.xls'],
1863
+ ['application/vnd.ms-powerpoint', '.ppt'],
1864
+ ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.docx'],
1865
+ ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.xlsx'],
1866
+ ['application/vnd.openxmlformats-officedocument.presentationml.presentation', '.pptx'],
1867
+ ['application/vnd.oasis.opendocument.text', '.odt'],
1868
+ ['application/vnd.oasis.opendocument.spreadsheet', '.ods'],
1869
+ ['application/epub+zip', '.epub'],
1870
+ ['application/zip', '.zip'],
1871
+ ['application/x-7z-compressed', '.7z'],
1872
+ ['application/gzip', '.gz'],
1873
+ ['text/plain', '.txt'],
1874
+ ['text/csv', '.csv'],
1875
+ ['text/markdown', '.md'],
1876
+ ['text/html', '.html'],
1877
+ ['text/xml', '.xml'],
1878
+ ['application/xml', '.xml'],
1879
+ ['application/yaml', '.yaml'],
1880
+ ['text/yaml', '.yaml'],
1881
+ ['image/png', '.png'],
1882
+ ['image/jpeg', '.jpg'],
1883
+ ['image/gif', '.gif'],
1884
+ ['image/webp', '.webp'],
1885
+ ['image/bmp', '.bmp'],
1886
+ ['image/svg+xml', '.svg'],
1887
+ ])
1888
+
1889
+ function safeAttachmentName(value, contentType, index) {
1890
+ let name = typeof value === 'string' ? value.trim() : ''
1891
+ try { name = decodeURIComponent(name) } catch { /* keep the original */ }
1892
+ name = name.replace(/\\/g, '/').split('/').pop()
1893
+ name = name
1894
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-')
1895
+ .replace(/^\.+/, '')
1896
+ .trim()
1897
+ .slice(0, 140)
1898
+ if (!name) name = `file-${index + 1}`
1899
+ if (!extname(name)) name += ATTACHMENT_EXTENSIONS.get(contentType?.split(';', 1)[0].toLowerCase()) ?? '.bin'
1900
+ return `${index + 1}-${name}`
1901
+ }
1902
+
1903
+ function attachmentUrlInfo(rawUrl) {
1904
+ try {
1905
+ const parsed = new URL(rawUrl)
1906
+ const name = new URLSearchParams(parsed.hash.slice(1)).get('gd-name') || ''
1907
+ parsed.hash = ''
1908
+ return { downloadUrl: parsed.toString(), name }
1909
+ } catch {
1910
+ return { downloadUrl: rawUrl.split('#', 1)[0], name: '' }
1911
+ }
1912
+ }
1913
+
1914
+ // Download files attached to a chat turn to a per-session temp dir and return
1915
+ // their local paths. Neither AI CLI consumes remote attachment URLs directly,
1916
+ // so the prompt points it at these files. Best-effort: one failed download does
1917
+ // not prevent the remaining attachments or message from reaching the AI.
1918
+ async function downloadSessionAttachments(sessionId, urls) {
1848
1919
  const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
1849
1920
  mkdirSync(dir, { recursive: true })
1850
1921
  const paths = []
1851
1922
  for (let i = 0; i < urls.length; i++) {
1852
1923
  try {
1853
- const res = await fetch(urls[i])
1854
- if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
1924
+ const info = attachmentUrlInfo(urls[i])
1925
+ const res = await fetch(info.downloadUrl)
1926
+ if (!res.ok) { log(`✗ attachment download HTTP ${res.status}: ${info.downloadUrl}`); continue }
1855
1927
  const buf = Buffer.from(await res.arrayBuffer())
1856
- const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
1857
- const ext = m ? m[1].toLowerCase() : 'png'
1858
- const file = join(dir, `image-${i + 1}.${ext}`)
1928
+ let sourceName = info.name
1929
+ if (!sourceName) {
1930
+ try { sourceName = new URL(info.downloadUrl).pathname.split('/').pop() || '' } catch { /* use fallback */ }
1931
+ }
1932
+ const file = join(dir, safeAttachmentName(sourceName, res.headers.get('content-type'), i))
1859
1933
  writeFileSync(file, buf)
1860
1934
  paths.push(file)
1861
1935
  } catch (e) {
1862
- log(`✗ image download failed (${urls[i]}): ${e.message}`)
1936
+ log(`✗ attachment download failed (${urls[i]}): ${e.message}`)
1863
1937
  }
1864
1938
  }
1865
1939
  return { dir, paths }
1866
1940
  }
1867
1941
 
1868
- // Point the AI at the images the user attached: neither CLI renders an image URL
1869
- // off stdin, so they're downloaded locally and referenced by path for its Read
1870
- // tool. Returns the prompt unchanged when nothing was attached (or every
1871
- // download failed), so an image-only turn still says something.
1872
- function withImageNote(prompt, paths) {
1942
+ // Point the AI at every locally downloaded attachment. The exact analysis tool
1943
+ // depends on the file type (Read for text/images/PDFs, or another available
1944
+ // local tool for structured/binary documents).
1945
+ function withAttachmentNote(prompt, paths) {
1873
1946
  if (!paths || paths.length === 0) return prompt
1874
1947
  const list = paths.map((p) => `- ${p}`).join('\n')
1875
- const note = `Потребителят прикачи ${paths.length} изображени${paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
1948
+ const note = `Потребителят прикачи ${paths.length} файл${paths.length === 1 ? '' : 'а'}. Прегледай и анализирай прикачванията с наличните ти инструменти. Локални пътища:\n${list}`
1876
1949
  return prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1877
1950
  }
1878
1951
 
@@ -2213,14 +2286,15 @@ function runCodexChatTurn(cfg, cmd, repoPath, opts) {
2213
2286
  async function runAiChat(cfg, cmd, repoPath) {
2214
2287
  const sessionId = cmd.payload?.sessionId
2215
2288
  const prompt = cmd.payload?.prompt ?? ''
2216
- const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
2289
+ const attachmentPayload = Array.isArray(cmd.payload?.attachments) ? cmd.payload.attachments : cmd.payload?.images
2290
+ const attachments = Array.isArray(attachmentPayload) ? attachmentPayload.filter((u) => typeof u === 'string' && u) : []
2217
2291
  const claudeSessionId = cmd.payload?.claudeSessionId || null
2218
2292
  const allowCommit = cmd.payload?.allowCommit === true
2219
2293
  const model = aiModelArg(cmd.payload?.model)
2220
2294
  const effort = aiEffortArg(cmd.payload?.effort)
2221
2295
  const provider = aiProviderArg(cmd.payload?.provider)
2222
2296
  // A turn may be text-only, image-only, or both — but needs at least one.
2223
- if (!sessionId || (!prompt && images.length === 0)) {
2297
+ if (!sessionId || (!prompt && attachments.length === 0)) {
2224
2298
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
2225
2299
  return
2226
2300
  }
@@ -2242,13 +2316,13 @@ async function runAiChat(cfg, cmd, repoPath) {
2242
2316
  }
2243
2317
 
2244
2318
  // Codex (gd-491): one process per turn, prompt on stdin at spawn — so the
2245
- // images have to be on disk BEFORE we start it, unlike the Claude path where
2319
+ // Attachments have to be on disk BEFORE we start it, unlike the Claude path where
2246
2320
  // the message is written into an already-running process.
2247
2321
  if (provider === 'codex') {
2248
- const dl = images.length > 0 ? await downloadSessionImages(sessionId, images) : null
2322
+ const dl = attachments.length > 0 ? await downloadSessionAttachments(sessionId, attachments) : null
2249
2323
  runCodexChatTurn(cfg, cmd, repoPath, {
2250
2324
  sessionId,
2251
- prompt: withImageNote(prompt, dl?.paths ?? []),
2325
+ prompt: withAttachmentNote(prompt, dl?.paths ?? []),
2252
2326
  imgDir: dl?.dir ?? null,
2253
2327
  resumeId: claudeSessionId,
2254
2328
  model,
@@ -2281,14 +2355,14 @@ async function runAiChat(cfg, cmd, repoPath) {
2281
2355
  log(`▶ ai_chat proc за ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
2282
2356
  }
2283
2357
 
2284
- // Fetch any attached images locally and fold their paths into the prompt so
2285
- // the AI reads them with its Read tool (URLs on stdin don't render).
2358
+ // Fetch attachments locally and fold their paths into the prompt; remote URLs
2359
+ // on stdin are not enough for either CLI to inspect the file contents.
2286
2360
  let imgDir = null
2287
2361
  let fullPrompt = prompt
2288
- if (images.length > 0) {
2289
- const dl = await downloadSessionImages(sessionId, images)
2362
+ if (attachments.length > 0) {
2363
+ const dl = await downloadSessionAttachments(sessionId, attachments)
2290
2364
  imgDir = dl.dir
2291
- fullPrompt = withImageNote(prompt, dl.paths)
2365
+ fullPrompt = withAttachmentNote(prompt, dl.paths)
2292
2366
  }
2293
2367
 
2294
2368
  // Open the turn BEFORE writing the message, so even the earliest output
@@ -2315,7 +2389,7 @@ async function runAiChat(cfg, cmd, repoPath) {
2315
2389
  await finishChatTurn(cfg, entry, { ok: false, code: `stdin: ${err.message}` })
2316
2390
  return
2317
2391
  }
2318
- log(`▶ ai_chat ход ${sessionId} (images=${images.length})`)
2392
+ log(`▶ ai_chat ход ${sessionId} (attachments=${attachments.length})`)
2319
2393
  }
2320
2394
 
2321
2395
  // ai_chat_stop — interrupt a running chat turn the user asked to stop (gd-303).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.4",
3
+ "version": "0.8.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": {