gitdone-agent 0.5.0 → 0.5.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 +184 -7
  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.5.0'
30
+ const AGENT_VERSION = '0.5.2'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -371,14 +371,29 @@ async function postRunEvents(cfg, runId, events, status, result) {
371
371
  }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
372
372
  }
373
373
 
374
+ // Post transcript lines (and optional turn status / Claude session id) for an
375
+ // interactive AiSession chat turn.
376
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId) {
377
+ await api(cfg, '/api/v1/agent/ai-session/events', {
378
+ sessionId,
379
+ events,
380
+ ...(status ? { status } : {}),
381
+ ...(claudeSessionId ? { claudeSessionId } : {}),
382
+ }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
383
+ }
384
+
374
385
  // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
375
386
  // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
376
387
  // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
377
- function parseStreamLine(line, push) {
388
+ function parseStreamLine(line, push, onInit) {
378
389
  let ev
379
390
  try { ev = JSON.parse(line) } catch { return }
380
391
  if (ev.type === 'system') {
381
- if (ev.subtype === 'init') push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
392
+ if (ev.subtype === 'init') {
393
+ push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
394
+ // Surface Claude's own session id so chat sessions can --resume it.
395
+ if (ev.session_id && typeof onInit === 'function') onInit(ev.session_id)
396
+ }
382
397
  return
383
398
  }
384
399
  if (ev.type === 'assistant' && ev.message?.content) {
@@ -448,31 +463,86 @@ function ensureAiHookFiles() {
448
463
  return AI_SETTINGS_PATH
449
464
  }
450
465
 
466
+ // Same PostToolUse hook, for interactive chat sessions: posts raw tool output to
467
+ // the session endpoint, reading GITDONE_SESSION_ID from the per-turn env.
468
+ const AI_SESSION_HOOK_SCRIPT = join(AGENT_DIR, 'ai-session-hook.mjs')
469
+ const AI_SESSION_SETTINGS_PATH = join(AGENT_DIR, 'ai-session-settings.json')
470
+
471
+ function ensureAiSessionHookFiles() {
472
+ ensureAgentDir()
473
+ const hookSrc = [
474
+ "import process from 'node:process'",
475
+ "let data = ''",
476
+ "process.stdin.setEncoding('utf8')",
477
+ "process.stdin.on('data', (c) => { data += c })",
478
+ 'process.stdin.on(\'end\', async () => {',
479
+ ' try {',
480
+ " const ev = JSON.parse(data || '{}')",
481
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, sessionId = process.env.GITDONE_SESSION_ID',
482
+ ' if (url && key && sessionId) {',
483
+ " const name = ev.tool_name || 'tool'",
484
+ ' const o = ev.tool_output',
485
+ " let out = ''",
486
+ " if (typeof o === 'string') out = o",
487
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
488
+ " out = String(out || '').slice(0, 2000)",
489
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
490
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
491
+ " await fetch(url + '/api/v1/agent/ai-session/events', {",
492
+ " method: 'POST',",
493
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
494
+ " body: JSON.stringify({ sessionId, events: [{ role: 'TOOL_RESULT', text }] }),",
495
+ ' }).catch(() => {})',
496
+ ' }',
497
+ ' } catch {}',
498
+ ' process.exit(0)',
499
+ '})',
500
+ ].join('\n')
501
+ writeFileSync(AI_SESSION_HOOK_SCRIPT, hookSrc, 'utf8')
502
+
503
+ const nodePath = getNodePath()
504
+ const settings = {
505
+ hooks: {
506
+ PostToolUse: [
507
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_SESSION_HOOK_SCRIPT}"` }] },
508
+ ],
509
+ },
510
+ }
511
+ writeFileSync(AI_SESSION_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
512
+ return AI_SESSION_SETTINGS_PATH
513
+ }
514
+
451
515
  // Run a headless Claude Code session for an `ai_run` command and stream its
452
516
  // output back. Long-running and fire-and-forget: it wires up async handlers and
453
517
  // returns immediately so the agent's snapshot loop is never blocked.
454
518
  function runAiCommand(cfg, cmd, repoPath) {
455
519
  const runId = cmd.payload?.runId
456
520
  const prompt = cmd.payload?.prompt ?? ''
521
+ const allowCommit = cmd.payload?.allowCommit === true
457
522
  if (!runId || !prompt) {
458
523
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
459
524
  return
460
525
  }
461
526
 
462
527
  const { path: claudePath, shell } = findClaude()
463
- // shell:true (claude.cmd) can't carry raw newlines on the command line.
464
- const promptArg = shell ? prompt.replace(/\r?\n/g, ' ') : prompt
465
528
 
466
529
  let settingsPath
467
530
  try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
468
531
 
532
+ // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
533
+ // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
534
+ // mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
535
+ // it), so Claude received no prompt and fell back to an empty stdin
536
+ // ("no stdin data received…"), then ran blind off whatever was in the repo.
537
+ // Piping it to stdin is robust for both the native exe and the shim.
469
538
  const args = [
470
- '-p', promptArg,
539
+ '-p',
471
540
  '--output-format', 'stream-json',
472
541
  '--verbose',
473
542
  '--permission-mode', 'acceptEdits',
474
543
  '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
475
- '--disallowedTools', 'Bash(git commit *),Bash(git push *)',
544
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
545
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
476
546
  ...(settingsPath ? ['--settings', settingsPath] : []),
477
547
  ]
478
548
 
@@ -505,6 +575,14 @@ function runAiCommand(cfg, cmd, repoPath) {
505
575
  return
506
576
  }
507
577
 
578
+ // Write the prompt to stdin and close it so Claude reads it as the task.
579
+ // Swallow EPIPE in case the process exits before we finish writing.
580
+ if (child.stdin) {
581
+ child.stdin.on('error', () => {})
582
+ try { child.stdin.write(prompt); child.stdin.end() }
583
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
584
+ }
585
+
508
586
  let buf = ''
509
587
  child.stdout.on('data', (d) => {
510
588
  buf += d.toString()
@@ -533,6 +611,100 @@ function runAiCommand(cfg, cmd, repoPath) {
533
611
  })
534
612
  }
535
613
 
614
+ // Run one turn of an interactive chat session: `claude -p` (resuming the prior
615
+ // Claude session when known) with the user's message on stdin, streaming the
616
+ // reply back to the session console. Long-running + fire-and-forget like ai_run.
617
+ function runAiChat(cfg, cmd, repoPath) {
618
+ const sessionId = cmd.payload?.sessionId
619
+ const prompt = cmd.payload?.prompt ?? ''
620
+ const claudeSessionId = cmd.payload?.claudeSessionId || null
621
+ const allowCommit = cmd.payload?.allowCommit === true
622
+ if (!sessionId || !prompt) {
623
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
624
+ return
625
+ }
626
+
627
+ const { path: claudePath, shell } = findClaude()
628
+
629
+ let settingsPath
630
+ try { settingsPath = ensureAiSessionHookFiles() } catch (e) { log(`✗ ai session hook setup failed: ${e.message}`) }
631
+
632
+ const args = [
633
+ '-p',
634
+ '--output-format', 'stream-json',
635
+ '--verbose',
636
+ '--permission-mode', 'acceptEdits',
637
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
638
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
639
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
640
+ ...(claudeSessionId ? ['--resume', claudeSessionId] : []),
641
+ ...(settingsPath ? ['--settings', settingsPath] : []),
642
+ ]
643
+
644
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
645
+
646
+ // Map console kinds → session transcript roles (model text → ASSISTANT).
647
+ const roleFor = (kind) => (kind === 'TEXT' ? 'ASSISTANT' : kind)
648
+ let pending = []
649
+ let capturedSession = null // Claude's session id, sent (idempotently) for --resume
650
+ let flushing = false
651
+ const flush = async () => {
652
+ if (flushing || pending.length === 0) return
653
+ flushing = true
654
+ const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
655
+ await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined)
656
+ flushing = false
657
+ }
658
+ const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
659
+ const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
660
+ const timer = setInterval(flush, 800)
661
+
662
+ log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
663
+
664
+ let child
665
+ try {
666
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
667
+ } catch (err) {
668
+ clearInterval(timer)
669
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
670
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
671
+ return
672
+ }
673
+
674
+ if (child.stdin) {
675
+ child.stdin.on('error', () => {})
676
+ try { child.stdin.write(prompt); child.stdin.end() }
677
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
678
+ }
679
+
680
+ let buf = ''
681
+ child.stdout.on('data', (d) => {
682
+ buf += d.toString()
683
+ let nl
684
+ while ((nl = buf.indexOf('\n')) >= 0) {
685
+ const line = buf.slice(0, nl).trim()
686
+ buf = buf.slice(nl + 1)
687
+ if (line) parseStreamLine(line, push, onInit)
688
+ }
689
+ })
690
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
691
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
692
+ child.on('close', async (code) => {
693
+ clearInterval(timer)
694
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
695
+ await flush()
696
+ const ok = code === 0
697
+ await postSessionEvents(
698
+ cfg, sessionId,
699
+ ok ? [] : [{ role: 'SYSTEM', text: `✗ Ходът приключи с код ${code}.` }],
700
+ ok ? 'idle' : 'error',
701
+ capturedSession || undefined,
702
+ )
703
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
704
+ log(`■ ai_chat ${sessionId} приключи (code ${code})`)
705
+ })
706
+ }
707
+
536
708
  async function executeCommand(cfg, cmd, repoPath) {
537
709
  // ai_run is long-running + streaming — launch it in the background and return
538
710
  // so the snapshot loop keeps going. It reports its own result on exit.
@@ -540,6 +712,11 @@ async function executeCommand(cfg, cmd, repoPath) {
540
712
  runAiCommand(cfg, cmd, repoPath)
541
713
  return
542
714
  }
715
+ // ai_chat — one interactive turn, same fire-and-forget model.
716
+ if (cmd.type === 'ai_chat') {
717
+ runAiChat(cfg, cmd, repoPath)
718
+ return
719
+ }
543
720
 
544
721
  let result = 'ok'
545
722
  let status = 'done'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.5.0",
3
+ "version": "0.5.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": {