gitdone-agent 0.3.1 → 0.5.1

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 +211 -1
  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.3.1'
30
+ const AGENT_VERSION = '0.5.1'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -107,6 +107,25 @@ function getNodePath() {
107
107
  return process.execPath
108
108
  }
109
109
 
110
+ // Locate the Claude Code CLI. Prefer a real executable (claude.exe from the
111
+ // native installer) so spawn works WITHOUT a shell — that lets us pass a
112
+ // multi-line, non-ASCII prompt as a single argv element with no escaping. An
113
+ // npm shim (claude.cmd) needs shell:true, where we collapse the prompt to one
114
+ // line to survive cmd.exe parsing.
115
+ function findClaude() {
116
+ const tryCmd = (c) => {
117
+ try {
118
+ const out = execSync(c, { encoding: 'utf8' }).trim()
119
+ return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
120
+ } catch { return [] }
121
+ }
122
+ const cands = [...tryCmd('where claude'), ...tryCmd('which claude')]
123
+ const exe = cands.find((p) => /\.exe$/i.test(p))
124
+ if (exe) return { path: exe, shell: false }
125
+ if (cands.length) return { path: cands[0], shell: true }
126
+ return { path: 'claude', shell: true }
127
+ }
128
+
110
129
  // Kill any previously-running agent processes so an update applies WITHOUT a
111
130
  // Windows re-login. Targets node processes launched from the agent dir
112
131
  // (~/.gitdone-agent), excluding ourselves — the npx installer runs from the
@@ -342,7 +361,198 @@ async function reportCommandResult(cfg, id, status, result) {
342
361
  await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
343
362
  }
344
363
 
