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.
Files changed (2) hide show
  1. package/index.js +291 -6
  2. 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 = git('git push', repoPath) || 'pushed'
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.2.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": {