gitdone-agent 0.5.2 → 0.5.4

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 +932 -863
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,863 +1,932 @@
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.4'
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. Returns `found:false` when nothing was
115
+ // located, so callers can show a clear message instead of spawning bare
116
+ // `claude` and letting cmd.exe emit "'claude' is not recognized…".
117
+ function findClaude() {
118
+ const tryCmd = (c) => {
119
+ try {
120
+ const out = execSync(c, { encoding: 'utf8' }).trim()
121
+ return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
122
+ } catch { return [] }
123
+ }
124
+ const cands = [...tryCmd('where claude'), ...tryCmd('which claude')]
125
+
126
+ // The agent is auto-started at login, so its PATH is frozen at that moment —
127
+ // a `claude` installed (or a PATH entry added) afterwards is invisible to
128
+ // `where`/`which` above. Probe the well-known install locations directly so a
129
+ // freshly-installed CLI works without a Windows re-login.
130
+ const home = homedir()
131
+ const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
132
+ for (const p of [
133
+ join(home, '.local', 'bin', 'claude.exe'), // Windows native installer
134
+ join(appData, 'npm', 'claude.cmd'), // Windows npm global shim
135
+ join(home, '.local', 'bin', 'claude'), // macOS/Linux native installer
136
+ '/usr/local/bin/claude', // Homebrew (Intel) / manual install
137
+ '/opt/homebrew/bin/claude', // Homebrew (Apple silicon)
138
+ ]) {
139
+ if (existsSync(p) && !cands.includes(p)) cands.push(p)
140
+ }
141
+
142
+ const exe = cands.find((p) => /\.exe$/i.test(p))
143
+ if (exe) return { path: exe, shell: false, found: true }
144
+ if (cands.length) return { path: cands[0], shell: true, found: true }
145
+ return { path: 'claude', shell: true, found: false }
146
+ }
147
+
148
+ // Kill any previously-running agent processes so an update applies WITHOUT a
149
+ // Windows re-login. Targets node processes launched from the agent dir
150
+ // (~/.gitdone-agent), excluding ourselves — the npx installer runs from the
151
+ // npx cache, so it won't match. Best-effort; failures are non-fatal.
152
+ function stopRunningAgents() {
153
+ const ps = [
154
+ "$ErrorActionPreference='SilentlyContinue'",
155
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" |",
156
+ ` Where-Object { $_.CommandLine -like '*.gitdone-agent*' -and $_.ProcessId -ne ${process.pid} } |`,
157
+ ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
158
+ ].join('\n')
159
+ try {
160
+ ensureAgentDir()
161
+ const scriptPath = join(AGENT_DIR, 'stop-agents.ps1')
162
+ writeFileSync(scriptPath, ps, 'utf8')
163
+ execSync(`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, { stdio: 'ignore' })
164
+ } catch { /* best-effort at worst the old agent lingers until next login */ }
165
+ }
166
+
167
+ function installStartup() {
168
+ ensureAgentDir()
169
+ const nodePath = getNodePath()
170
+
171
+ // Stop the previously-running agent(s) first so we don't end up with the old
172
+ // build still reporting alongside the new one until the next Windows login.
173
+ stopRunningAgents()
174
+
175
+ // Copy this script to a stable location. process.argv[1] may point inside an
176
+ // npx cache dir that gets purged later — referencing it from autostart would
177
+ // silently break on the next login. A stable copy survives.
178
+ try {
179
+ copyFileSync(resolve(process.argv[1]), STABLE_AGENT)
180
+ } catch (err) {
181
+ console.error(`Warning: could not copy agent to ${STABLE_AGENT}: ${err.message}`)
182
+ }
183
+ const agentPath = existsSync(STABLE_AGENT) ? STABLE_AGENT : resolve(process.argv[1])
184
+
185
+ // The running agent reads everything from config.json — no args needed.
186
+ // VBScript runs it silently so no console window pops up on login.
187
+ const vbs = [
188
+ 'Set WshShell = CreateObject("WScript.Shell")',
189
+ `WshShell.Run """${nodePath}"" ""${agentPath}""", 0, False`,
190
+ ].join('\r\n')
191
+
192
+ writeFileSync(getVbsPath(), vbs, 'utf8')
193
+ }
194
+
195
+ function uninstallStartup() {
196
+ const vbsPath = getVbsPath()
197
+ if (existsSync(vbsPath)) {
198
+ unlinkSync(vbsPath)
199
+ console.log(`✓ Премахнат автостарт за gitdone-agent`)
200
+ } else {
201
+ console.log(`Не е намерен автостарт (${vbsPath})`)
202
+ }
203
+ }
204
+
205
+ // ─── Doctor ────────────────────────────────────────────────────────────────────
206
+
207
+ function runDoctor() {
208
+ const cfg = readConfig()
209
+ console.log('gitdone-agent doctor')
210
+ console.log(` node : ${getNodePath()}`)
211
+ console.log(` agent dir : ${AGENT_DIR} ${existsSync(AGENT_DIR) ? '(ok)' : '(MISSING)'}`)
212
+ console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
213
+ console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
214
+ console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
215
+ console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
216
+ const claude = findClaude()
217
+ console.log(` claude cli : ${claude.found ? claude.path : 'NOT FOUND — инсталирай от claude.ai/code и рестартирай агента'}`)
218
+ if (cfg) {
219
+ console.log(` machineId : ${cfg.machineId}`)
220
+ console.log(` server : ${cfg.url}`)
221
+ console.log(` interval : ${cfg.interval}s`)
222
+ console.log(` roots :`)
223
+ for (const r of cfg.roots ?? []) console.log(` - ${r} ${existsSync(r) ? '' : '(MISSING)'}`)
224
+ if (cfg.roots?.length) {
225
+ const found = scanRepos(cfg.roots)
226
+ console.log(` found repos : ${found.length}`)
227
+ for (const r of found) console.log(` - ${r.name} (${r.path})`)
228
+ }
229
+ }
230
+ }
231
+
232
+ // ─── Git helpers ─────────────────────────────────────────────────────────────
233
+
234
+ function git(cmd, cwd) {
235
+ try {
236
+ return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
237
+ } catch {
238
+ return ''
239
+ }
240
+ }
241
+
242
+ // Like git(), but surfaces failures (with stderr) instead of swallowing them —
243
+ // used for push/pull where we need to detect auth rejection.
244
+ function gitTry(cmd, cwd) {
245
+ try {
246
+ const out = execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
247
+ return { ok: true, out }
248
+ } catch (err) {
249
+ const out = (err.stderr || err.stdout || err.message || '').toString().trim()
250
+ return { ok: false, out }
251
+ }
252
+ }
253
+
254
+ // Never let a token reach the log file or the server's command-result.
255
+ function redact(text, token) {
256
+ if (!text || !token) return text
257
+ return text.split(token).join('***')
258
+ }
259
+
260
+ // https://x-access-token:<token>@github.com/<owner>/<repo>.git — an ad-hoc URL
261
+ // passed straight to push/pull, so it never gets written into .git/config.
262
+ function authUrl(auth) {
263
+ const repo = auth.repo.replace(/\.git$/i, '')
264
+ return `https://x-access-token:${auth.token}@github.com/${repo}.git`
265
+ }
266
+
267
+ // Push/pull using the server-issued token when we have one; otherwise fall back
268
+ // to the machine's own git credentials (old behaviour). Throws on failure.
269
+ function pushOrPull(type, repoPath, auth) {
270
+ const branch = git('git branch --show-current', repoPath) || 'HEAD'
271
+ let cmd
272
+ if (auth) {
273
+ const url = authUrl(auth)
274
+ cmd = type === 'push' ? `git push "${url}" HEAD:${branch}` : `git pull "${url}" ${branch}`
275
+ } else {
276
+ cmd = type === 'push' ? 'git push' : 'git pull'
277
+ }
278
+ const r = gitTry(cmd, repoPath)
279
+ if (!r.ok) throw new Error(r.out || `${type} failed`)
280
+ return r.out || (type === 'push' ? 'pushed' : 'pulled')
281
+ }
282
+
283
+ const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
284
+
285
+ // Discard all local changes to a SINGLE file, so it disappears from the repo's
286
+ // "changes". Mirrors GitHub Desktop's "Discard changes": unstage first, then
287
+ // either restore the file from HEAD (tracked) or delete it (new/untracked).
288
+ function discardFile(repoPath, file) {
289
+ if (!file) throw new Error('no file')
290
+ // Unstage so index + worktree get reverted together (no-op if not staged).
291
+ gitTry(`git reset -q HEAD -- "${file}"`, repoPath)
292
+ // Does the file exist in the last commit? If so we restore its content; if
293
+ // not, it's a newly-added/untracked file and discarding means removing it.
294
+ const inHead = gitTry(`git cat-file -e "HEAD:${file}"`, repoPath).ok
295
+ if (inHead) {
296
+ const r = gitTry(`git checkout HEAD -- "${file}"`, repoPath)
297
+ if (!r.ok) throw new Error(r.out || 'checkout failed')
298
+ return 'discarded'
299
+ }
300
+ // New/untracked file (now unstaged) — drop it from the working tree.
301
+ const r = gitTry(`git clean -fdq -- "${file}"`, repoPath)
302
+ if (!r.ok) throw new Error(r.out || 'clean failed')
303
+ return 'discarded (new file removed)'
304
+ }
305
+
306
+ function parseDiffByFile(diffOutput) {
307
+ const files = {}
308
+ if (!diffOutput) return files
309
+ const sections = diffOutput.split(/(?=^diff --git )/m)
310
+ for (const section of sections) {
311
+ if (!section.trim()) continue
312
+ const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
313
+ if (match) files[match[1]] = section
314
+ }
315
+ return files
316
+ }
317
+
318
+ function getSnapshot(repoPath) {
319
+ const branch = git('git branch --show-current', repoPath) || 'HEAD'
320
+
321
+ const statusLines = git('git status --porcelain', repoPath).split('\n').filter(Boolean)
322
+ const modified = []
323
+ const staged = []
324
+ const statuses = {}
325
+ for (const line of statusLines) {
326
+ const xy = line.slice(0, 2)
327
+ const file = line.slice(3)
328
+ statuses[file] = xy
329
+ if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
330
+ if (xy[1] !== ' ') modified.push(file)
331
+ }
332
+
333
+ const aheadBy = parseInt(git('git rev-list --count @{u}..HEAD', repoPath), 10) || 0
334
+ const behindBy = parseInt(git('git rev-list --count HEAD..@{u}', repoPath), 10) || 0
335
+
336
+ const logLine = git('git log -1 --pretty=format:%H|%s|%an|%aI', repoPath)
337
+ let lastCommit = null
338
+ if (logLine) {
339
+ const [sha, message, author, date] = logLine.split('|')
340
+ lastCommit = { sha, message, author, date }
341
+ }
342
+
343
+ const diffs = parseDiffByFile(git('git diff HEAD', repoPath))
344
+
345
+ // Origin remote lets the server auto-detect the GitHub repo for the History tab.
346
+ const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
347
+
348
+ return { branch, modified, staged, aheadBy, behindBy, lastCommit, statuses, diffs, remoteUrl }
349
+ }
350
+
351
+ // ─── Repo discovery ─────────────────────────────────────────────────────────────
352
+ // Walk each root looking for directories that contain a `.git` entry. Stop
353
+ // descending once a repo is found (don't recurse into submodules/nested repos),
354
+ // skip noisy dirs, and cap depth so a huge tree can't hang a tick.
355
+
356
+ const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', '.cache', 'vendor', '.venv', '__pycache__'])
357
+
358
+ function scanRepos(roots, maxDepth = 5) {
359
+ const found = []
360
+ const seen = new Set()
361
+
362
+ function walk(dir, depth) {
363
+ if (depth > maxDepth) return
364
+ let entries
365
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
366
+
367
+ if (entries.some((e) => e.name === '.git')) {
368
+ const path = resolve(dir)
369
+ if (!seen.has(path)) {
370
+ seen.add(path)
371
+ found.push({ name: path.split(/[\\/]/).pop() ?? path, path })
372
+ }
373
+ return // a repo — don't descend further
374
+ }
375
+
376
+ for (const e of entries) {
377
+ if (!e.isDirectory()) continue
378
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
379
+ walk(join(dir, e.name), depth + 1)
380
+ }
381
+ }
382
+
383
+ for (const root of roots) walk(resolve(root), 0)
384
+ return found
385
+ }
386
+
387
+ // ─── Server sync + commands ─────────────────────────────────────────────────────
388
+
389
+ async function api(cfg, path, body) {
390
+ const res = await fetch(`${cfg.url}${path}`, {
391
+ method: 'POST',
392
+ headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
393
+ body: JSON.stringify(body),
394
+ })
395
+ if (!res.ok) {
396
+ const text = await res.text().catch(() => '')
397
+ throw new Error(`HTTP ${res.status} ${path}: ${text}`)
398
+ }
399
+ return res.json().catch(() => ({}))
400
+ }
401
+
402
+ async function reportCommandResult(cfg, id, status, result) {
403
+ await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
404
+ }
405
+
406
+ // Post a batch of console events (and optional lifecycle status) for an AiRun.
407
+ async function postRunEvents(cfg, runId, events, status, result) {
408
+ await api(cfg, '/api/v1/agent/ai-run/events', {
409
+ runId,
410
+ events,
411
+ ...(status ? { status } : {}),
412
+ ...(result !== undefined ? { result } : {}),
413
+ }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
414
+ }
415
+
416
+ // Post transcript lines (and optional turn status / Claude session id) for an
417
+ // interactive AiSession chat turn.
418
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId) {
419
+ await api(cfg, '/api/v1/agent/ai-session/events', {
420
+ sessionId,
421
+ events,
422
+ ...(status ? { status } : {}),
423
+ ...(claudeSessionId ? { claudeSessionId } : {}),
424
+ }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
425
+ }
426
+
427
+ // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
428
+ // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
429
+ // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
430
+ function parseStreamLine(line, push, onInit) {
431
+ let ev
432
+ try { ev = JSON.parse(line) } catch { return }
433
+ if (ev.type === 'system') {
434
+ if (ev.subtype === 'init') {
435
+ push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
436
+ // Surface Claude's own session id so chat sessions can --resume it.
437
+ if (ev.session_id && typeof onInit === 'function') onInit(ev.session_id)
438
+ }
439
+ return
440
+ }
441
+ if (ev.type === 'assistant' && ev.message?.content) {
442
+ for (const block of ev.message.content) {
443
+ if (block.type === 'text' && block.text?.trim()) {
444
+ push('TEXT', block.text.trim())
445
+ } else if (block.type === 'tool_use') {
446
+ const input = block.input ? JSON.stringify(block.input) : ''
447
+ push('TOOL_CALL', `${block.name}${input ? ` ${input.slice(0, 400)}` : ''}`)
448
+ }
449
+ }
450
+ return
451
+ }
452
+ if (ev.type === 'result' && ev.subtype && ev.subtype !== 'success') {
453
+ push('SYSTEM', `Резултат: ${ev.subtype}`)
454
+ }
455
+ }
456
+
457
+ // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
458
+ // (Bash stdout, edit results, …) to the run's console, plus the settings file
459
+ // that registers it. The hook reads GITDONE_* from its env (set per run on the
460
+ // claude process). Returns the settings path to pass via --settings.
461
+ const AI_HOOK_SCRIPT = join(AGENT_DIR, 'ai-run-hook.mjs')
462
+ const AI_SETTINGS_PATH = join(AGENT_DIR, 'ai-run-settings.json')
463
+
464
+ function ensureAiHookFiles() {
465
+ ensureAgentDir()
466
+ const hookSrc = [
467
+ "import process from 'node:process'",
468
+ "let data = ''",
469
+ "process.stdin.setEncoding('utf8')",
470
+ "process.stdin.on('data', (c) => { data += c })",
471
+ 'process.stdin.on(\'end\', async () => {',
472
+ ' try {',
473
+ " const ev = JSON.parse(data || '{}')",
474
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, runId = process.env.GITDONE_RUN_ID',
475
+ ' if (url && key && runId) {',
476
+ " const name = ev.tool_name || 'tool'",
477
+ ' const o = ev.tool_output',
478
+ " let out = ''",
479
+ " if (typeof o === 'string') out = o",
480
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
481
+ " out = String(out || '').slice(0, 2000)",
482
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
483
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
484
+ " await fetch(url + '/api/v1/agent/ai-run/events', {",
485
+ " method: 'POST',",
486
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
487
+ " body: JSON.stringify({ runId, events: [{ kind: 'TOOL_RESULT', text }] }),",
488
+ ' }).catch(() => {})',
489
+ ' }',
490
+ ' } catch {}',
491
+ ' process.exit(0)',
492
+ '})',
493
+ ].join('\n')
494
+ writeFileSync(AI_HOOK_SCRIPT, hookSrc, 'utf8')
495
+
496
+ const nodePath = getNodePath()
497
+ const settings = {
498
+ hooks: {
499
+ PostToolUse: [
500
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_HOOK_SCRIPT}"` }] },
501
+ ],
502
+ },
503
+ }
504
+ writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
505
+ return AI_SETTINGS_PATH
506
+ }
507
+
508
+ // Same PostToolUse hook, for interactive chat sessions: posts raw tool output to
509
+ // the session endpoint, reading GITDONE_SESSION_ID from the per-turn env.
510
+ const AI_SESSION_HOOK_SCRIPT = join(AGENT_DIR, 'ai-session-hook.mjs')
511
+ const AI_SESSION_SETTINGS_PATH = join(AGENT_DIR, 'ai-session-settings.json')
512
+
513
+ function ensureAiSessionHookFiles() {
514
+ ensureAgentDir()
515
+ const hookSrc = [
516
+ "import process from 'node:process'",
517
+ "let data = ''",
518
+ "process.stdin.setEncoding('utf8')",
519
+ "process.stdin.on('data', (c) => { data += c })",
520
+ 'process.stdin.on(\'end\', async () => {',
521
+ ' try {',
522
+ " const ev = JSON.parse(data || '{}')",
523
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, sessionId = process.env.GITDONE_SESSION_ID',
524
+ ' if (url && key && sessionId) {',
525
+ " const name = ev.tool_name || 'tool'",
526
+ ' const o = ev.tool_output',
527
+ " let out = ''",
528
+ " if (typeof o === 'string') out = o",
529
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
530
+ " out = String(out || '').slice(0, 2000)",
531
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
532
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
533
+ " await fetch(url + '/api/v1/agent/ai-session/events', {",
534
+ " method: 'POST',",
535
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
536
+ " body: JSON.stringify({ sessionId, events: [{ role: 'TOOL_RESULT', text }] }),",
537
+ ' }).catch(() => {})',
538
+ ' }',
539
+ ' } catch {}',
540
+ ' process.exit(0)',
541
+ '})',
542
+ ].join('\n')
543
+ writeFileSync(AI_SESSION_HOOK_SCRIPT, hookSrc, 'utf8')
544
+
545
+ const nodePath = getNodePath()
546
+ const settings = {
547
+ hooks: {
548
+ PostToolUse: [
549
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_SESSION_HOOK_SCRIPT}"` }] },
550
+ ],
551
+ },
552
+ }
553
+ writeFileSync(AI_SESSION_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
554
+ return AI_SESSION_SETTINGS_PATH
555
+ }
556
+
557
+ // Run a headless Claude Code session for an `ai_run` command and stream its
558
+ // output back. Long-running and fire-and-forget: it wires up async handlers and
559
+ // returns immediately so the agent's snapshot loop is never blocked.
560
+ function runAiCommand(cfg, cmd, repoPath) {
561
+ const runId = cmd.payload?.runId
562
+ const prompt = cmd.payload?.prompt ?? ''
563
+ const allowCommit = cmd.payload?.allowCommit === true
564
+ if (!runId || !prompt) {
565
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
566
+ return
567
+ }
568
+
569
+ const { path: claudePath, shell, found } = findClaude()
570
+ if (!found) {
571
+ const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
572
+ log(`✗ ai_run ${runId}: claude not found`)
573
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', 'claude not found')
574
+ reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
575
+ return
576
+ }
577
+
578
+ let settingsPath
579
+ try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
580
+
581
+ // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
582
+ // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
583
+ // mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
584
+ // it), so Claude received no prompt and fell back to an empty stdin
585
+ // ("no stdin data received…"), then ran blind off whatever was in the repo.
586
+ // Piping it to stdin is robust for both the native exe and the shim.
587
+ const args = [
588
+ '-p',
589
+ '--output-format', 'stream-json',
590
+ '--verbose',
591
+ '--permission-mode', 'acceptEdits',
592
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
593
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
594
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
595
+ ...(settingsPath ? ['--settings', settingsPath] : []),
596
+ ]
597
+
598
+ // The PostToolUse hook reads these from its env to post raw tool output.
599
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
600
+
601
+ // Batch events on a timer so we don't hammer the server per token/line.
602
+ let pending = []
603
+ let flushing = false
604
+ const flush = async () => {
605
+ if (flushing || pending.length === 0) return
606
+ flushing = true
607
+ const batch = pending; pending = []
608
+ await postRunEvents(cfg, runId, batch)
609
+ flushing = false
610
+ }
611
+ const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
612
+ const timer = setInterval(flush, 800)
613
+
614
+ log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
615
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
616
+
617
+ let child
618
+ try {
619
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
620
+ } catch (err) {
621
+ clearInterval(timer)
622
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
623
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
624
+ return
625
+ }
626
+
627
+ // Write the prompt to stdin and close it so Claude reads it as the task.
628
+ // Swallow EPIPE in case the process exits before we finish writing.
629
+ if (child.stdin) {
630
+ child.stdin.on('error', () => {})
631
+ try { child.stdin.write(prompt); child.stdin.end() }
632
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
633
+ }
634
+
635
+ let buf = ''
636
+ child.stdout.on('data', (d) => {
637
+ buf += d.toString()
638
+ let nl
639
+ while ((nl = buf.indexOf('\n')) >= 0) {
640
+ const line = buf.slice(0, nl).trim()
641
+ buf = buf.slice(nl + 1)
642
+ if (line) parseStreamLine(line, push)
643
+ }
644
+ })
645
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
646
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
647
+ child.on('close', async (code) => {
648
+ clearInterval(timer)
649
+ if (buf.trim()) parseStreamLine(buf.trim(), push)
650
+ await flush()
651
+ const ok = code === 0
652
+ await postRunEvents(
653
+ cfg, runId,
654
+ [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }],
655
+ ok ? 'done' : 'error',
656
+ ok ? 'ok' : `exit ${code}`,
657
+ )
658
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
659
+ log(`■ ai_run ${runId} приключи (code ${code})`)
660
+ })
661
+ }
662
+
663
+ // Run one turn of an interactive chat session: `claude -p` (resuming the prior
664
+ // Claude session when known) with the user's message on stdin, streaming the
665
+ // reply back to the session console. Long-running + fire-and-forget like ai_run.
666
+ function runAiChat(cfg, cmd, repoPath) {
667
+ const sessionId = cmd.payload?.sessionId
668
+ const prompt = cmd.payload?.prompt ?? ''
669
+ const claudeSessionId = cmd.payload?.claudeSessionId || null
670
+ const allowCommit = cmd.payload?.allowCommit === true
671
+ if (!sessionId || !prompt) {
672
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
673
+ return
674
+ }
675
+
676
+ const { path: claudePath, shell, found } = findClaude()
677
+ if (!found) {
678
+ const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
679
+ log(`✗ ai_chat ${sessionId}: claude not found`)
680
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
681
+ reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
682
+ return
683
+ }
684
+
685
+ let settingsPath
686
+ try { settingsPath = ensureAiSessionHookFiles() } catch (e) { log(`✗ ai session hook setup failed: ${e.message}`) }
687
+
688
+ const args = [
689
+ '-p',
690
+ '--output-format', 'stream-json',
691
+ '--verbose',
692
+ '--permission-mode', 'acceptEdits',
693
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
694
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
695
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
696
+ ...(claudeSessionId ? ['--resume', claudeSessionId] : []),
697
+ ...(settingsPath ? ['--settings', settingsPath] : []),
698
+ ]
699
+
700
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
701
+
702
+ // Map console kinds → session transcript roles (model text → ASSISTANT).
703
+ const roleFor = (kind) => (kind === 'TEXT' ? 'ASSISTANT' : kind)
704
+ let pending = []
705
+ let capturedSession = null // Claude's session id, sent (idempotently) for --resume
706
+ let flushing = false
707
+ const flush = async () => {
708
+ if (flushing || pending.length === 0) return
709
+ flushing = true
710
+ const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
711
+ await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined)
712
+ flushing = false
713
+ }
714
+ const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
715
+ const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
716
+ const timer = setInterval(flush, 800)
717
+
718
+ log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
719
+
720
+ let child
721
+ try {
722
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
723
+ } catch (err) {
724
+ clearInterval(timer)
725
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
726
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
727
+ return
728
+ }
729
+
730
+ if (child.stdin) {
731
+ child.stdin.on('error', () => {})
732
+ try { child.stdin.write(prompt); child.stdin.end() }
733
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
734
+ }
735
+
736
+ let buf = ''
737
+ child.stdout.on('data', (d) => {
738
+ buf += d.toString()
739
+ let nl
740
+ while ((nl = buf.indexOf('\n')) >= 0) {
741
+ const line = buf.slice(0, nl).trim()
742
+ buf = buf.slice(nl + 1)
743
+ if (line) parseStreamLine(line, push, onInit)
744
+ }
745
+ })
746
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
747
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
748
+ child.on('close', async (code) => {
749
+ clearInterval(timer)
750
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
751
+ await flush()
752
+ const ok = code === 0
753
+ await postSessionEvents(
754
+ cfg, sessionId,
755
+ ok ? [] : [{ role: 'SYSTEM', text: `✗ Ходът приключи с код ${code}.` }],
756
+ ok ? 'idle' : 'error',
757
+ capturedSession || undefined,
758
+ )
759
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
760
+ log(`■ ai_chat ${sessionId} приключи (code ${code})`)
761
+ })
762
+ }
763
+
764
+ async function executeCommand(cfg, cmd, repoPath) {
765
+ // ai_run is long-running + streaming — launch it in the background and return
766
+ // so the snapshot loop keeps going. It reports its own result on exit.
767
+ if (cmd.type === 'ai_run') {
768
+ runAiCommand(cfg, cmd, repoPath)
769
+ return
770
+ }
771
+ // ai_chat — one interactive turn, same fire-and-forget model.
772
+ if (cmd.type === 'ai_chat') {
773
+ runAiChat(cfg, cmd, repoPath)
774
+ return
775
+ }
776
+
777
+ let result = 'ok'
778
+ let status = 'done'
779
+ const auth = cfg.auth?.[repoPath] ?? null
780
+ try {
781
+ if (cmd.type === 'commit') {
782
+ const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
783
+ // When the UI sends a list of selected files, stage ONLY those so the
784
+ // resulting commit (and the push that follows) contains just the checked
785
+ // files. No list → stage everything (old behaviour / older UIs).
786
+ const files = Array.isArray(cmd.payload?.files)
787
+ ? cmd.payload.files.filter((f) => typeof f === 'string' && f.trim())
788
+ : []
789
+ if (files.length) {
790
+ const quoted = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ')
791
+ git(`git add -- ${quoted}`, repoPath)
792
+ } else {
793
+ git('git add -A', repoPath)
794
+ }
795
+ result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
796
+ } else if (cmd.type === 'push' || cmd.type === 'pull') {
797
+ result = pushOrPull(cmd.type, repoPath, auth)
798
+ } else if (cmd.type === 'discard') {
799
+ result = discardFile(repoPath, cmd.payload?.file)
800
+ }
801
+ log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
802
+ } catch (err) {
803
+ status = 'error'
804
+ result = redact(err.message, auth?.token)
805
+ // Cached token rejected (e.g. rotated/expired on the server) → drop it so
806
+ // the next snapshot reports hasGithubAuth:false and the server reissues one.
807
+ if (auth && AUTH_FAIL.test(err.message)) {
808
+ delete cfg.auth[repoPath]
809
+ writeConfig(cfg)
810
+ result += ' — токенът е изчистен, пробвай командата пак'
811
+ }
812
+ log(`✗ ${cmd.type} failed @ ${repoPath}: ${result}`)
813
+ }
814
+ await reportCommandResult(cfg, cmd.id, status, result)
815
+ }
816
+
817
+ // Report discovered repos + machine info, get back which repos to track.
818
+ async function sync(cfg, discovered) {
819
+ const data = await api(cfg, '/api/v1/agent/sync', {
820
+ machineId: cfg.machineId,
821
+ hostname: cfg.hostname,
822
+ agentVersion: AGENT_VERSION,
823
+ roots: cfg.roots,
824
+ repos: discovered,
825
+ })
826
+ return data.tracked ?? []
827
+ }
828
+
829
+ // Push one tracked repo's snapshot and run any pending commands for it.
830
+ async function pushSnapshot(cfg, repo) {
831
+ const snapshot = getSnapshot(repo.path)
832
+ const data = await api(cfg, '/api/v1/agent/snapshot', {
833
+ machineId: cfg.machineId,
834
+ path: repo.path,
835
+ repoName: repo.name,
836
+ // Tells the server whether we already hold a push token for this repo.
837
+ // Always a boolean → marks us as a token-capable agent.
838
+ hasGithubAuth: !!cfg.auth?.[repo.path],
839
+ ...snapshot,
840
+ })
841
+ // Server issued a (fresh) push token — cache it on disk so we ask only once.
842
+ if (data.githubAuth?.token && data.githubAuth?.repo) {
843
+ cfg.auth = cfg.auth ?? {}
844
+ cfg.auth[repo.path] = { token: data.githubAuth.token, repo: data.githubAuth.repo }
845
+ writeConfig(cfg)
846
+ }
847
+ for (const cmd of data.commands ?? []) {
848
+ await executeCommand(cfg, cmd, repo.path)
849
+ }
850
+ return snapshot
851
+ }
852
+
853
+ // ─── Main ────────────────────────────────────────────────────────────────────
854
+
855
+ async function runLoop(cfg) {
856
+ log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
857
+ log(` roots: ${cfg.roots.join(', ') || '(none add one with --root)'}`)
858
+
859
+ async function tick() {
860
+ try {
861
+ const discovered = scanRepos(cfg.roots)
862
+ const tracked = await sync(cfg, discovered)
863
+ let pushed = 0
864
+ for (const repo of tracked) {
865
+ try { await pushSnapshot(cfg, repo); pushed++ }
866
+ catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
867
+ }
868
+ log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
869
+ } catch (err) {
870
+ log(`✗ sync error: ${err.message}`)
871
+ }
872
+ }
873
+
874
+ await tick()
875
+ setInterval(tick, cfg.interval * 1000)
876
+ }
877
+
878
+ async function main() {
879
+ const args = parseArgs()
880
+
881
+ if (args.doctor) {
882
+ runDoctor()
883
+ process.exit(0)
884
+ }
885
+
886
+ if (args.uninstall) {
887
+ uninstallStartup()
888
+ process.exit(0)
889
+ }
890
+
891
+ // Setup / install path: merge CLI args into config, optionally (re)install.
892
+ if (args.install || args.key || args.roots.length || args.url || args.interval) {
893
+ const cfg = buildConfig(args)
894
+ if (!cfg.key) {
895
+ console.error('Error: --key is required on first setup (e.g. --key=gdo_xxx)')
896
+ process.exit(1)
897
+ }
898
+ writeConfig(cfg)
899
+
900
+ if (args.install) {
901
+ installStartup()
902
+ console.log(`✓ Инсталиран! При следващото влизане в Windows агентът ще стартира автоматично.`)
903
+ console.log(` Агент : ${STABLE_AGENT}`)
904
+ console.log(` Config : ${CONFIG_PATH}`)
905
+ console.log(` Лог : ${LOG_PATH}`)
906
+ console.log(` Roots :`)
907
+ for (const r of cfg.roots) console.log(` - ${r}`)
908
+ console.log()
909
+
910
+ // Launch silently in the background right now.
911
+ const child = spawn('wscript.exe', [getVbsPath()], { detached: true, stdio: 'ignore' })
912
+ child.unref()
913
+ console.log('✓ Агентът е стартиран в заден план — можеш да затвориш прозореца.')
914
+ console.log(' Сега избери кои репота да следи от gitdone.eu/github.')
915
+ process.exit(0)
916
+ }
917
+
918
+ // --key/--root without --install: just run the loop in the foreground.
919
+ await runLoop(cfg)
920
+ return
921
+ }
922
+
923
+ // No args → running mode (this is how autostart launches us): read config.
924
+ const cfg = readConfig()
925
+ if (!cfg || !cfg.key) {
926
+ console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
927
+ process.exit(1)
928
+ }
929
+ await runLoop(cfg)
930
+ }
931
+
932
+ main()