364
+ // Post a batch of console events (and optional lifecycle status) for an AiRun.
365
+ async function postRunEvents(cfg, runId, events, status, result) {
366
+ await api(cfg, '/api/v1/agent/ai-run/events', {
367
+ runId,
368
+ events,
369
+ ...(status ? { status } : {}),
370
+ ...(result !== undefined ? { result } : {}),
371
+ }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
372
+ }
373
+
374
+ // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
375
+ // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
376
+ // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
377
+ function parseStreamLine(line, push) {
378
+ let ev
379
+ try { ev = JSON.parse(line) } catch { return }
380
+ if (ev.type === 'system') {
381
+ if (ev.subtype === 'init') push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
382
+ return
383
+ }
384
+ if (ev.type === 'assistant' && ev.message?.content) {
385
+ for (const block of ev.message.content) {
386
+ if (block.type === 'text' && block.text?.trim()) {
387
+ push('TEXT', block.text.trim())
388
+ } else if (block.type === 'tool_use') {
389
+ const input = block.input ? JSON.stringify(block.input) : ''
390
+ push('TOOL_CALL', `${block.name}${input ? ` ${input.slice(0, 400)}` : ''}`)
391
+ }
392
+ }
393
+ return
394
+ }
395
+ if (ev.type === 'result' && ev.subtype && ev.subtype !== 'success') {
396
+ push('SYSTEM', `Резултат: ${ev.subtype}`)
397
+ }
398
+ }
399
+
400
+ // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
401
+ // (Bash stdout, edit results, …) to the run's console, plus the settings file
402
+ // that registers it. The hook reads GITDONE_* from its env (set per run on the
403
+ // claude process). Returns the settings path to pass via --settings.
404
+ const AI_HOOK_SCRIPT = join(AGENT_DIR, 'ai-run-hook.mjs')
405
+ const AI_SETTINGS_PATH = join(AGENT_DIR, 'ai-run-settings.json')
406
+
407
+ function ensureAiHookFiles() {
408
+ ensureAgentDir()
409
+ const hookSrc = [
410
+ "import process from 'node:process'",
411
+ "let data = ''",
412
+ "process.stdin.setEncoding('utf8')",
413
+ "process.stdin.on('data', (c) => { data += c })",
414
+ 'process.stdin.on(\'end\', async () => {',
415
+ ' try {',
416
+ " const ev = JSON.parse(data || '{}')",
417
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, runId = process.env.GITDONE_RUN_ID',
418
+ ' if (url && key && runId) {',
419
+ " const name = ev.tool_name || 'tool'",
420
+ ' const o = ev.tool_output',
421
+ " let out = ''",
422
+ " if (typeof o === 'string') out = o",
423
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
424
+ " out = String(out || '').slice(0, 2000)",
425
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
426
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
427
+ " await fetch(url + '/api/v1/agent/ai-run/events', {",
428
+ " method: 'POST',",
429
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
430
+ " body: JSON.stringify({ runId, events: [{ kind: 'TOOL_RESULT', text }] }),",
431
+ ' }).catch(() => {})',
432
+ ' }',
433
+ ' } catch {}',
434
+ ' process.exit(0)',
435
+ '})',
436
+ ].join('\n')
437
+ writeFileSync(AI_HOOK_SCRIPT, hookSrc, 'utf8')
438
+
439
+ const nodePath = getNodePath()
440
+ const settings = {
441
+ hooks: {
442
+ PostToolUse: [
443
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_HOOK_SCRIPT}"` }] },
444
+ ],
445
+ },
446
+ }
447
+ writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
448
+ return AI_SETTINGS_PATH
449
+ }
450
+
451
+ // Run a headless Claude Code session for an `ai_run` command and stream its
452
+ // output back. Long-running and fire-and-forget: it wires up async handlers and
453
+ // returns immediately so the agent's snapshot loop is never blocked.
454
+ function runAiCommand(cfg, cmd, repoPath) {
455
+ const runId = cmd.payload?.runId
456
+ const prompt = cmd.payload?.prompt ?? ''
457
+ if (!runId || !prompt) {
458
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
459
+ return
460
+ }
461
+
462
+ const { path: claudePath, shell } = findClaude()
463
+
464
+ let settingsPath
465
+ try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
466
+
467
+ // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
468
+ // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
469
+ // mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
470
+ // it), so Claude received no prompt and fell back to an empty stdin
471
+ // ("no stdin data received…"), then ran blind off whatever was in the repo.
472
+ // Piping it to stdin is robust for both the native exe and the shim.
473
+ const args = [
474
+ '-p',
475
+ '--output-format', 'stream-json',
476
+ '--verbose',
477
+ '--permission-mode', 'acceptEdits',
478
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
479
+ '--disallowedTools', 'Bash(git commit *),Bash(git push *)',
480
+ ...(settingsPath ? ['--settings', settingsPath] : []),
481
+ ]
482
+
483
+ // The PostToolUse hook reads these from its env to post raw tool output.
484
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
485
+
486
+ // Batch events on a timer so we don't hammer the server per token/line.
487
+ let pending = []
488
+ let flushing = false
489
+ const flush = async () => {
490
+ if (flushing || pending.length === 0) return
491
+ flushing = true
492
+ const batch = pending; pending = []
493
+ await postRunEvents(cfg, runId, batch)
494
+ flushing = false
495
+ }
496
+ const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
497
+ const timer = setInterval(flush, 800)
498
+
499
+ log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
500
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
501
+
502
+ let child
503
+ try {
504
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
505
+ } catch (err) {
506
+ clearInterval(timer)
507
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
508
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
509
+ return
510
+ }
511
+
512
+ // Write the prompt to stdin and close it so Claude reads it as the task.
513
+ // Swallow EPIPE in case the process exits before we finish writing.
514
+ if (child.stdin) {
515
+ child.stdin.on('error', () => {})
516
+ try { child.stdin.write(prompt); child.stdin.end() }
517
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
518
+ }
519
+
520
+ let buf = ''
521
+ child.stdout.on('data', (d) => {
522
+ buf += d.toString()
523
+ let nl
524
+ while ((nl = buf.indexOf('\n')) >= 0) {
525
+ const line = buf.slice(0, nl).trim()
526
+ buf = buf.slice(nl + 1)
527
+ if (line) parseStreamLine(line, push)
528
+ }
529
+ })
530
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
531
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
532
+ child.on('close', async (code) => {
533
+ clearInterval(timer)
534
+ if (buf.trim()) parseStreamLine(buf.trim(), push)
535
+ await flush()
536
+ const ok = code === 0
537
+ await postRunEvents(
538
+ cfg, runId,
539
+ [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }],
540
+ ok ? 'done' : 'error',
541
+ ok ? 'ok' : `exit ${code}`,
542
+ )
543
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
544
+ log(`■ ai_run ${runId} приключи (code ${code})`)
545
+ })
546
+ }
547
+
345
548
  async function executeCommand(cfg, cmd, repoPath) {
549
+ // ai_run is long-running + streaming — launch it in the background and return
550
+ // so the snapshot loop keeps going. It reports its own result on exit.
551
+ if (cmd.type === 'ai_run') {
552
+ runAiCommand(cfg, cmd, repoPath)
553
+ return
554
+ }
555
+
346
556
  let result = 'ok'
347
557
  let status = 'done'
348
558
  const auth = cfg.auth?.[repoPath] ?? null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.3.1",
3
+ "version": "0.5.1",
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": {