gitdone-agent 0.5.2 → 0.5.3

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 +897 -863
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,863 +1,897 @@
1
- #!/usr/bin/env node
2
- // gitdone-agent — scans root folders for git repos and pushes snapshots of the
3
- // ones the server marks as "tracked" to gitdone.eu.
4
- //
5
- // Setup (once):
6
- // npx gitdone-agent --key=gdo_xxx --root=C:\path\to\projects --install
7
- //
8
- // Then pick which discovered repos to track from gitdone.eu/github. Add more
9
- // roots anytime with --root (repeatable). The running agent reads its config
10
- // from ~/.gitdone-agent/config.json, so the autostart entry needs no args.
11
-
12
- import { execSync, spawn } from 'node:child_process'
13
- import {
14
- existsSync, writeFileSync, readFileSync, unlinkSync,
15
- mkdirSync, copyFileSync, appendFileSync, readdirSync,
16
- } from 'node:fs'
17
- import { resolve, join } from 'node:path'
18
- import { homedir, hostname } from 'node:os'
19
- import { randomUUID } from 'node:crypto'
20
-
21
- // ─── Stable agent dir, config + logging ────────────────────────────────────────
22
- // Everything persistent lives here: a stable copy of the agent script (so
23
- // autostart never points at a purged npx temp dir), the config file (source of
24
- // truth for the running agent), and a log file (so failures are visible even
25
- // when the agent runs in a hidden window).
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.2'
31
-
32
- const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
- const CONFIG_PATH = join(AGENT_DIR, 'config.json')
34
- const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
35
- const LOG_PATH = join(AGENT_DIR, 'agent.log')
36
-
37
- function ensureAgentDir() {
38
- if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
39
- }
40
-
41
- function log(msg) {
42
- const line = `[${new Date().toISOString()}] ${msg}`
43
- console.log(line)
44
- try { ensureAgentDir(); appendFileSync(LOG_PATH, line + '\n') } catch { /* best-effort */ }
45
- }
46
-
47
- function readConfig() {
48
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
49
- }
50
-
51
- function writeConfig(cfg) {
52
- ensureAgentDir()
53
- writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
54
- }
55
-
56
- // ─── Arg parsing ─────────────────────────────────────────────────────────────
57
-
58
- function parseArgs() {
59
- const argv = process.argv.slice(2)
60
- const flags = {}
61
- const roots = []
62
- for (const a of argv) {
63
- if (!a.startsWith('--')) continue
64
- const [k, ...rest] = a.slice(2).split('=')
65
- const v = rest.join('=')
66
- if (k === 'root') { if (v) roots.push(resolve(v)) }
67
- else flags[k] = v
68
- }
69
- return {
70
- key: flags.key,
71
- roots,
72
- interval: flags.interval ? Number(flags.interval) : undefined,
73
- url: flags.url ? flags.url.replace(/\/$/, '') : undefined,
74
- install: 'install' in flags,
75
- uninstall: 'uninstall' in flags,
76
- doctor: 'doctor' in flags,
77
- }
78
- }
79
-
80
- // Merge CLI args into the persisted config, generating a machineId on first use.
81
- function buildConfig(args) {
82
- const existing = readConfig() ?? {}
83
- const mergedRoots = Array.from(new Set([...(existing.roots ?? []), ...args.roots]))
84
- return {
85
- key: args.key ?? existing.key,
86
- url: args.url ?? existing.url ?? 'https://gitdone.eu',
87
- interval: args.interval ?? existing.interval ?? 30,
88
- machineId: existing.machineId ?? randomUUID(),
89
- hostname: existing.hostname ?? hostname(),
90
- roots: mergedRoots,
91
- }
92
- }
93
-
94
- // ─── Windows auto-start ───────────────────────────────────────────────────────
95
-
96
- function getStartupDir() {
97
- return join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
98
- }
99
-
100
- function getVbsPath() {
101
- return join(getStartupDir(), 'gitdone-agent.vbs')
102
- }
103
-
104
- function getNodePath() {
105
- try { return execSync('where node', { encoding: 'utf8' }).trim().split('\n')[0].trim() } catch {}
106
- try { return execSync('which node', { encoding: 'utf8' }).trim() } catch {}
107
- return process.execPath
108
- }
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
-
148
- function installStartup() {
149
- ensureAgentDir()
150
- const nodePath = getNodePath()
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
-
156
- // Copy this script to a stable location. process.argv[1] may point inside an
157
- // npx cache dir that gets purged later — referencing it from autostart would
158
- // silently break on the next login. A stable copy survives.
159
- try {
160
- copyFileSync(resolve(process.argv[1]), STABLE_AGENT)
161
- } catch (err) {
162
- console.error(`Warning: could not copy agent to ${STABLE_AGENT}: ${err.message}`)
163
- }
164
- const agentPath = existsSync(STABLE_AGENT) ? STABLE_AGENT : resolve(process.argv[1])
165
-
166
- // The running agent reads everything from config.json — no args needed.
167
- // VBScript runs it silently so no console window pops up on login.
168
- const vbs = [
169
- 'Set WshShell = CreateObject("WScript.Shell")',
170
- `WshShell.Run """${nodePath}"" ""${agentPath}""", 0, False`,
171
- ].join('\r\n')
172
-
173
- writeFileSync(getVbsPath(), vbs, 'utf8')
174
- }
175
-
176
- function uninstallStartup() {
177
- const vbsPath = getVbsPath()
178
- if (existsSync(vbsPath)) {
179
- unlinkSync(vbsPath)
180
- console.log(`✓ Премахнат автостарт за gitdone-agent`)
181
- } else {
182
- console.log(`Не е намерен автостарт (${vbsPath})`)
183
- }
184
- }
185
-
186
- // ─── Doctor ────────────────────────────────────────────────────────────────────
187
-
188
- function runDoctor() {
189
- const cfg = readConfig()
190
- console.log('gitdone-agent doctor')
191
- console.log(` node : ${getNodePath()}`)
192
- console.log(` agent dir : ${AGENT_DIR} ${existsSync(AGENT_DIR) ? '(ok)' : '(MISSING)'}`)
193
- console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
194
- console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
195
- console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
196
- console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
197
- if (cfg) {
198
- console.log(` machineId : ${cfg.machineId}`)
199
- console.log(` server : ${cfg.url}`)
200
- console.log(` interval : ${cfg.interval}s`)
201
- console.log(` roots :`)
202
- for (const r of cfg.roots ?? []) console.log(` - ${r} ${existsSync(r) ? '' : '(MISSING)'}`)
203
- if (cfg.roots?.length) {
204
- const found = scanRepos(cfg.roots)
205
- console.log(` found repos : ${found.length}`)
206
- for (const r of found) console.log(` - ${r.name} (${r.path})`)
207
- }
208
- }
209
- }
210
-
211
- // ─── Git helpers ─────────────────────────────────────────────────────────────
212
-
213
- function git(cmd, cwd) {
214
- try {
215
- return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
216
- } catch {
217
- return ''
218
- }
219
- }
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
-
264
- function parseDiffByFile(diffOutput) {
265
- const files = {}
266
- if (!diffOutput) return files
267
- const sections = diffOutput.split(/(?=^diff --git )/m)
268
- for (const section of sections) {
269
- if (!section.trim()) continue
270
- const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
271
- if (match) files[match[1]] = section
272
- }
273
- return files
274
- }
275
-
276
- function getSnapshot(repoPath) {
277
- const branch = git('git branch --show-current', repoPath) || 'HEAD'
278
-
279
- const statusLines = git('git status --porcelain', repoPath).split('\n').filter(Boolean)
280
- const modified = []
281
- const staged = []
282
- const statuses = {}
283
- for (const line of statusLines) {
284
- const xy = line.slice(0, 2)
285
- const file = line.slice(3)
286
- statuses[file] = xy
287
- if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
288
- if (xy[1] !== ' ') modified.push(file)
289
- }
290
-
291
- const aheadBy = parseInt(git('git rev-list --count @{u}..HEAD', repoPath), 10) || 0
292
- const behindBy = parseInt(git('git rev-list --count HEAD..@{u}', repoPath), 10) || 0
293
-
294
- const logLine = git('git log -1 --pretty=format:%H|%s|%an|%aI', repoPath)
295
- let lastCommit = null
296
- if (logLine) {
297
- const [sha, message, author, date] = logLine.split('|')
298
- lastCommit = { sha, message, author, date }
299
- }
300
-
301
- const diffs = parseDiffByFile(git('git diff HEAD', repoPath))
302
-
303
- // Origin remote → lets the server auto-detect the GitHub repo for the History tab.
304
- const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
305
-
306
- return { branch, modified, staged, aheadBy, behindBy, lastCommit, statuses, diffs, remoteUrl }
307
- }
308
-
309
- // ─── Repo discovery ─────────────────────────────────────────────────────────────
310
- // Walk each root looking for directories that contain a `.git` entry. Stop
311
- // descending once a repo is found (don't recurse into submodules/nested repos),
312
- // skip noisy dirs, and cap depth so a huge tree can't hang a tick.
313
-
314
- const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', '.cache', 'vendor', '.venv', '__pycache__'])
315
-
316
- function scanRepos(roots, maxDepth = 5) {
317
- const found = []
318
- const seen = new Set()
319
-
320
- function walk(dir, depth) {
321
- if (depth > maxDepth) return
322
- let entries
323
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
324
-
325
- if (entries.some((e) => e.name === '.git')) {
326
- const path = resolve(dir)
327
- if (!seen.has(path)) {
328
- seen.add(path)
329
- found.push({ name: path.split(/[\\/]/).pop() ?? path, path })
330
- }
331
- return // a repo don't descend further
332
- }
333
-
334
- for (const e of entries) {
335
- if (!e.isDirectory()) continue
336
- if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
337
- walk(join(dir, e.name), depth + 1)
338
- }
339
- }
340
-
341
- for (const root of roots) walk(resolve(root), 0)
342
- return found
343
- }
344
-
345
- // ─── Server sync + commands ─────────────────────────────────────────────────────
346
-
347
- async function api(cfg, path, body) {
348
- const res = await fetch(`${cfg.url}${path}`, {
349
- method: 'POST',
350
- headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
351
- body: JSON.stringify(body),
352
- })
353
- if (!res.ok) {
354
- const text = await res.text().catch(() => '')
355
- throw new Error(`HTTP ${res.status} ${path}: ${text}`)
356
- }
357
- return res.json().catch(() => ({}))
358
- }
359
-
360
- async function reportCommandResult(cfg, id, status, result) {
361
- await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
362
- }
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
- // 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
-
385
- // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
386
- // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
387
- // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
388
- function parseStreamLine(line, push, onInit) {
389
- let ev
390
- try { ev = JSON.parse(line) } catch { return }
391
- if (ev.type === 'system') {
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
- }
397
- return
398
- }
399
- if (ev.type === 'assistant' && ev.message?.content) {
400
- for (const block of ev.message.content) {
401
- if (block.type === 'text' && block.text?.trim()) {
402
- push('TEXT', block.text.trim())
403
- } else if (block.type === 'tool_use') {
404
- const input = block.input ? JSON.stringify(block.input) : ''
405
- push('TOOL_CALL', `${block.name}${input ? ` ${input.slice(0, 400)}` : ''}`)
406
- }
407
- }
408
- return
409
- }
410
- if (ev.type === 'result' && ev.subtype && ev.subtype !== 'success') {
411
- push('SYSTEM', `Резултат: ${ev.subtype}`)
412
- }
413
- }
414
-
415
- // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
416
- // (Bash stdout, edit results, …) to the run's console, plus the settings file
417
- // that registers it. The hook reads GITDONE_* from its env (set per run on the
418
- // claude process). Returns the settings path to pass via --settings.
419
- const AI_HOOK_SCRIPT = join(AGENT_DIR, 'ai-run-hook.mjs')
420
- const AI_SETTINGS_PATH = join(AGENT_DIR, 'ai-run-settings.json')
421
-
422
- function ensureAiHookFiles() {
423
- ensureAgentDir()
424
- const hookSrc = [
425
- "import process from 'node:process'",
426
- "let data = ''",
427
- "process.stdin.setEncoding('utf8')",
428
- "process.stdin.on('data', (c) => { data += c })",
429
- 'process.stdin.on(\'end\', async () => {',
430
- ' try {',
431
- " const ev = JSON.parse(data || '{}')",
432
- ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, runId = process.env.GITDONE_RUN_ID',
433
- ' if (url && key && runId) {',
434
- " const name = ev.tool_name || 'tool'",
435
- ' const o = ev.tool_output',
436
- " let out = ''",
437
- " if (typeof o === 'string') out = o",
438
- " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
439
- " out = String(out || '').slice(0, 2000)",
440
- " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
441
- " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
442
- " await fetch(url + '/api/v1/agent/ai-run/events', {",
443
- " method: 'POST',",
444
- " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
445
- " body: JSON.stringify({ runId, events: [{ kind: 'TOOL_RESULT', text }] }),",
446
- ' }).catch(() => {})',
447
- ' }',
448
- ' } catch {}',
449
- ' process.exit(0)',
450
- '})',
451
- ].join('\n')
452
- writeFileSync(AI_HOOK_SCRIPT, hookSrc, 'utf8')
453
-
454
- const nodePath = getNodePath()
455
- const settings = {
456
- hooks: {
457
- PostToolUse: [
458
- { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_HOOK_SCRIPT}"` }] },
459
- ],
460
- },
461
- }
462
- writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
463
- return AI_SETTINGS_PATH
464
- }
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
-
515
- // Run a headless Claude Code session for an `ai_run` command and stream its
516
- // output back. Long-running and fire-and-forget: it wires up async handlers and
517
- // returns immediately so the agent's snapshot loop is never blocked.
518
- function runAiCommand(cfg, cmd, repoPath) {
519
- const runId = cmd.payload?.runId
520
- const prompt = cmd.payload?.prompt ?? ''
521
- const allowCommit = cmd.payload?.allowCommit === true
522
- if (!runId || !prompt) {
523
- reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
524
- return
525
- }
526
-
527
- const { path: claudePath, shell } = findClaude()
528
-
529
- let settingsPath
530
- try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
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.
538
- const args = [
539
- '-p',
540
- '--output-format', 'stream-json',
541
- '--verbose',
542
- '--permission-mode', 'acceptEdits',
543
- '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
544
- // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
545
- ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
546
- ...(settingsPath ? ['--settings', settingsPath] : []),
547
- ]
548
-
549
- // The PostToolUse hook reads these from its env to post raw tool output.
550
- const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
551
-
552
- // Batch events on a timer so we don't hammer the server per token/line.
553
- let pending = []
554
- let flushing = false
555
- const flush = async () => {
556
- if (flushing || pending.length === 0) return
557
- flushing = true
558
- const batch = pending; pending = []
559
- await postRunEvents(cfg, runId, batch)
560
- flushing = false
561
- }
562
- const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
563
- const timer = setInterval(flush, 800)
564
-
565
- log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
566
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
567
-
568
- let child
569
- try {
570
- child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
571
- } catch (err) {
572
- clearInterval(timer)
573
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
574
- reportCommandResult(cfg, cmd.id, 'error', err.message)
575
- return
576
- }
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
-
586
- let buf = ''
587
- child.stdout.on('data', (d) => {
588
- buf += d.toString()
589
- let nl
590
- while ((nl = buf.indexOf('\n')) >= 0) {
591
- const line = buf.slice(0, nl).trim()
592
- buf = buf.slice(nl + 1)
593
- if (line) parseStreamLine(line, push)
594
- }
595
- })
596
- child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
597
- child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
598
- child.on('close', async (code) => {
599
- clearInterval(timer)
600
- if (buf.trim()) parseStreamLine(buf.trim(), push)
601
- await flush()
602
- const ok = code === 0
603
- await postRunEvents(
604
- cfg, runId,
605
- [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }],
606
- ok ? 'done' : 'error',
607
- ok ? 'ok' : `exit ${code}`,
608
- )
609
- reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
610
- log(`■ ai_run ${runId} приключи (code ${code})`)
611
- })
612
- }
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
-
708
- async function executeCommand(cfg, cmd, repoPath) {
709
- // ai_run is long-running + streaming — launch it in the background and return
710
- // so the snapshot loop keeps going. It reports its own result on exit.
711
- if (cmd.type === 'ai_run') {
712
- runAiCommand(cfg, cmd, repoPath)
713
- return
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
- }
720
-
721
- let result = 'ok'
722
- let status = 'done'
723
- const auth = cfg.auth?.[repoPath] ?? null
724
- try {
725
- if (cmd.type === 'commit') {
726
- const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
727
- git('git add -A', repoPath)
728
- result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
729
- } else if (cmd.type === 'push' || cmd.type === 'pull') {
730
- result = pushOrPull(cmd.type, repoPath, auth)
731
- }
732
- log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
733
- } catch (err) {
734
- status = 'error'
735
- result = redact(err.message, auth?.token)
736
- // Cached token rejected (e.g. rotated/expired on the server) → drop it so
737
- // the next snapshot reports hasGithubAuth:false and the server reissues one.
738
- if (auth && AUTH_FAIL.test(err.message)) {
739
- delete cfg.auth[repoPath]
740
- writeConfig(cfg)
741
- result += ' — токенът е изчистен, пробвай командата пак'
742
- }
743
- log(`✗ ${cmd.type} failed @ ${repoPath}: ${result}`)
744
- }
745
- await reportCommandResult(cfg, cmd.id, status, result)
746
- }
747
-
748
- // Report discovered repos + machine info, get back which repos to track.
749
- async function sync(cfg, discovered) {
750
- const data = await api(cfg, '/api/v1/agent/sync', {
751
- machineId: cfg.machineId,
752
- hostname: cfg.hostname,
753
- agentVersion: AGENT_VERSION,
754
- roots: cfg.roots,
755
- repos: discovered,
756
- })
757
- return data.tracked ?? []
758
- }
759
-
760
- // Push one tracked repo's snapshot and run any pending commands for it.
761
- async function pushSnapshot(cfg, repo) {
762
- const snapshot = getSnapshot(repo.path)
763
- const data = await api(cfg, '/api/v1/agent/snapshot', {
764
- machineId: cfg.machineId,
765
- path: repo.path,
766
- repoName: repo.name,
767
- // Tells the server whether we already hold a push token for this repo.
768
- // Always a boolean → marks us as a token-capable agent.
769
- hasGithubAuth: !!cfg.auth?.[repo.path],
770
- ...snapshot,
771
- })
772
- // Server issued a (fresh) push token — cache it on disk so we ask only once.
773
- if (data.githubAuth?.token && data.githubAuth?.repo) {
774
- cfg.auth = cfg.auth ?? {}
775
- cfg.auth[repo.path] = { token: data.githubAuth.token, repo: data.githubAuth.repo }
776
- writeConfig(cfg)
777
- }
778
- for (const cmd of data.commands ?? []) {
779
- await executeCommand(cfg, cmd, repo.path)
780
- }
781
- return snapshot
782
- }
783
-
784
- // ─── Main ────────────────────────────────────────────────────────────────────
785
-
786
- async function runLoop(cfg) {
787
- log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
788
- log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
789
-
790
- async function tick() {
791
- try {
792
- const discovered = scanRepos(cfg.roots)
793
- const tracked = await sync(cfg, discovered)
794
- let pushed = 0
795
- for (const repo of tracked) {
796
- try { await pushSnapshot(cfg, repo); pushed++ }
797
- catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
798
- }
799
- log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
800
- } catch (err) {
801
- log(`✗ sync error: ${err.message}`)
802
- }
803
- }
804
-
805
- await tick()
806
- setInterval(tick, cfg.interval * 1000)
807
- }
808
-
809
- async function main() {
810
- const args = parseArgs()
811
-
812
- if (args.doctor) {
813
- runDoctor()
814
- process.exit(0)
815
- }
816
-
817
- if (args.uninstall) {
818
- uninstallStartup()
819
- process.exit(0)
820
- }
821
-
822
- // Setup / install path: merge CLI args into config, optionally (re)install.
823
- if (args.install || args.key || args.roots.length || args.url || args.interval) {
824
- const cfg = buildConfig(args)
825
- if (!cfg.key) {
826
- console.error('Error: --key is required on first setup (e.g. --key=gdo_xxx)')
827
- process.exit(1)
828
- }
829
- writeConfig(cfg)
830
-
831
- if (args.install) {
832
- installStartup()
833
- console.log(`✓ Инсталиран! При следващото влизане в Windows агентът ще стартира автоматично.`)
834
- console.log(` Агент : ${STABLE_AGENT}`)
835
- console.log(` Config : ${CONFIG_PATH}`)
836
- console.log(` Лог : ${LOG_PATH}`)
837
- console.log(` Roots :`)
838
- for (const r of cfg.roots) console.log(` - ${r}`)
839
- console.log()
840
-
841
- // Launch silently in the background right now.
842
- const child = spawn('wscript.exe', [getVbsPath()], { detached: true, stdio: 'ignore' })
843
- child.unref()
844
- console.log('✓ Агентът е стартиран в заден план — можеш да затвориш прозореца.')
845
- console.log(' Сега избери кои репота да следи от gitdone.eu/github.')
846
- process.exit(0)
847
- }
848
-
849
- // --key/--root without --install: just run the loop in the foreground.
850
- await runLoop(cfg)
851
- return
852
- }
853
-
854
- // No args → running mode (this is how autostart launches us): read config.
855
- const cfg = readConfig()
856
- if (!cfg || !cfg.key) {
857
- console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
858
- process.exit(1)
859
- }
860
- await runLoop(cfg)
861
- }
862
-
863
- main()
1
+ #!/usr/bin/env node
2
+ // gitdone-agent — scans root folders for git repos and pushes snapshots of the
3
+ // ones the server marks as "tracked" to gitdone.eu.
4
+ //
5
+ // Setup (once):
6
+ // npx gitdone-agent --key=gdo_xxx --root=C:\path\to\projects --install
7
+ //
8
+ // Then pick which discovered repos to track from gitdone.eu/github. Add more
9
+ // roots anytime with --root (repeatable). The running agent reads its config
10
+ // from ~/.gitdone-agent/config.json, so the autostart entry needs no args.
11
+
12
+ import { execSync, spawn } from 'node:child_process'
13
+ import {
14
+ existsSync, writeFileSync, readFileSync, unlinkSync,
15
+ mkdirSync, copyFileSync, appendFileSync, readdirSync,
16
+ } from 'node:fs'
17
+ import { resolve, join } from 'node:path'
18
+ import { homedir, hostname } from 'node:os'
19
+ import { randomUUID } from 'node:crypto'
20
+
21
+ // ─── Stable agent dir, config + logging ────────────────────────────────────────
22
+ // Everything persistent lives here: a stable copy of the agent script (so
23
+ // autostart never points at a purged npx temp dir), the config file (source of
24
+ // truth for the running agent), and a log file (so failures are visible even
25
+ // when the agent runs in a hidden window).
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.3'
31
+
32
+ const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
+ const CONFIG_PATH = join(AGENT_DIR, 'config.json')
34
+ const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
35
+ const LOG_PATH = join(AGENT_DIR, 'agent.log')
36
+
37
+ function ensureAgentDir() {
38
+ if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
39
+ }
40
+
41
+ function log(msg) {
42
+ const line = `[${new Date().toISOString()}] ${msg}`
43
+ console.log(line)
44
+ try { ensureAgentDir(); appendFileSync(LOG_PATH, line + '\n') } catch { /* best-effort */ }
45
+ }
46
+
47
+ function readConfig() {
48
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
49
+ }
50
+
51
+ function writeConfig(cfg) {
52
+ ensureAgentDir()
53
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
54
+ }
55
+
56
+ // ─── Arg parsing ─────────────────────────────────────────────────────────────
57
+
58
+ function parseArgs() {
59
+ const argv = process.argv.slice(2)
60
+ const flags = {}
61
+ const roots = []
62
+ for (const a of argv) {
63
+ if (!a.startsWith('--')) continue
64
+ const [k, ...rest] = a.slice(2).split('=')
65
+ const v = rest.join('=')
66
+ if (k === 'root') { if (v) roots.push(resolve(v)) }
67
+ else flags[k] = v
68
+ }
69
+ return {
70
+ key: flags.key,
71
+ roots,
72
+ interval: flags.interval ? Number(flags.interval) : undefined,
73
+ url: flags.url ? flags.url.replace(/\/$/, '') : undefined,
74
+ install: 'install' in flags,
75
+ uninstall: 'uninstall' in flags,
76
+ doctor: 'doctor' in flags,
77
+ }
78
+ }
79
+
80
+ // Merge CLI args into the persisted config, generating a machineId on first use.
81
+ function buildConfig(args) {
82
+ const existing = readConfig() ?? {}
83
+ const mergedRoots = Array.from(new Set([...(existing.roots ?? []), ...args.roots]))
84
+ return {
85
+ key: args.key ?? existing.key,
86
+ url: args.url ?? existing.url ?? 'https://gitdone.eu',
87
+ interval: args.interval ?? existing.interval ?? 30,
88
+ machineId: existing.machineId ?? randomUUID(),
89
+ hostname: existing.hostname ?? hostname(),
90
+ roots: mergedRoots,
91
+ }
92
+ }
93
+
94
+ // ─── Windows auto-start ───────────────────────────────────────────────────────
95
+
96
+ function getStartupDir() {
97
+ return join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
98
+ }
99
+
100
+ function getVbsPath() {
101
+ return join(getStartupDir(), 'gitdone-agent.vbs')
102
+ }
103
+
104
+ function getNodePath() {
105
+ try { return execSync('where node', { encoding: 'utf8' }).trim().split('\n')[0].trim() } catch {}
106
+ try { return execSync('which node', { encoding: 'utf8' }).trim() } catch {}
107
+ return process.execPath
108
+ }
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
+
148
+ function installStartup() {
149
+ ensureAgentDir()
150
+ const nodePath = getNodePath()
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
+
156
+ // Copy this script to a stable location. process.argv[1] may point inside an
157
+ // npx cache dir that gets purged later — referencing it from autostart would
158
+ // silently break on the next login. A stable copy survives.
159
+ try {
160
+ copyFileSync(resolve(process.argv[1]), STABLE_AGENT)
161
+ } catch (err) {
162
+ console.error(`Warning: could not copy agent to ${STABLE_AGENT}: ${err.message}`)
163
+ }
164
+ const agentPath = existsSync(STABLE_AGENT) ? STABLE_AGENT : resolve(process.argv[1])
165
+
166
+ // The running agent reads everything from config.json — no args needed.
167
+ // VBScript runs it silently so no console window pops up on login.
168
+ const vbs = [
169
+ 'Set WshShell = CreateObject("WScript.Shell")',
170
+ `WshShell.Run """${nodePath}"" ""${agentPath}""", 0, False`,
171
+ ].join('\r\n')
172
+
173
+ writeFileSync(getVbsPath(), vbs, 'utf8')
174
+ }
175
+
176
+ function uninstallStartup() {
177
+ const vbsPath = getVbsPath()
178
+ if (existsSync(vbsPath)) {
179
+ unlinkSync(vbsPath)
180
+ console.log(`✓ Премахнат автостарт за gitdone-agent`)
181
+ } else {
182
+ console.log(`Не е намерен автостарт (${vbsPath})`)
183
+ }
184
+ }
185
+
186
+ // ─── Doctor ────────────────────────────────────────────────────────────────────
187
+
188
+ function runDoctor() {
189
+ const cfg = readConfig()
190
+ console.log('gitdone-agent doctor')
191
+ console.log(` node : ${getNodePath()}`)
192
+ console.log(` agent dir : ${AGENT_DIR} ${existsSync(AGENT_DIR) ? '(ok)' : '(MISSING)'}`)
193
+ console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
194
+ console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
195
+ console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
196
+ console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
197
+ if (cfg) {
198
+ console.log(` machineId : ${cfg.machineId}`)
199
+ console.log(` server : ${cfg.url}`)
200
+ console.log(` interval : ${cfg.interval}s`)
201
+ console.log(` roots :`)
202
+ for (const r of cfg.roots ?? []) console.log(` - ${r} ${existsSync(r) ? '' : '(MISSING)'}`)
203
+ if (cfg.roots?.length) {
204
+ const found = scanRepos(cfg.roots)
205
+ console.log(` found repos : ${found.length}`)
206
+ for (const r of found) console.log(` - ${r.name} (${r.path})`)
207
+ }
208
+ }
209
+ }
210
+
211
+ // ─── Git helpers ─────────────────────────────────────────────────────────────
212
+
213
+ function git(cmd, cwd) {
214
+ try {
215
+ return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
216
+ } catch {
217
+ return ''
218
+ }
219
+ }
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
+
264
+ // Discard all local changes to a SINGLE file, so it disappears from the repo's
265
+ // "changes". Mirrors GitHub Desktop's "Discard changes": unstage first, then
266
+ // either restore the file from HEAD (tracked) or delete it (new/untracked).
267
+ function discardFile(repoPath, file) {
268
+ if (!file) throw new Error('no file')
269
+ // Unstage so index + worktree get reverted together (no-op if not staged).
270
+ gitTry(`git reset -q HEAD -- "${file}"`, repoPath)
271
+ // Does the file exist in the last commit? If so we restore its content; if
272
+ // not, it's a newly-added/untracked file and discarding means removing it.
273
+ const inHead = gitTry(`git cat-file -e "HEAD:${file}"`, repoPath).ok
274
+ if (inHead) {
275
+ const r = gitTry(`git checkout HEAD -- "${file}"`, repoPath)
276
+ if (!r.ok) throw new Error(r.out || 'checkout failed')
277
+ return 'discarded'
278
+ }
279
+ // New/untracked file (now unstaged) drop it from the working tree.
280
+ const r = gitTry(`git clean -fdq -- "${file}"`, repoPath)
281
+ if (!r.ok) throw new Error(r.out || 'clean failed')
282
+ return 'discarded (new file removed)'
283
+ }
284
+
285
+ function parseDiffByFile(diffOutput) {
286
+ const files = {}
287
+ if (!diffOutput) return files
288
+ const sections = diffOutput.split(/(?=^diff --git )/m)
289
+ for (const section of sections) {
290
+ if (!section.trim()) continue
291
+ const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
292
+ if (match) files[match[1]] = section
293
+ }
294
+ return files
295
+ }
296
+
297
+ function getSnapshot(repoPath) {
298
+ const branch = git('git branch --show-current', repoPath) || 'HEAD'
299
+
300
+ const statusLines = git('git status --porcelain', repoPath).split('\n').filter(Boolean)
301
+ const modified = []
302
+ const staged = []
303
+ const statuses = {}
304
+ for (const line of statusLines) {
305
+ const xy = line.slice(0, 2)
306
+ const file = line.slice(3)
307
+ statuses[file] = xy
308
+ if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
309
+ if (xy[1] !== ' ') modified.push(file)
310
+ }
311
+
312
+ const aheadBy = parseInt(git('git rev-list --count @{u}..HEAD', repoPath), 10) || 0
313
+ const behindBy = parseInt(git('git rev-list --count HEAD..@{u}', repoPath), 10) || 0
314
+
315
+ const logLine = git('git log -1 --pretty=format:%H|%s|%an|%aI', repoPath)
316
+ let lastCommit = null
317
+ if (logLine) {
318
+ const [sha, message, author, date] = logLine.split('|')
319
+ lastCommit = { sha, message, author, date }
320
+ }
321
+
322
+ const diffs = parseDiffByFile(git('git diff HEAD', repoPath))
323
+
324
+ // Origin remote → lets the server auto-detect the GitHub repo for the History tab.
325
+ const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
326
+
327
+ return { branch, modified, staged, aheadBy, behindBy, lastCommit, statuses, diffs, remoteUrl }
328
+ }
329
+
330
+ // ─── Repo discovery ─────────────────────────────────────────────────────────────
331
+ // Walk each root looking for directories that contain a `.git` entry. Stop
332
+ // descending once a repo is found (don't recurse into submodules/nested repos),
333
+ // skip noisy dirs, and cap depth so a huge tree can't hang a tick.
334
+
335
+ const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', '.cache', 'vendor', '.venv', '__pycache__'])
336
+
337
+ function scanRepos(roots, maxDepth = 5) {
338
+ const found = []
339
+ const seen = new Set()
340
+
341
+ function walk(dir, depth) {
342
+ if (depth > maxDepth) return
343
+ let entries
344
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
345
+
346
+ if (entries.some((e) => e.name === '.git')) {
347
+ const path = resolve(dir)
348
+ if (!seen.has(path)) {
349
+ seen.add(path)
350
+ found.push({ name: path.split(/[\\/]/).pop() ?? path, path })
351
+ }
352
+ return // a repo — don't descend further
353
+ }
354
+
355
+ for (const e of entries) {
356
+ if (!e.isDirectory()) continue
357
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
358
+ walk(join(dir, e.name), depth + 1)
359
+ }
360
+ }
361
+
362
+ for (const root of roots) walk(resolve(root), 0)
363
+ return found
364
+ }
365
+
366
+ // ─── Server sync + commands ─────────────────────────────────────────────────────
367
+
368
+ async function api(cfg, path, body) {
369
+ const res = await fetch(`${cfg.url}${path}`, {
370
+ method: 'POST',
371
+ headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
372
+ body: JSON.stringify(body),
373
+ })
374
+ if (!res.ok) {
375
+ const text = await res.text().catch(() => '')
376
+ throw new Error(`HTTP ${res.status} ${path}: ${text}`)
377
+ }
378
+ return res.json().catch(() => ({}))
379
+ }
380
+
381
+ async function reportCommandResult(cfg, id, status, result) {
382
+ await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
383
+ }
384
+
385
+ // Post a batch of console events (and optional lifecycle status) for an AiRun.
386
+ async function postRunEvents(cfg, runId, events, status, result) {
387
+ await api(cfg, '/api/v1/agent/ai-run/events', {
388
+ runId,
389
+ events,
390
+ ...(status ? { status } : {}),
391
+ ...(result !== undefined ? { result } : {}),
392
+ }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
393
+ }
394
+
395
+ // Post transcript lines (and optional turn status / Claude session id) for an
396
+ // interactive AiSession chat turn.
397
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId) {
398
+ await api(cfg, '/api/v1/agent/ai-session/events', {
399
+ sessionId,
400
+ events,
401
+ ...(status ? { status } : {}),
402
+ ...(claudeSessionId ? { claudeSessionId } : {}),
403
+ }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
404
+ }
405
+
406
+ // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
407
+ // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
408
+ // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
409
+ function parseStreamLine(line, push, onInit) {
410
+ let ev
411
+ try { ev = JSON.parse(line) } catch { return }
412
+ if (ev.type === 'system') {
413
+ if (ev.subtype === 'init') {
414
+ push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
415
+ // Surface Claude's own session id so chat sessions can --resume it.
416
+ if (ev.session_id && typeof onInit === 'function') onInit(ev.session_id)
417
+ }
418
+ return
419
+ }
420
+ if (ev.type === 'assistant' && ev.message?.content) {
421
+ for (const block of ev.message.content) {
422
+ if (block.type === 'text' && block.text?.trim()) {
423
+ push('TEXT', block.text.trim())
424
+ } else if (block.type === 'tool_use') {
425
+ const input = block.input ? JSON.stringify(block.input) : ''
426
+ push('TOOL_CALL', `${block.name}${input ? ` ${input.slice(0, 400)}` : ''}`)
427
+ }
428
+ }
429
+ return
430
+ }
431
+ if (ev.type === 'result' && ev.subtype && ev.subtype !== 'success') {
432
+ push('SYSTEM', `Резултат: ${ev.subtype}`)
433
+ }
434
+ }
435
+
436
+ // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
437
+ // (Bash stdout, edit results, …) to the run's console, plus the settings file
438
+ // that registers it. The hook reads GITDONE_* from its env (set per run on the
439
+ // claude process). Returns the settings path to pass via --settings.
440
+ const AI_HOOK_SCRIPT = join(AGENT_DIR, 'ai-run-hook.mjs')
441
+ const AI_SETTINGS_PATH = join(AGENT_DIR, 'ai-run-settings.json')
442
+
443
+ function ensureAiHookFiles() {
444
+ ensureAgentDir()
445
+ const hookSrc = [
446
+ "import process from 'node:process'",
447
+ "let data = ''",
448
+ "process.stdin.setEncoding('utf8')",
449
+ "process.stdin.on('data', (c) => { data += c })",
450
+ 'process.stdin.on(\'end\', async () => {',
451
+ ' try {',
452
+ " const ev = JSON.parse(data || '{}')",
453
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, runId = process.env.GITDONE_RUN_ID',
454
+ ' if (url && key && runId) {',
455
+ " const name = ev.tool_name || 'tool'",
456
+ ' const o = ev.tool_output',
457
+ " let out = ''",
458
+ " if (typeof o === 'string') out = o",
459
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
460
+ " out = String(out || '').slice(0, 2000)",
461
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
462
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
463
+ " await fetch(url + '/api/v1/agent/ai-run/events', {",
464
+ " method: 'POST',",
465
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
466
+ " body: JSON.stringify({ runId, events: [{ kind: 'TOOL_RESULT', text }] }),",
467
+ ' }).catch(() => {})',
468
+ ' }',
469
+ ' } catch {}',
470
+ ' process.exit(0)',
471
+ '})',
472
+ ].join('\n')
473
+ writeFileSync(AI_HOOK_SCRIPT, hookSrc, 'utf8')
474
+
475
+ const nodePath = getNodePath()
476
+ const settings = {
477
+ hooks: {
478
+ PostToolUse: [
479
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_HOOK_SCRIPT}"` }] },
480
+ ],
481
+ },
482
+ }
483
+ writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
484
+ return AI_SETTINGS_PATH
485
+ }
486
+
487
+ // Same PostToolUse hook, for interactive chat sessions: posts raw tool output to
488
+ // the session endpoint, reading GITDONE_SESSION_ID from the per-turn env.
489
+ const AI_SESSION_HOOK_SCRIPT = join(AGENT_DIR, 'ai-session-hook.mjs')
490
+ const AI_SESSION_SETTINGS_PATH = join(AGENT_DIR, 'ai-session-settings.json')
491
+
492
+ function ensureAiSessionHookFiles() {
493
+ ensureAgentDir()
494
+ const hookSrc = [
495
+ "import process from 'node:process'",
496
+ "let data = ''",
497
+ "process.stdin.setEncoding('utf8')",
498
+ "process.stdin.on('data', (c) => { data += c })",
499
+ 'process.stdin.on(\'end\', async () => {',
500
+ ' try {',
501
+ " const ev = JSON.parse(data || '{}')",
502
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, sessionId = process.env.GITDONE_SESSION_ID',
503
+ ' if (url && key && sessionId) {',
504
+ " const name = ev.tool_name || 'tool'",
505
+ ' const o = ev.tool_output',
506
+ " let out = ''",
507
+ " if (typeof o === 'string') out = o",
508
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
509
+ " out = String(out || '').slice(0, 2000)",
510
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
511
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
512
+ " await fetch(url + '/api/v1/agent/ai-session/events', {",
513
+ " method: 'POST',",
514
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
515
+ " body: JSON.stringify({ sessionId, events: [{ role: 'TOOL_RESULT', text }] }),",
516
+ ' }).catch(() => {})',
517
+ ' }',
518
+ ' } catch {}',
519
+ ' process.exit(0)',
520
+ '})',
521
+ ].join('\n')
522
+ writeFileSync(AI_SESSION_HOOK_SCRIPT, hookSrc, 'utf8')
523
+
524
+ const nodePath = getNodePath()
525
+ const settings = {
526
+ hooks: {
527
+ PostToolUse: [
528
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_SESSION_HOOK_SCRIPT}"` }] },
529
+ ],
530
+ },
531
+ }
532
+ writeFileSync(AI_SESSION_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
533
+ return AI_SESSION_SETTINGS_PATH
534
+ }
535
+
536
+ // Run a headless Claude Code session for an `ai_run` command and stream its
537
+ // output back. Long-running and fire-and-forget: it wires up async handlers and
538
+ // returns immediately so the agent's snapshot loop is never blocked.
539
+ function runAiCommand(cfg, cmd, repoPath) {
540
+ const runId = cmd.payload?.runId
541
+ const prompt = cmd.payload?.prompt ?? ''
542
+ const allowCommit = cmd.payload?.allowCommit === true
543
+ if (!runId || !prompt) {
544
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
545
+ return
546
+ }
547
+
548
+ const { path: claudePath, shell } = findClaude()
549
+
550
+ let settingsPath
551
+ try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
552
+
553
+ // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
554
+ // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
555
+ // mangled under the claude.cmd npm shim (shell:true cmd.exe splits/strips
556
+ // it), so Claude received no prompt and fell back to an empty stdin
557
+ // ("no stdin data received…"), then ran blind off whatever was in the repo.
558
+ // Piping it to stdin is robust for both the native exe and the shim.
559
+ const args = [
560
+ '-p',
561
+ '--output-format', 'stream-json',
562
+ '--verbose',
563
+ '--permission-mode', 'acceptEdits',
564
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
565
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
566
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
567
+ ...(settingsPath ? ['--settings', settingsPath] : []),
568
+ ]
569
+
570
+ // The PostToolUse hook reads these from its env to post raw tool output.
571
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
572
+
573
+ // Batch events on a timer so we don't hammer the server per token/line.
574
+ let pending = []
575
+ let flushing = false
576
+ const flush = async () => {
577
+ if (flushing || pending.length === 0) return
578
+ flushing = true
579
+ const batch = pending; pending = []
580
+ await postRunEvents(cfg, runId, batch)
581
+ flushing = false
582
+ }
583
+ const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
584
+ const timer = setInterval(flush, 800)
585
+
586
+ log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
587
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
588
+
589
+ let child
590
+ try {
591
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
592
+ } catch (err) {
593
+ clearInterval(timer)
594
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
595
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
596
+ return
597
+ }
598
+
599
+ // Write the prompt to stdin and close it so Claude reads it as the task.
600
+ // Swallow EPIPE in case the process exits before we finish writing.
601
+ if (child.stdin) {
602
+ child.stdin.on('error', () => {})
603
+ try { child.stdin.write(prompt); child.stdin.end() }
604
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
605
+ }
606
+
607
+ let buf = ''
608
+ child.stdout.on('data', (d) => {
609
+ buf += d.toString()
610
+ let nl
611
+ while ((nl = buf.indexOf('\n')) >= 0) {
612
+ const line = buf.slice(0, nl).trim()
613
+ buf = buf.slice(nl + 1)
614
+ if (line) parseStreamLine(line, push)
615
+ }
616
+ })
617
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
618
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
619
+ child.on('close', async (code) => {
620
+ clearInterval(timer)
621
+ if (buf.trim()) parseStreamLine(buf.trim(), push)
622
+ await flush()
623
+ const ok = code === 0
624
+ await postRunEvents(
625
+ cfg, runId,
626
+ [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }],
627
+ ok ? 'done' : 'error',
628
+ ok ? 'ok' : `exit ${code}`,
629
+ )
630
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
631
+ log(`■ ai_run ${runId} приключи (code ${code})`)
632
+ })
633
+ }
634
+
635
+ // Run one turn of an interactive chat session: `claude -p` (resuming the prior
636
+ // Claude session when known) with the user's message on stdin, streaming the
637
+ // reply back to the session console. Long-running + fire-and-forget like ai_run.
638
+ function runAiChat(cfg, cmd, repoPath) {
639
+ const sessionId = cmd.payload?.sessionId
640
+ const prompt = cmd.payload?.prompt ?? ''
641
+ const claudeSessionId = cmd.payload?.claudeSessionId || null
642
+ const allowCommit = cmd.payload?.allowCommit === true
643
+ if (!sessionId || !prompt) {
644
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
645
+ return
646
+ }
647
+
648
+ const { path: claudePath, shell } = findClaude()
649
+
650
+ let settingsPath
651
+ try { settingsPath = ensureAiSessionHookFiles() } catch (e) { log(`✗ ai session hook setup failed: ${e.message}`) }
652
+
653
+ const args = [
654
+ '-p',
655
+ '--output-format', 'stream-json',
656
+ '--verbose',
657
+ '--permission-mode', 'acceptEdits',
658
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
659
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
660
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
661
+ ...(claudeSessionId ? ['--resume', claudeSessionId] : []),
662
+ ...(settingsPath ? ['--settings', settingsPath] : []),
663
+ ]
664
+
665
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
666
+
667
+ // Map console kinds → session transcript roles (model text → ASSISTANT).
668
+ const roleFor = (kind) => (kind === 'TEXT' ? 'ASSISTANT' : kind)
669
+ let pending = []
670
+ let capturedSession = null // Claude's session id, sent (idempotently) for --resume
671
+ let flushing = false
672
+ const flush = async () => {
673
+ if (flushing || pending.length === 0) return
674
+ flushing = true
675
+ const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
676
+ await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined)
677
+ flushing = false
678
+ }
679
+ const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
680
+ const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
681
+ const timer = setInterval(flush, 800)
682
+
683
+ log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
684
+
685
+ let child
686
+ try {
687
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
688
+ } catch (err) {
689
+ clearInterval(timer)
690
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
691
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
692
+ return
693
+ }
694
+
695
+ if (child.stdin) {
696
+ child.stdin.on('error', () => {})
697
+ try { child.stdin.write(prompt); child.stdin.end() }
698
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
699
+ }
700
+
701
+ let buf = ''
702
+ child.stdout.on('data', (d) => {
703
+ buf += d.toString()
704
+ let nl
705
+ while ((nl = buf.indexOf('\n')) >= 0) {
706
+ const line = buf.slice(0, nl).trim()
707
+ buf = buf.slice(nl + 1)
708
+ if (line) parseStreamLine(line, push, onInit)
709
+ }
710
+ })
711
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
712
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
713
+ child.on('close', async (code) => {
714
+ clearInterval(timer)
715
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
716
+ await flush()
717
+ const ok = code === 0
718
+ await postSessionEvents(
719
+ cfg, sessionId,
720
+ ok ? [] : [{ role: 'SYSTEM', text: `✗ Ходът приключи с код ${code}.` }],
721
+ ok ? 'idle' : 'error',
722
+ capturedSession || undefined,
723
+ )
724
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
725
+ log(`■ ai_chat ${sessionId} приключи (code ${code})`)
726
+ })
727
+ }
728
+
729
+ async function executeCommand(cfg, cmd, repoPath) {
730
+ // ai_run is long-running + streaming — launch it in the background and return
731
+ // so the snapshot loop keeps going. It reports its own result on exit.
732
+ if (cmd.type === 'ai_run') {
733
+ runAiCommand(cfg, cmd, repoPath)
734
+ return
735
+ }
736
+ // ai_chat one interactive turn, same fire-and-forget model.
737
+ if (cmd.type === 'ai_chat') {
738
+ runAiChat(cfg, cmd, repoPath)
739
+ return
740
+ }
741
+
742
+ let result = 'ok'
743
+ let status = 'done'
744
+ const auth = cfg.auth?.[repoPath] ?? null
745
+ try {
746
+ if (cmd.type === 'commit') {
747
+ const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
748
+ // When the UI sends a list of selected files, stage ONLY those so the
749
+ // resulting commit (and the push that follows) contains just the checked
750
+ // files. No list → stage everything (old behaviour / older UIs).
751
+ const files = Array.isArray(cmd.payload?.files)
752
+ ? cmd.payload.files.filter((f) => typeof f === 'string' && f.trim())
753
+ : []
754
+ if (files.length) {
755
+ const quoted = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ')
756
+ git(`git add -- ${quoted}`, repoPath)
757
+ } else {
758
+ git('git add -A', repoPath)
759
+ }
760
+ result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
761
+ } else if (cmd.type === 'push' || cmd.type === 'pull') {
762
+ result = pushOrPull(cmd.type, repoPath, auth)
763
+ } else if (cmd.type === 'discard') {
764
+ result = discardFile(repoPath, cmd.payload?.file)
765
+ }
766
+ log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
767
+ } catch (err) {
768
+ status = 'error'
769
+ result = redact(err.message, auth?.token)
770
+ // Cached token rejected (e.g. rotated/expired on the server) → drop it so
771
+ // the next snapshot reports hasGithubAuth:false and the server reissues one.
772
+ if (auth && AUTH_FAIL.test(err.message)) {
773
+ delete cfg.auth[repoPath]
774
+ writeConfig(cfg)
775
+ result += ' токенът е изчистен, пробвай командата пак'
776
+ }
777
+ log(`✗ ${cmd.type} failed @ ${repoPath}: ${result}`)
778
+ }
779
+ await reportCommandResult(cfg, cmd.id, status, result)
780
+ }
781
+
782
+ // Report discovered repos + machine info, get back which repos to track.
783
+ async function sync(cfg, discovered) {
784
+ const data = await api(cfg, '/api/v1/agent/sync', {
785
+ machineId: cfg.machineId,
786
+ hostname: cfg.hostname,
787
+ agentVersion: AGENT_VERSION,
788
+ roots: cfg.roots,
789
+ repos: discovered,
790
+ })
791
+ return data.tracked ?? []
792
+ }
793
+
794
+ // Push one tracked repo's snapshot and run any pending commands for it.
795
+ async function pushSnapshot(cfg, repo) {
796
+ const snapshot = getSnapshot(repo.path)
797
+ const data = await api(cfg, '/api/v1/agent/snapshot', {
798
+ machineId: cfg.machineId,
799
+ path: repo.path,
800
+ repoName: repo.name,
801
+ // Tells the server whether we already hold a push token for this repo.
802
+ // Always a boolean → marks us as a token-capable agent.
803
+ hasGithubAuth: !!cfg.auth?.[repo.path],
804
+ ...snapshot,
805
+ })
806
+ // Server issued a (fresh) push token — cache it on disk so we ask only once.
807
+ if (data.githubAuth?.token && data.githubAuth?.repo) {
808
+ cfg.auth = cfg.auth ?? {}
809
+ cfg.auth[repo.path] = { token: data.githubAuth.token, repo: data.githubAuth.repo }
810
+ writeConfig(cfg)
811
+ }
812
+ for (const cmd of data.commands ?? []) {
813
+ await executeCommand(cfg, cmd, repo.path)
814
+ }
815
+ return snapshot
816
+ }
817
+
818
+ // ─── Main ────────────────────────────────────────────────────────────────────
819
+
820
+ async function runLoop(cfg) {
821
+ log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
822
+ log(` roots: ${cfg.roots.join(', ') || '(none add one with --root)'}`)
823
+
824
+ async function tick() {
825
+ try {
826
+ const discovered = scanRepos(cfg.roots)
827
+ const tracked = await sync(cfg, discovered)
828
+ let pushed = 0
829
+ for (const repo of tracked) {
830
+ try { await pushSnapshot(cfg, repo); pushed++ }
831
+ catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
832
+ }
833
+ log(`✓ tick discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
834
+ } catch (err) {
835
+ log(`✗ sync error: ${err.message}`)
836
+ }
837
+ }
838
+
839
+ await tick()
840
+ setInterval(tick, cfg.interval * 1000)
841
+ }
842
+
843
+ async function main() {
844
+ const args = parseArgs()
845
+
846
+ if (args.doctor) {
847
+ runDoctor()
848
+ process.exit(0)
849
+ }
850
+
851
+ if (args.uninstall) {
852
+ uninstallStartup()
853
+ process.exit(0)
854
+ }
855
+
856
+ // Setup / install path: merge CLI args into config, optionally (re)install.
857
+ if (args.install || args.key || args.roots.length || args.url || args.interval) {
858
+ const cfg = buildConfig(args)
859
+ if (!cfg.key) {
860
+ console.error('Error: --key is required on first setup (e.g. --key=gdo_xxx)')
861
+ process.exit(1)
862
+ }
863
+ writeConfig(cfg)
864
+
865
+ if (args.install) {
866
+ installStartup()
867
+ console.log(`✓ Инсталиран! При следващото влизане в Windows агентът ще стартира автоматично.`)
868
+ console.log(` Агент : ${STABLE_AGENT}`)
869
+ console.log(` Config : ${CONFIG_PATH}`)
870
+ console.log(` Лог : ${LOG_PATH}`)
871
+ console.log(` Roots :`)
872
+ for (const r of cfg.roots) console.log(` - ${r}`)
873
+ console.log()
874
+
875
+ // Launch silently in the background right now.
876
+ const child = spawn('wscript.exe', [getVbsPath()], { detached: true, stdio: 'ignore' })
877
+ child.unref()
878
+ console.log('✓ Агентът е стартиран в заден план — можеш да затвориш прозореца.')
879
+ console.log(' Сега избери кои репота да следи от gitdone.eu/github.')
880
+ process.exit(0)
881
+ }
882
+
883
+ // --key/--root without --install: just run the loop in the foreground.
884
+ await runLoop(cfg)
885
+ return
886
+ }
887
+
888
+ // No args → running mode (this is how autostart launches us): read config.
889
+ const cfg = readConfig()
890
+ if (!cfg || !cfg.key) {
891
+ console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
892
+ process.exit(1)
893
+ }
894
+ await runLoop(cfg)
895
+ }
896
+
897
+ main()