gitdone-agent 0.6.0 → 0.6.3

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 +158 -21
  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.6.0'
30
+ const AGENT_VERSION = '0.6.3'
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) {
@@ -354,14 +366,57 @@ function discardFile(repoPath, file) {
354
366
  return 'discarded (new file removed)'
355
367
  }
356
368
 
357
- 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) {
358
407
  const files = {}
359
- if (!diffOutput) return files
360
- 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)
361
413
  for (const section of sections) {
362
414
  if (!section.trim()) continue
363
- const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
364
- 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
365
420
  }
366
421
  return files
367
422
  }
@@ -391,7 +446,7 @@ function getSnapshot(repoPath) {
391
446
  lastCommit = { sha, message, author, date }
392
447
  }
393
448
 
394
- const diffs = parseDiffByFile(git('git diff HEAD', repoPath))
449
+ const diffs = parseDiffByFile(gitRaw('git diff HEAD', repoPath))
395
450
 
396
451
  // Origin remote → lets the server auto-detect the GitHub repo for the History tab.
397
452
  const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
@@ -466,19 +521,24 @@ async function postRunEvents(cfg, runId, events, status, result) {
466
521
 
467
522
  // Post transcript lines (and optional turn status / Claude session id) for an
468
523
  // interactive AiSession chat turn.
469
- async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId) {
524
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText) {
470
525
  await api(cfg, '/api/v1/agent/ai-session/events', {
471
526
  sessionId,
472
527
  events,
473
528
  ...(status ? { status } : {}),
474
529
  ...(claudeSessionId ? { claudeSessionId } : {}),
530
+ // Explicit '' clears the live preview; `undefined` leaves it untouched.
531
+ ...(streamingText !== undefined ? { streamingText } : {}),
475
532
  }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
476
533
  }
477
534
 
478
535
  // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
479
536
  // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
480
537
  // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
481
- function parseStreamLine(line, push, onInit) {
538
+ // With `--include-partial-messages`, Claude also emits `stream_event` lines
539
+ // carrying incremental text deltas — onDelta gets those so chat sessions can
540
+ // show the reply being written live (gd-302).
541
+ function parseStreamLine(line, push, onInit, onDelta) {
482
542
  let ev
483
543
  try { ev = JSON.parse(line) } catch { return }
484
544
  if (ev.type === 'system') {
@@ -489,6 +549,16 @@ function parseStreamLine(line, push, onInit) {
489
549
  }
490
550
  return
491
551
  }
552
+ // Partial-message stream: only the model's visible text deltas feed the live
553
+ // preview. Tool-input JSON deltas and thinking deltas are ignored here — the
554
+ // full block still arrives as a normal `assistant` event below.
555
+ if (ev.type === 'stream_event' && typeof onDelta === 'function') {
556
+ const e = ev.event
557
+ if (e?.type === 'content_block_delta' && e.delta?.type === 'text_delta' && e.delta.text) {
558
+ onDelta(e.delta.text)
559
+ }
560
+ return
561
+ }
492
562
  if (ev.type === 'assistant' && ev.message?.content) {
493
563
  for (const block of ev.message.content) {
494
564
  if (block.type === 'text' && block.text?.trim()) {
@@ -711,15 +781,42 @@ function runAiCommand(cfg, cmd, repoPath) {
711
781
  })
712
782
  }
713
783
 
784
+ // Download the images attached to a chat turn to a per-session temp dir and
785
+ // return their local paths. `claude -p` can't take image URLs on stdin, so we
786
+ // fetch them locally and point Claude at the files (its Read tool renders
787
+ // images). Best-effort: a failed download is skipped, not fatal.
788
+ async function downloadSessionImages(sessionId, urls) {
789
+ const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
790
+ mkdirSync(dir, { recursive: true })
791
+ const paths = []
792
+ for (let i = 0; i < urls.length; i++) {
793
+ try {
794
+ const res = await fetch(urls[i])
795
+ if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
796
+ const buf = Buffer.from(await res.arrayBuffer())
797
+ const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
798
+ const ext = m ? m[1].toLowerCase() : 'png'
799
+ const file = join(dir, `image-${i + 1}.${ext}`)
800
+ writeFileSync(file, buf)
801
+ paths.push(file)
802
+ } catch (e) {
803
+ log(`✗ image download failed (${urls[i]}): ${e.message}`)
804
+ }
805
+ }
806
+ return { dir, paths }
807
+ }
808
+
714
809
  // Run one turn of an interactive chat session: `claude -p` (resuming the prior
715
810
  // Claude session when known) with the user's message on stdin, streaming the
716
811
  // reply back to the session console. Long-running + fire-and-forget like ai_run.
717
- function runAiChat(cfg, cmd, repoPath) {
812
+ async function runAiChat(cfg, cmd, repoPath) {
718
813
  const sessionId = cmd.payload?.sessionId
719
814
  const prompt = cmd.payload?.prompt ?? ''
815
+ const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
720
816
  const claudeSessionId = cmd.payload?.claudeSessionId || null
721
817
  const allowCommit = cmd.payload?.allowCommit === true
722
- if (!sessionId || !prompt) {
818
+ // A turn may be text-only, image-only, or both — but needs at least one.
819
+ if (!sessionId || (!prompt && images.length === 0)) {
723
820
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
724
821
  return
725
822
  }
@@ -740,6 +837,9 @@ function runAiChat(cfg, cmd, repoPath) {
740
837
  '-p',
741
838
  '--output-format', 'stream-json',
742
839
  '--verbose',
840
+ // Stream the reply token-by-token so the console can show it being written
841
+ // live, not only once each block is complete (gd-302).
842
+ '--include-partial-messages',
743
843
  '--permission-mode', 'acceptEdits',
744
844
  '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
745
845
  // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
@@ -755,24 +855,58 @@ function runAiChat(cfg, cmd, repoPath) {
755
855
  let pending = []
756
856
  let capturedSession = null // Claude's session id, sent (idempotently) for --resume
757
857
  let flushing = false
858
+ let liveText = '' // in-progress text of the current assistant block (live preview)
859
+ let sentLive = '' // last streamingText we posted — so we only push on change
860
+ let lastPostAt = 0 // Date.now() of the last post, for the keep-alive heartbeat
758
861
  const flush = async () => {
759
- if (flushing || pending.length === 0) return
862
+ if (flushing) return
863
+ const hasEvents = pending.length > 0
864
+ const liveChanged = liveText !== sentLive
865
+ // Heartbeat: with nothing new for a while, still post (bumps lastActivityAt)
866
+ // so the console shows honest "still working" and can spot a real stall.
867
+ const heartbeat = !hasEvents && !liveChanged && Date.now() - lastPostAt > 2500
868
+ if (!hasEvents && !liveChanged && !heartbeat) return
760
869
  flushing = true
761
870
  const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
762
- await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined)
871
+ const streamingText = liveChanged ? liveText : undefined
872
+ sentLive = liveText
873
+ await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined, streamingText)
874
+ lastPostAt = Date.now()
763
875
  flushing = false
764
876
  }
765
- const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
877
+ const push = (kind, text) => {
878
+ if (text == null || String(text) === '') return
879
+ // A completed text block becomes a real event — drop its live preview so the
880
+ // finalised bubble and the cleared preview swap in on the same flush.
881
+ if (kind === 'TEXT') liveText = ''
882
+ pending.push({ kind, text: String(text) })
883
+ }
766
884
  const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
767
- const timer = setInterval(flush, 800)
885
+ const onDelta = (chunk) => { liveText += chunk }
886
+ const timer = setInterval(flush, 500)
887
+
888
+ // Fetch any attached images locally and fold their paths into the prompt so
889
+ // Claude reads them with its Read tool (URLs on stdin don't render).
890
+ let imgDir = null
891
+ let fullPrompt = prompt
892
+ if (images.length > 0) {
893
+ const dl = await downloadSessionImages(sessionId, images)
894
+ imgDir = dl.dir
895
+ if (dl.paths.length > 0) {
896
+ const list = dl.paths.map((p) => `- ${p}`).join('\n')
897
+ const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
898
+ fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
899
+ }
900
+ }
768
901
 
769
- log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
902
+ log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}, images=${images.length}) via ${claudePath}`)
770
903
 
771
904
  let child
772
905
  try {
773
906
  child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
774
907
  } catch (err) {
775
908
  clearInterval(timer)
909
+ if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
776
910
  postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
777
911
  reportCommandResult(cfg, cmd.id, 'error', err.message)
778
912
  return
@@ -780,7 +914,7 @@ function runAiChat(cfg, cmd, repoPath) {
780
914
 
781
915
  if (child.stdin) {
782
916
  child.stdin.on('error', () => {})
783
- try { child.stdin.write(prompt); child.stdin.end() }
917
+ try { child.stdin.write(fullPrompt); child.stdin.end() }
784
918
  catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
785
919
  }
786
920
 
@@ -791,14 +925,16 @@ function runAiChat(cfg, cmd, repoPath) {
791
925
  while ((nl = buf.indexOf('\n')) >= 0) {
792
926
  const line = buf.slice(0, nl).trim()
793
927
  buf = buf.slice(nl + 1)
794
- if (line) parseStreamLine(line, push, onInit)
928
+ if (line) parseStreamLine(line, push, onInit, onDelta)
795
929
  }
796
930
  })
797
931
  child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
798
932
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
799
933
  child.on('close', async (code) => {
800
934
  clearInterval(timer)
801
- if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
935
+ if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
936
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta)
937
+ liveText = '' // turn is over — drop any lingering live preview
802
938
  await flush()
803
939
  const ok = code === 0
804
940
  await postSessionEvents(
@@ -806,6 +942,7 @@ function runAiChat(cfg, cmd, repoPath) {
806
942
  ok ? [] : [{ role: 'SYSTEM', text: `✗ Ходът приключи с код ${code}.` }],
807
943
  ok ? 'idle' : 'error',
808
944
  capturedSession || undefined,
945
+ '', // clear the live preview on the terminal post
809
946
  )
810
947
  reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
811
948
  log(`■ ai_chat ${sessionId} приключи (code ${code})`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
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": {