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.
- package/index.js +199 -1
- 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.
|
|
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
|