gitdone-agent 0.6.5 → 0.6.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.
Files changed (2) hide show
  1. package/index.js +110 -12
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -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.5'
30
+ const AGENT_VERSION = '0.6.6'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -403,6 +403,42 @@ function decodeDiffText(buf) {
403
403
  }
404
404
  }
405
405
 
406
+ // Git C-quotes paths with spaces / special chars / non-ASCII bytes, wrapping them
407
+ // in double quotes with backslash escapes (`"export coca_cola/x.cs"`, octal `\NNN`
408
+ // for raw bytes). Both `git status --porcelain` and the `diff --git` header do it.
409
+ // Left unhandled, files with spaces (or Cyrillic) got mis-keyed and their diff was
410
+ // dropped (gd-321). This reverses it back to a plain path so the diff key matches
411
+ // the file name shown in the list.
412
+ function unquoteGitPath(s) {
413
+ if (typeof s !== 'string' || s.length < 2 || s[0] !== '"' || s[s.length - 1] !== '"') return s
414
+ const body = s.slice(1, -1)
415
+ const bytes = []
416
+ for (let i = 0; i < body.length; i++) {
417
+ if (body[i] === '\\' && i + 1 < body.length) {
418
+ const n = body[i + 1]
419
+ if (n === 'n') { bytes.push(10); i++ }
420
+ else if (n === 't') { bytes.push(9); i++ }
421
+ else if (n === 'r') { bytes.push(13); i++ }
422
+ else if (n === '"') { bytes.push(34); i++ }
423
+ else if (n === '\\') { bytes.push(92); i++ }
424
+ else if (n >= '0' && n <= '7') { bytes.push(parseInt(body.substr(i + 1, 3), 8) & 0xff); i += 3 }
425
+ else { bytes.push(body.charCodeAt(i)) }
426
+ } else {
427
+ bytes.push(body.charCodeAt(i) & 0xff)
428
+ }
429
+ }
430
+ return Buffer.from(bytes).toString('utf8')
431
+ }
432
+
433
+ // Read the b-side path from a `diff --git` header line, handling git's quoting
434
+ // of paths with spaces / special chars (gd-321).
435
+ function diffHeaderPath(hdr) {
436
+ const q = hdr.match(/ ("b\/.*")$/) // quoted: "a/x" "b/x"
437
+ if (q) return unquoteGitPath(q[1]).replace(/^b\//, '')
438
+ const u = hdr.match(/ b\/(.*)$/) // plain: a/x b/x
439
+ return u ? u[1] : null
440
+ }
441
+
406
442
  function parseDiffByFile(diffBuf) {
407
443
  const files = {}
408
444
  if (!diffBuf || diffBuf.length === 0) return files
@@ -413,10 +449,11 @@ function parseDiffByFile(diffBuf) {
413
449
  for (const section of sections) {
414
450
  if (!section.trim()) continue
415
451
  // Decode THIS file's bytes on their own (UTF-8 or CP1251), then read the
416
- // filename from the decoded header.
452
+ // filename from the decoded header (quoted or not).
417
453
  const text = decodeDiffText(Buffer.from(section, 'latin1'))
418
- const match = text.match(/^diff --git a\/.+ b\/(.+)$/m)
419
- if (match) files[match[1]] = text
454
+ const hdr = text.match(/^diff --git (.+)$/m)
455
+ const name = hdr ? diffHeaderPath(hdr[1]) : null
456
+ if (name) files[name] = text
420
457
  }
421
458
  return files
422
459
  }
@@ -430,7 +467,7 @@ function getSnapshot(repoPath) {
430
467
  const statuses = {}
431
468
  for (const line of statusLines) {
432
469
  const xy = line.slice(0, 2)
433
- const file = line.slice(3)
470
+ const file = unquoteGitPath(line.slice(3))
434
471
  statuses[file] = xy
435
472
  if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
436
473
  if (xy[1] !== ' ') modified.push(file)
@@ -510,12 +547,15 @@ async function reportCommandResult(cfg, id, status, result) {
510
547
  }
511
548
 
512
549
  // Post a batch of console events (and optional lifecycle status) for an AiRun.
513
- async function postRunEvents(cfg, runId, events, status, result) {
550
+ // `usage` is sent once on the final (done/error) post so the server can record
551
+ // how many tokens the run cost.
552
+ async function postRunEvents(cfg, runId, events, status, result, usage) {
514
553
  await api(cfg, '/api/v1/agent/ai-run/events', {
515
554
  runId,
516
555
  events,
517
556
  ...(status ? { status } : {}),
518
557
  ...(result !== undefined ? { result } : {}),
558
+ ...(usage ? { usage } : {}),
519
559
  }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
520
560
  }
521
561
 
@@ -538,7 +578,15 @@ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId
538
578
  // With `--include-partial-messages`, Claude also emits `stream_event` lines
539
579
  // carrying incremental text deltas — onDelta gets those so chat sessions can
540
580
  // show the reply being written live (gd-302).
541
- function parseStreamLine(line, push, onInit, onDelta) {
581
+ // "12.3k" / "1.2M" compaction for the console token summary line.
582
+ function fmtTokens(n) {
583
+ n = Number(n) || 0
584
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
585
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
586
+ return String(n)
587
+ }
588
+
589
+ function parseStreamLine(line, push, onInit, onDelta, onMeta) {
542
590
  let ev
543
591
  try { ev = JSON.parse(line) } catch { return }
544
592
  if (ev.type === 'system') {
@@ -546,6 +594,8 @@ function parseStreamLine(line, push, onInit, onDelta) {
546
594
  push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
547
595
  // Surface Claude's own session id so chat sessions can --resume it.
548
596
  if (ev.session_id && typeof onInit === 'function') onInit(ev.session_id)
597
+ // Remember which model ran, for the token/cost record (gd-334).
598
+ if (ev.model && typeof onMeta === 'function') onMeta({ model: ev.model })
549
599
  }
550
600
  return
551
601
  }
@@ -570,8 +620,40 @@ function parseStreamLine(line, push, onInit, onDelta) {
570
620
  }
571
621
  return
572
622
  }
573
- if (ev.type === 'result' && ev.subtype && ev.subtype !== 'success') {
574
- push('SYSTEM', `Резултат: ${ev.subtype}`)
623
+ if (ev.type === 'result') {
624
+ if (ev.subtype && ev.subtype !== 'success') push('SYSTEM', `Резултат: ${ev.subtype}`)
625
+ // claude's final result carries cumulative token usage + its own cost.
626
+ // Prefer `modelUsage` (summed over every model/subagent turn) which is the
627
+ // true cumulative; top-level `usage` is often just the last turn. Fall back
628
+ // to `usage` when modelUsage is absent (older CLI).
629
+ if (typeof onMeta === 'function' && (ev.modelUsage || ev.usage || typeof ev.total_cost_usd === 'number')) {
630
+ let usage
631
+ let model
632
+ if (ev.modelUsage && typeof ev.modelUsage === 'object') {
633
+ const acc = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 }
634
+ for (const [name, mu] of Object.entries(ev.modelUsage)) {
635
+ if (!model) model = name
636
+ acc.inputTokens += mu.inputTokens ?? mu.input_tokens ?? 0
637
+ acc.outputTokens += mu.outputTokens ?? mu.output_tokens ?? 0
638
+ acc.cacheReadTokens += mu.cacheReadInputTokens ?? mu.cache_read_input_tokens ?? 0
639
+ acc.cacheCreateTokens += mu.cacheCreationInputTokens ?? mu.cache_creation_input_tokens ?? 0
640
+ }
641
+ usage = acc
642
+ } else if (ev.usage) {
643
+ const u = ev.usage
644
+ usage = {
645
+ inputTokens: u.input_tokens ?? 0,
646
+ outputTokens: u.output_tokens ?? 0,
647
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
648
+ cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
649
+ }
650
+ }
651
+ onMeta({
652
+ ...(usage ? { usage } : {}),
653
+ ...(model ? { model } : {}),
654
+ costUsd: typeof ev.total_cost_usd === 'number' ? ev.total_cost_usd : undefined,
655
+ })
656
+ }
575
657
  }
576
658
  }
577
659
 
@@ -762,6 +844,15 @@ function runAiCommand(cfg, cmd, repoPath) {
762
844
  const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
763
845
  const timer = setInterval(flush, 800)
764
846
 
847
+ // Accumulate model + token usage across the stream (model from init, usage
848
+ // from the final result event) to report once on exit (gd-334).
849
+ const meta = { model: undefined, usage: undefined, costUsd: undefined }
850
+ const onMeta = (m) => {
851
+ if (m.model) meta.model = m.model
852
+ if (m.usage) meta.usage = m.usage
853
+ if (typeof m.costUsd === 'number') meta.costUsd = m.costUsd
854
+ }
855
+
765
856
  log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
766
857
  postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
767
858
 
@@ -790,21 +881,28 @@ function runAiCommand(cfg, cmd, repoPath) {
790
881
  while ((nl = buf.indexOf('\n')) >= 0) {
791
882
  const line = buf.slice(0, nl).trim()
792
883
  buf = buf.slice(nl + 1)
793
- if (line) parseStreamLine(line, push)
884
+ if (line) parseStreamLine(line, push, undefined, undefined, onMeta)
794
885
  }
795
886
  })
796
887
  child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
797
888
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
798
889
  child.on('close', async (code) => {
799
890
  clearInterval(timer)
800
- if (buf.trim()) parseStreamLine(buf.trim(), push)
891
+ if (buf.trim()) parseStreamLine(buf.trim(), push, undefined, undefined, onMeta)
801
892
  await flush()
802
893
  const ok = code === 0
894
+ // Build the usage payload + a console summary line from what we saw.
895
+ const usage = meta.usage
896
+ ? { model: meta.model, ...meta.usage, ...(typeof meta.costUsd === 'number' ? { costUsd: meta.costUsd } : {}) }
897
+ : undefined
898
+ const events = [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }]
899
+ if (usage) events.push({ kind: 'SYSTEM', text: `📊 Токени: ${fmtTokens(usage.inputTokens)} вход · ${fmtTokens(usage.outputTokens)} изход${usage.cacheReadTokens ? ` · ${fmtTokens(usage.cacheReadTokens)} кеш` : ''}${typeof usage.costUsd === 'number' ? ` · $${usage.costUsd.toFixed(4)}` : ''}` })
803
900
  await postRunEvents(
804
901
  cfg, runId,
805
- [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }],
902
+ events,
806
903
  ok ? 'done' : 'error',
807
904
  ok ? 'ok' : `exit ${code}`,
905
+ usage,
808
906
  )
809
907
  reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
810
908
  log(`■ ai_run ${runId} приключи (code ${code})`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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": {