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.
- package/index.js +175 -34
- 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
|
|
31
|
-
//
|
|
32
|
-
const AGENT_VERSION = '0.8.
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
|
1867
|
-
|
|
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
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
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(`✗
|
|
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
|
|
1882
|
-
//
|
|
1883
|
-
// tool
|
|
1884
|
-
|
|
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}
|
|
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
|
|
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 &&
|
|
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
|
-
//
|
|
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 =
|
|
2393
|
+
const dl = attachments.length > 0 ? await downloadSessionAttachments(sessionId, attachments) : null
|
|
2262
2394
|
runCodexChatTurn(cfg, cmd, repoPath, {
|
|
2263
2395
|
sessionId,
|
|
2264
|
-
prompt:
|
|
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
|
|
2298
|
-
//
|
|
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 (
|
|
2302
|
-
const dl = await
|
|
2433
|
+
if (attachments.length > 0) {
|
|
2434
|
+
const dl = await downloadSessionAttachments(sessionId, attachments)
|
|
2303
2435
|
imgDir = dl.dir
|
|
2304
|
-
fullPrompt =
|
|
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} (
|
|
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
|
|
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
|
|
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) {
|