gitdone-agent 0.2.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 +291 -6
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -24,6 +24,11 @@ import { randomUUID } from 'node:crypto'
|
|
|
24
24
|
// truth for the running agent), and a log file (so failures are visible even
|
|
25
25
|
// when the agent runs in a hidden window).
|
|
26
26
|
|
|
27
|
+
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
28
|
+
// Keep in lockstep with packages/agent/package.json "version" AND
|
|
29
|
+
// src/lib/agentVersion.ts LATEST_AGENT_VERSION.
|
|
30
|
+
const AGENT_VERSION = '0.5.0'
|
|
31
|
+
|
|
27
32
|
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
28
33
|
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
29
34
|
const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
|
|
@@ -102,10 +107,52 @@ function getNodePath() {
|
|
|
102
107
|
return process.execPath
|
|
103
108
|
}
|
|
104
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
|
+
|
|
129
|
+
// Kill any previously-running agent processes so an update applies WITHOUT a
|
|
130
|
+
// Windows re-login. Targets node processes launched from the agent dir
|
|
131
|
+
// (~/.gitdone-agent), excluding ourselves — the npx installer runs from the
|
|
132
|
+
// npx cache, so it won't match. Best-effort; failures are non-fatal.
|
|
133
|
+
function stopRunningAgents() {
|
|
134
|
+
const ps = [
|
|
135
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
136
|
+
"Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" |",
|
|
137
|
+
` Where-Object { $_.CommandLine -like '*.gitdone-agent*' -and $_.ProcessId -ne ${process.pid} } |`,
|
|
138
|
+
' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
|
|
139
|
+
].join('\n')
|
|
140
|
+
try {
|
|
141
|
+
ensureAgentDir()
|
|
142
|
+
const scriptPath = join(AGENT_DIR, 'stop-agents.ps1')
|
|
143
|
+
writeFileSync(scriptPath, ps, 'utf8')
|
|
144
|
+
execSync(`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, { stdio: 'ignore' })
|
|
145
|
+
} catch { /* best-effort — at worst the old agent lingers until next login */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
function installStartup() {
|
|
106
149
|
ensureAgentDir()
|
|
107
150
|
const nodePath = getNodePath()
|
|
108
151
|
|
|
152
|
+
// Stop the previously-running agent(s) first so we don't end up with the old
|
|
153
|
+
// build still reporting alongside the new one until the next Windows login.
|
|
154
|
+
stopRunningAgents()
|
|
155
|
+
|
|
109
156
|
// Copy this script to a stable location. process.argv[1] may point inside an
|
|
110
157
|
// npx cache dir that gets purged later — referencing it from autostart would
|
|
111
158
|
// silently break on the next login. A stable copy survives.
|
|
@@ -171,6 +218,49 @@ function git(cmd, cwd) {
|
|
|
171
218
|
}
|
|
172
219
|
}
|
|
173
220
|
|
|
221
|
+
// Like git(), but surfaces failures (with stderr) instead of swallowing them —
|
|
222
|
+
// used for push/pull where we need to detect auth rejection.
|
|
223
|
+
function gitTry(cmd, cwd) {
|
|
224
|
+
try {
|
|
225
|
+
const out = execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
226
|
+
return { ok: true, out }
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const out = (err.stderr || err.stdout || err.message || '').toString().trim()
|
|
229
|
+
return { ok: false, out }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Never let a token reach the log file or the server's command-result.
|
|
234
|
+
function redact(text, token) {
|
|
235
|
+
if (!text || !token) return text
|
|
236
|
+
return text.split(token).join('***')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// https://x-access-token:<token>@github.com/<owner>/<repo>.git — an ad-hoc URL
|
|
240
|
+
// passed straight to push/pull, so it never gets written into .git/config.
|
|
241
|
+
function authUrl(auth) {
|
|
242
|
+
const repo = auth.repo.replace(/\.git$/i, '')
|
|
243
|
+
return `https://x-access-token:${auth.token}@github.com/${repo}.git`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Push/pull using the server-issued token when we have one; otherwise fall back
|
|
247
|
+
// to the machine's own git credentials (old behaviour). Throws on failure.
|
|
248
|
+
function pushOrPull(type, repoPath, auth) {
|
|
249
|
+
const branch = git('git branch --show-current', repoPath) || 'HEAD'
|
|
250
|
+
let cmd
|
|
251
|
+
if (auth) {
|
|
252
|
+
const url = authUrl(auth)
|
|
253
|
+
cmd = type === 'push' ? `git push "${url}" HEAD:${branch}` : `git pull "${url}" ${branch}`
|
|
254
|
+
} else {
|
|
255
|
+
cmd = type === 'push' ? 'git push' : 'git pull'
|
|
256
|
+
}
|
|
257
|
+
const r = gitTry(cmd, repoPath)
|
|
258
|
+
if (!r.ok) throw new Error(r.out || `${type} failed`)
|
|
259
|
+
return r.out || (type === 'push' ? 'pushed' : 'pulled')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
|
|
263
|
+
|
|
174
264
|
function parseDiffByFile(diffOutput) {
|
|
175
265
|
const files = {}
|
|
176
266
|
if (!diffOutput) return files
|
|
@@ -271,23 +361,208 @@ async function reportCommandResult(cfg, id, status, result) {
|
|
|
271
361
|
await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
|
|
272
362
|
}
|
|
273
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
|
+
|
|
274
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
|
+
|
|
275
544
|
let result = 'ok'
|
|
276
545
|
let status = 'done'
|
|
546
|
+
const auth = cfg.auth?.[repoPath] ?? null
|
|
277
547
|
try {
|
|
278
548
|
if (cmd.type === 'commit') {
|
|
279
549
|
const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
|
|
280
550
|
git('git add -A', repoPath)
|
|
281
551
|
result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
|
|
282
|
-
} else if (cmd.type === 'push') {
|
|
283
|
-
result =
|
|
284
|
-
} else if (cmd.type === 'pull') {
|
|
285
|
-
result = git('git pull', repoPath) || 'pulled'
|
|
552
|
+
} else if (cmd.type === 'push' || cmd.type === 'pull') {
|
|
553
|
+
result = pushOrPull(cmd.type, repoPath, auth)
|
|
286
554
|
}
|
|
287
|
-
log(`✓ ${cmd.type} @ ${repoPath}: ${result}`)
|
|
555
|
+
log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
|
|
288
556
|
} catch (err) {
|
|
289
557
|
status = 'error'
|
|
290
|
-
result = err.message
|
|
558
|
+
result = redact(err.message, auth?.token)
|
|
559
|
+
// Cached token rejected (e.g. rotated/expired on the server) → drop it so
|
|
560
|
+
// the next snapshot reports hasGithubAuth:false and the server reissues one.
|
|
561
|
+
if (auth && AUTH_FAIL.test(err.message)) {
|
|
562
|
+
delete cfg.auth[repoPath]
|
|
563
|
+
writeConfig(cfg)
|
|
564
|
+
result += ' — токенът е изчистен, пробвай командата пак'
|
|
565
|
+
}
|
|
291
566
|
log(`✗ ${cmd.type} failed @ ${repoPath}: ${result}`)
|
|
292
567
|
}
|
|
293
568
|
await reportCommandResult(cfg, cmd.id, status, result)
|
|
@@ -298,6 +573,7 @@ async function sync(cfg, discovered) {
|
|
|
298
573
|
const data = await api(cfg, '/api/v1/agent/sync', {
|
|
299
574
|
machineId: cfg.machineId,
|
|
300
575
|
hostname: cfg.hostname,
|
|
576
|
+
agentVersion: AGENT_VERSION,
|
|
301
577
|
roots: cfg.roots,
|
|
302
578
|
repos: discovered,
|
|
303
579
|
})
|
|
@@ -311,8 +587,17 @@ async function pushSnapshot(cfg, repo) {
|
|
|
311
587
|
machineId: cfg.machineId,
|
|
312
588
|
path: repo.path,
|
|
313
589
|
repoName: repo.name,
|
|
590
|
+
// Tells the server whether we already hold a push token for this repo.
|
|
591
|
+
// Always a boolean → marks us as a token-capable agent.
|
|
592
|
+
hasGithubAuth: !!cfg.auth?.[repo.path],
|
|
314
593
|
...snapshot,
|
|
315
594
|
})
|
|
595
|
+
// Server issued a (fresh) push token — cache it on disk so we ask only once.
|
|
596
|
+
if (data.githubAuth?.token && data.githubAuth?.repo) {
|
|
597
|
+
cfg.auth = cfg.auth ?? {}
|
|
598
|
+
cfg.auth[repo.path] = { token: data.githubAuth.token, repo: data.githubAuth.repo }
|
|
599
|
+
writeConfig(cfg)
|
|
600
|
+
}
|
|
316
601
|
for (const cmd of data.commands ?? []) {
|
|
317
602
|
await executeCommand(cfg, cmd, repo.path)
|
|
318
603
|
}
|