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.
- package/index.js +158 -21
- 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.
|
|
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
|
-
|
|
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 (!
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
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
|
|
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(
|
|
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 (
|
|
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})`)
|