gitdone-agent 0.3.1 → 0.5.0

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 +199 -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.0'
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,186 @@ 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
+ // shell:true (claude.cmd) can't carry raw newlines on the command line.
464
+ const promptArg = shell ? prompt.replace(/\r?\n/g, ' ') : prompt
465
+
466
+ let settingsPath
467
+ try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
468
+
469
+ const args = [
470
+ '-p', promptArg,
471
+ '--output-format', 'stream-json',
472
+ '--verbose',
473
+ '--permission-mode', 'acceptEdits',
474
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
475
+ '--disallowedTools', 'Bash(git commit *),Bash(git push *)',
476
+ ...(settingsPath ? ['--settings', settingsPath] : []),
477
+ ]
478
+
479
+ // The PostToolUse hook reads these from its env to post raw tool output.
480
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
481
+
482
+ // Batch events on a timer so we don't hammer the server per token/line.
483
+ let pending = []
484
+ let flushing = false
485
+ const flush = async () => {
486
+ if (flushing || pending.length === 0) return
487
+ flushing = true
488
+ const batch = pending; pending = []
489
+ await postRunEvents(cfg, runId, batch)
490
+ flushing = false
491
+ }
492
+ const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
493
+ const timer = setInterval(flush, 800)
494
+
495
+ log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
496
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
497
+
498
+ let child
499
+ try {
500
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
501
+ } catch (err) {
502
+ clearInterval(timer)
503
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
504
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
505
+ return
506
+ }
507
+
508
+ let buf = ''
509
+ child.stdout.on('data', (d) => {
510
+ buf += d.toString()
511
+ let nl
512
+ while ((nl = buf.indexOf('\n')) >= 0) {
513
+ const line = buf.slice(0, nl).trim()
514
+ buf = buf.slice(nl + 1)
515
+ if (line) parseStreamLine(line, push)
516
+ }
517
+ })
518
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
519
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
520
+ child.on('close', async (code) => {
521
+ clearInterval(timer)
522
+ if (buf.trim()) parseStreamLine(buf.trim(), push)
523
+ await flush()
524
+ const ok = code === 0
525
+ await postRunEvents(
526
+ cfg, runId,
527
+ [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }],
528
+ ok ? 'done' : 'error',
529
+ ok ? 'ok' : `exit ${code}`,
530
+ )
531
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
532
+ log(`■ ai_run ${runId} приключи (code ${code})`)
533
+ })
534
+ }
535
+
345
536
  async function executeCommand(cfg, cmd, repoPath) {
537
+ // ai_run is long-running + streaming — launch it in the background and return
538
+ // so the snapshot loop keeps going. It reports its own result on exit.
539
+ if (cmd.type === 'ai_run') {
540
+ runAiCommand(cfg, cmd, repoPath)
541
+ return
542
+ }
543
+
346
544
  let result = 'ok'
347
545
  let status = 'done'
348
546
  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.0",
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": {