gitdone-agent 0.5.6 → 0.6.2

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 +268 -22
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -12,10 +12,10 @@
12
12
  import { execSync, spawn } from 'node:child_process'
13
13
  import {
14
14
  existsSync, writeFileSync, readFileSync, unlinkSync,
15
- mkdirSync, copyFileSync, appendFileSync, readdirSync,
15
+ mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync,
16
16
  } from 'node:fs'
17
17
  import { resolve, join } from 'node:path'
18
- import { homedir, hostname } from 'node:os'
18
+ import { homedir, hostname, tmpdir } from 'node:os'
19
19
  import { randomUUID } from 'node:crypto'
20
20
 
21
21
  // ─── Stable agent dir, config + logging ────────────────────────────────────────
@@ -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.5.6'
30
+ const AGENT_VERSION = '0.6.2'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -239,6 +239,18 @@ function git(cmd, cwd) {
239
239
  }
240
240
  }
241
241
 
242
+ // Like git(), but returns the RAW bytes instead of a UTF-8 string. Needed for
243
+ // `git diff`, whose payload can be Windows-1251 (CP1251) Cyrillic — decoding
244
+ // those bytes as UTF-8 up front would corrupt them to „�"/„?" irreversibly
245
+ // (gd-276). Callers decode per-file via decodeDiffText().
246
+ function gitRaw(cmd, cwd) {
247
+ try {
248
+ return execSync(cmd, { cwd, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'] })
249
+ } catch {
250
+ return Buffer.alloc(0)
251
+ }
252
+ }
253
+
242
254
  // Like git(), but surfaces failures (with stderr) instead of swallowing them —
243
255
  // used for push/pull where we need to detect auth rejection.
244
256
  function gitTry(cmd, cwd) {
@@ -264,20 +276,71 @@ function authUrl(auth) {
264
276
  return `https://x-access-token:${auth.token}@github.com/${repo}.git`
265
277
  }
266
278
 
279
+ // A push rejected because the remote branch moved ahead of us (someone else
280
+ // pushed in the meantime). Git prints "non-fast-forward" / "fetch first" /
281
+ // "tip of your current branch is behind". We recover by pulling+rebasing.
282
+ const NON_FAST_FORWARD = /non-fast-forward|fetch first|tip of your current branch is behind|failed to push some refs/i
283
+
284
+ // Point the local remote-tracking ref (e.g. refs/remotes/origin/master) at the
285
+ // commit we just synced. We push/pull through the ad-hoc token URL, and — unlike
286
+ // `git push origin` — pushing/pulling by URL leaves refs/remotes/origin/* UNTOUCHED.
287
+ // getSnapshot() then measures "ahead"/"behind" against that stale tracking ref
288
+ // (git rev-list @{u}..HEAD), so already-pushed commits keep showing as pending
289
+ // forever: the ahead badge never clears, GitHub Desktop shows the same "N↑", and
290
+ // the user pushes again and again while local commits pile up (gd-273). Fast-
291
+ // forwarding the tracking ref to the true remote tip resets ahead/behind to reality.
292
+ // Best-effort: git() swallows failures so this never breaks the push/pull itself.
293
+ function updateTrackingRef(repoPath, branch, commitish) {
294
+ // Prefer the branch's configured upstream (usually origin/<branch>); fall back
295
+ // to origin/<branch> when no upstream is set.
296
+ const upstream = git('git rev-parse --abbrev-ref --symbolic-full-name @{u}', repoPath)
297
+ const ref = upstream ? `refs/remotes/${upstream}` : `refs/remotes/origin/${branch}`
298
+ git(`git update-ref "${ref}" ${commitish}`, repoPath)
299
+ }
300
+
267
301
  // Push/pull using the server-issued token when we have one; otherwise fall back
268
302
  // to the machine's own git credentials (old behaviour). Throws on failure.
269
303
  function pushOrPull(type, repoPath, auth) {
270
304
  const branch = git('git branch --show-current', repoPath) || 'HEAD'
271
- let cmd
272
- if (auth) {
273
- const url = authUrl(auth)
274
- cmd = type === 'push' ? `git push "${url}" HEAD:${branch}` : `git pull "${url}" ${branch}`
275
- } else {
276
- cmd = type === 'push' ? 'git push' : 'git pull'
305
+ const url = auth ? authUrl(auth) : null
306
+ const pushCmd = url ? `git push "${url}" HEAD:${branch}` : 'git push'
307
+
308
+ if (type === 'pull') {
309
+ const r = gitTry(url ? `git pull "${url}" ${branch}` : 'git pull', repoPath)
310
+ if (!r.ok) throw new Error(r.out || 'pull failed')
311
+ // Pulling by URL never advanced origin/<branch>; point it at the fetched tip
312
+ // (FETCH_HEAD) so a later push isn't seen as being "ahead" of a stale ref.
313
+ if (url) updateTrackingRef(repoPath, branch, 'FETCH_HEAD')
314
+ return r.out || 'pulled'
315
+ }
316
+
317
+ let r = gitTry(pushCmd, repoPath)
318
+ if (r.ok) {
319
+ // We just pushed HEAD to the remote branch, so the remote tip == HEAD. Sync
320
+ // the local tracking ref to clear the "ahead" count (gd-273).
321
+ if (url) updateTrackingRef(repoPath, branch, 'HEAD')
322
+ return r.out || 'pushed'
323
+ }
324
+
325
+ // Push rejected because the remote is ahead (gd-272): commit succeeded but the
326
+ // push bounced with non-fast-forward, so the files looked "pushed" while the
327
+ // UI showed a scary git error. Pull with --rebase to replay our commits on top
328
+ // of the remote ones, then push again — what the user means by "push my files".
329
+ if (NON_FAST_FORWARD.test(r.out)) {
330
+ const pull = gitTry(url ? `git pull --rebase "${url}" ${branch}` : 'git pull --rebase', repoPath)
331
+ if (!pull.ok) {
332
+ // Rebase couldn't apply cleanly (conflicts) — abort so the repo isn't left
333
+ // mid-rebase, and surface an actionable message instead of guessing.
334
+ gitTry('git rebase --abort', repoPath)
335
+ throw new Error(`отдалеченият клон е напред и има конфликт при обединяване — дръпни (Sync) и слей ръчно, после пусни пак.\n${pull.out}`)
336
+ }
337
+ r = gitTry(pushCmd, repoPath)
338
+ if (!r.ok) throw new Error(r.out || 'push failed')
339
+ if (url) updateTrackingRef(repoPath, branch, 'HEAD')
340
+ return `дръпнах новите промени от сървъра и пушнах наново.\n${r.out}`.trim()
277
341
  }
278
- const r = gitTry(cmd, repoPath)
279
- if (!r.ok) throw new Error(r.out || `${type} failed`)
280
- return r.out || (type === 'push' ? 'pushed' : 'pulled')
342
+
343
+ throw new Error(r.out || 'push failed')
281
344
  }
282
345
 
283
346
  const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
@@ -303,14 +366,57 @@ function discardFile(repoPath, file) {
303
366
  return 'discarded (new file removed)'
304
367
  }
305
368
 
306
- function parseDiffByFile(diffOutput) {
369
+ // ─── Windows-1251 (CP1251) aware diff decoding (gd-276) ───────────────────────
370
+ // Legacy Cyrillic sources are often saved in CP1251. Their high bytes (0x80–0xFF,
371
+ // single-byte) are invalid UTF-8, so reading the diff as UTF-8 shows „?"/„�"
372
+ // instead of кирилица. We keep the raw bytes and decode each file's diff section
373
+ // on its own: valid UTF-8 stays UTF-8; the rest goes through the CP1251 table
374
+ // below — so a UTF-8 repo is untouched while CP1251 files finally read correctly.
375
+
376
+ // CP1251 high range 0x80–0xFF → Unicode code points (0x00–0x7F is plain ASCII).
377
+ // 0x98 is unassigned in CP1251 → U+FFFD.
378
+ const CP1251_HIGH = [
379
+ 0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
380
+ 0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
381
+ 0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
382
+ 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
383
+ 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F,
384
+ 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F,
385
+ 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F,
386
+ 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F,
387
+ ]
388
+
389
+ function decodeCp1251(buf) {
390
+ let out = ''
391
+ for (const b of buf) out += String.fromCharCode(b < 0x80 ? b : CP1251_HIGH[b - 0x80])
392
+ return out
393
+ }
394
+
395
+ // A strict UTF-8 decoder (always available even on small-ICU builds) — throws on
396
+ // any invalid sequence, which is exactly how we tell UTF-8 apart from CP1251.
397
+ const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true })
398
+ function decodeDiffText(buf) {
399
+ try {
400
+ return UTF8_STRICT.decode(buf)
401
+ } catch {
402
+ return decodeCp1251(buf)
403
+ }
404
+ }
405
+
406
+ function parseDiffByFile(diffBuf) {
307
407
  const files = {}
308
- if (!diffOutput) return files
309
- const sections = diffOutput.split(/(?=^diff --git )/m)
408
+ if (!diffBuf || diffBuf.length === 0) return files
409
+ // Latin1 is a lossless 1-byte⇢1-char mapping, so the ASCII "diff --git" split
410
+ // markers match while every original byte survives for the per-section decode.
411
+ const raw = Buffer.isBuffer(diffBuf) ? diffBuf.toString('latin1') : String(diffBuf)
412
+ const sections = raw.split(/(?=^diff --git )/m)
310
413
  for (const section of sections) {
311
414
  if (!section.trim()) continue
312
- const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
313
- if (match) files[match[1]] = section
415
+ // Decode THIS file's bytes on their own (UTF-8 or CP1251), then read the
416
+ // filename from the decoded header.
417
+ const text = decodeDiffText(Buffer.from(section, 'latin1'))
418
+ const match = text.match(/^diff --git a\/.+ b\/(.+)$/m)
419
+ if (match) files[match[1]] = text
314
420
  }
315
421
  return files
316
422
  }
@@ -340,7 +446,7 @@ function getSnapshot(repoPath) {
340
446
  lastCommit = { sha, message, author, date }
341
447
  }
342
448
 
343
- const diffs = parseDiffByFile(git('git diff HEAD', repoPath))
449
+ const diffs = parseDiffByFile(gitRaw('git diff HEAD', repoPath))
344
450
 
345
451
  // Origin remote → lets the server auto-detect the GitHub repo for the History tab.
346
452
  const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
@@ -660,15 +766,42 @@ function runAiCommand(cfg, cmd, repoPath) {
660
766
  })
661
767
  }
662
768
 
769
+ // Download the images attached to a chat turn to a per-session temp dir and
770
+ // return their local paths. `claude -p` can't take image URLs on stdin, so we
771
+ // fetch them locally and point Claude at the files (its Read tool renders
772
+ // images). Best-effort: a failed download is skipped, not fatal.
773
+ async function downloadSessionImages(sessionId, urls) {
774
+ const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
775
+ mkdirSync(dir, { recursive: true })
776
+ const paths = []
777
+ for (let i = 0; i < urls.length; i++) {
778
+ try {
779
+ const res = await fetch(urls[i])
780
+ if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
781
+ const buf = Buffer.from(await res.arrayBuffer())
782
+ const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
783
+ const ext = m ? m[1].toLowerCase() : 'png'
784
+ const file = join(dir, `image-${i + 1}.${ext}`)
785
+ writeFileSync(file, buf)
786
+ paths.push(file)
787
+ } catch (e) {
788
+ log(`✗ image download failed (${urls[i]}): ${e.message}`)
789
+ }
790
+ }
791
+ return { dir, paths }
792
+ }
793
+
663
794
  // Run one turn of an interactive chat session: `claude -p` (resuming the prior
664
795
  // Claude session when known) with the user's message on stdin, streaming the
665
796
  // reply back to the session console. Long-running + fire-and-forget like ai_run.
666
- function runAiChat(cfg, cmd, repoPath) {
797
+ async function runAiChat(cfg, cmd, repoPath) {
667
798
  const sessionId = cmd.payload?.sessionId
668
799
  const prompt = cmd.payload?.prompt ?? ''
800
+ const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
669
801
  const claudeSessionId = cmd.payload?.claudeSessionId || null
670
802
  const allowCommit = cmd.payload?.allowCommit === true
671
- if (!sessionId || !prompt) {
803
+ // A turn may be text-only, image-only, or both — but needs at least one.
804
+ if (!sessionId || (!prompt && images.length === 0)) {
672
805
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
673
806
  return
674
807
  }
@@ -715,13 +848,28 @@ function runAiChat(cfg, cmd, repoPath) {
715
848
  const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
716
849
  const timer = setInterval(flush, 800)
717
850
 
718
- log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
851
+ // Fetch any attached images locally and fold their paths into the prompt so
852
+ // Claude reads them with its Read tool (URLs on stdin don't render).
853
+ let imgDir = null
854
+ let fullPrompt = prompt
855
+ if (images.length > 0) {
856
+ const dl = await downloadSessionImages(sessionId, images)
857
+ imgDir = dl.dir
858
+ if (dl.paths.length > 0) {
859
+ const list = dl.paths.map((p) => `- ${p}`).join('\n')
860
+ const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
861
+ fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
862
+ }
863
+ }
864
+
865
+ log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}, images=${images.length}) via ${claudePath}`)
719
866
 
720
867
  let child
721
868
  try {
722
869
  child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
723
870
  } catch (err) {
724
871
  clearInterval(timer)
872
+ if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
725
873
  postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
726
874
  reportCommandResult(cfg, cmd.id, 'error', err.message)
727
875
  return
@@ -729,7 +877,7 @@ function runAiChat(cfg, cmd, repoPath) {
729
877
 
730
878
  if (child.stdin) {
731
879
  child.stdin.on('error', () => {})
732
- try { child.stdin.write(prompt); child.stdin.end() }
880
+ try { child.stdin.write(fullPrompt); child.stdin.end() }
733
881
  catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
734
882
  }
735
883
 
@@ -747,6 +895,7 @@ function runAiChat(cfg, cmd, repoPath) {
747
895
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
748
896
  child.on('close', async (code) => {
749
897
  clearInterval(timer)
898
+ if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
750
899
  if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
751
900
  await flush()
752
901
  const ok = code === 0
@@ -897,6 +1046,99 @@ async function pushSnapshot(cfg, repo) {
897
1046
  return snapshot
898
1047
  }
899
1048
 
1049
+ // ─── Low-latency command channel (gd-274) ───────────────────────────────────────
1050
+ // The 30s snapshot poll is fine for reflecting git state, but makes user actions
1051
+ // (Push/Pull/Commit/АИ) feel sluggish: a queued command waits up to a full tick
1052
+ // before the agent even asks for it. So we ALSO hold a persistent SSE connection
1053
+ // to the server and drain+run commands the instant it says "wake". The snapshot
1054
+ // poll stays as the reliable fallback (and cron heartbeat) if the stream drops.
1055
+
1056
+ const GIT_STATE_CMDS = new Set(['commit', 'push', 'pull', 'discard'])
1057
+
1058
+ // Pull this machine's pending commands in one shot (no git/snapshot work server-
1059
+ // side) and run them. Serialised via a tiny mutex so overlapping wakes don't
1060
+ // double-drain; a wake arriving mid-drain sets a flag to run once more after.
1061
+ let draining = false
1062
+ let drainAgain = false
1063
+ async function drainCommands(cfg) {
1064
+ if (draining) { drainAgain = true; return }
1065
+ draining = true
1066
+ try {
1067
+ do {
1068
+ drainAgain = false
1069
+ let data
1070
+ try {
1071
+ data = await api(cfg, '/api/v1/agent/commands', { machineId: cfg.machineId })
1072
+ } catch (err) {
1073
+ log(`✗ command drain failed: ${err.message}`)
1074
+ return
1075
+ }
1076
+ const commands = data.commands ?? []
1077
+ if (commands.length === 0) continue
1078
+
1079
+ // Cache any push tokens the server issued for these commands' repos.
1080
+ if (data.githubAuth && typeof data.githubAuth === 'object') {
1081
+ cfg.auth = cfg.auth ?? {}
1082
+ for (const [path, a] of Object.entries(data.githubAuth)) {
1083
+ if (a && a.token && a.repo) cfg.auth[path] = a
1084
+ }
1085
+ writeConfig(cfg)
1086
+ }
1087
+
1088
+ // Run each command against its own repo path, and remember which repos had
1089
+ // their git state changed so we can push a fresh snapshot right after —
1090
+ // that's what makes the UI update instantly instead of on the next tick.
1091
+ const touched = new Map()
1092
+ for (const cmd of commands) {
1093
+ const repoPath = cmd.path
1094
+ if (!repoPath) { log(`✗ command ${cmd.id} has no path — skipped`); continue }
1095
+ await executeCommand(cfg, cmd, repoPath)
1096
+ if (GIT_STATE_CMDS.has(cmd.type)) touched.set(repoPath, cmd.repoName || repoPath)
1097
+ }
1098
+ for (const [path, name] of touched) {
1099
+ try { await pushSnapshot(cfg, { path, name }) }
1100
+ catch (err) { log(`✗ post-command snapshot failed @ ${path}: ${err.message}`) }
1101
+ }
1102
+ } while (drainAgain)
1103
+ } finally {
1104
+ draining = false
1105
+ }
1106
+ }
1107
+
1108
+ // Hold a persistent SSE connection open and drain the moment the server pushes a
1109
+ // `wake`. Reconnects forever with a short backoff; the server also recycles the
1110
+ // connection every few minutes (each reconnect re-sends an initial wake, so
1111
+ // anything queued while we were away is picked up). Never throws.
1112
+ const STREAM_RECONNECT_MS = 3000
1113
+ async function streamCommands(cfg) {
1114
+ const url = `${cfg.url}/api/v1/agent/commands/stream?machineId=${encodeURIComponent(cfg.machineId)}`
1115
+ for (;;) {
1116
+ try {
1117
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${cfg.key}` } })
1118
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
1119
+ log('▶ command stream connected')
1120
+ const reader = res.body.getReader()
1121
+ const decoder = new TextDecoder()
1122
+ let buf = ''
1123
+ for (;;) {
1124
+ const { value, done } = await reader.read()
1125
+ if (done) break
1126
+ buf += decoder.decode(value, { stream: true })
1127
+ // SSE events are separated by a blank line; a `wake` means "drain now".
1128
+ let idx
1129
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
1130
+ const frame = buf.slice(0, idx)
1131
+ buf = buf.slice(idx + 2)
1132
+ if (/(^|\n)event:\s*wake/.test(frame)) drainCommands(cfg)
1133
+ }
1134
+ }
1135
+ } catch (err) {
1136
+ log(`… command stream disconnected (${err.message}); reconnecting`)
1137
+ }
1138
+ await new Promise((r) => setTimeout(r, STREAM_RECONNECT_MS))
1139
+ }
1140
+ }
1141
+
900
1142
  // ─── Main ────────────────────────────────────────────────────────────────────
901
1143
 
902
1144
  async function runLoop(cfg) {
@@ -918,6 +1160,10 @@ async function runLoop(cfg) {
918
1160
  }
919
1161
  }
920
1162
 
1163
+ // Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
1164
+ // its own reconnect loop and never rejects.
1165
+ streamCommands(cfg)
1166
+
921
1167
  await tick()
922
1168
  setInterval(tick, cfg.interval * 1000)
923
1169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.5.6",
3
+ "version": "0.6.2",
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": {