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.
- package/index.js +184 -7
- 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.
|
|
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')
|
|
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',
|
|
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
|
-
|
|
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'
|