gitdone-agent 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +1892 -1733
  2. package/package.json +15 -15
package/index.js CHANGED
@@ -1,1733 +1,1892 @@
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, execFileSync, spawn } from 'node:child_process'
13
- import {
14
- existsSync, writeFileSync, readFileSync, unlinkSync, renameSync,
15
- mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync, statSync,
16
- } from 'node:fs'
17
- import { resolve, join } from 'node:path'
18
- import { homedir, hostname, tmpdir } 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.6.16'
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
- // Keep agent.log from growing forever: past 5 MB it rolls to agent.log.old
42
- // (replacing the previous roll). Checked every 500 lines, not per line.
43
- const LOG_MAX_BYTES = 5 * 1024 * 1024
44
- let logLinesSinceCheck = 0
45
- function rotateLogIfNeeded() {
46
- try {
47
- if (statSync(LOG_PATH).size > LOG_MAX_BYTES) renameSync(LOG_PATH, LOG_PATH + '.old')
48
- } catch { /* no log yet / roll failed — keep appending */ }
49
- }
50
-
51
- function log(msg) {
52
- const line = `[${new Date().toISOString()}] ${msg}`
53
- console.log(line)
54
- try {
55
- ensureAgentDir()
56
- if (logLinesSinceCheck++ % 500 === 0) rotateLogIfNeeded()
57
- appendFileSync(LOG_PATH, line + '\n')
58
- } catch { /* best-effort */ }
59
- }
60
-
61
- // ─── Crash safety ─────────────────────────────────────────────────────────────
62
- // Node ≥15 KILLS the process on any unhandled promise rejection, and an uncaught
63
- // exception always did — and the autostart used to be one-shot, so a single
64
- // stray error left the machine offline until the next Windows login (gd-403).
65
- // Log and keep running instead; the loops here are stateless enough that
66
- // surviving is strictly better than dying. If errors come in a storm (>20/min —
67
- // something is genuinely broken), exit(1) and let the supervisor loop restart
68
- // us with a clean slate.
69
- let crashBurst = { count: 0, since: Date.now() }
70
- function survive(kind, err) {
71
- const now = Date.now()
72
- if (now - crashBurst.since > 60_000) crashBurst = { count: 0, since: now }
73
- crashBurst.count++
74
- log(`✗ ${kind}: ${err?.stack || err?.message || err}`)
75
- if (crashBurst.count > 20) {
76
- log('✗ over 20 crashes in 60s — exiting so the supervisor restarts us clean')
77
- process.exit(1)
78
- }
79
- }
80
- process.on('uncaughtException', (err) => survive('uncaught exception', err))
81
- process.on('unhandledRejection', (err) => survive('unhandled rejection', err))
82
-
83
- function readConfig() {
84
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
85
- }
86
-
87
- function writeConfig(cfg) {
88
- ensureAgentDir()
89
- // Write-then-rename so a crash / reboot mid-write can never leave a truncated
90
- // config.json — a corrupt config makes every subsequent autostart exit
91
- // immediately, which reads as "агентът умря и рестартът не помага" (gd-403).
92
- const tmp = CONFIG_PATH + '.tmp'
93
- writeFileSync(tmp, JSON.stringify(cfg, null, 2), 'utf8')
94
- renameSync(tmp, CONFIG_PATH)
95
- }
96
-
97
- // ─── Arg parsing ─────────────────────────────────────────────────────────────
98
-
99
- function parseArgs() {
100
- const argv = process.argv.slice(2)
101
- const flags = {}
102
- const roots = []
103
- for (const a of argv) {
104
- if (!a.startsWith('--')) continue
105
- const [k, ...rest] = a.slice(2).split('=')
106
- const v = rest.join('=')
107
- if (k === 'root') { if (v) roots.push(resolve(v)) }
108
- else flags[k] = v
109
- }
110
- return {
111
- key: flags.key,
112
- roots,
113
- interval: flags.interval ? Number(flags.interval) : undefined,
114
- url: flags.url ? flags.url.replace(/\/$/, '') : undefined,
115
- install: 'install' in flags,
116
- uninstall: 'uninstall' in flags,
117
- doctor: 'doctor' in flags,
118
- }
119
- }
120
-
121
- // Merge CLI args into the persisted config, generating a machineId on first use.
122
- function buildConfig(args) {
123
- const existing = readConfig() ?? {}
124
- const mergedRoots = Array.from(new Set([...(existing.roots ?? []), ...args.roots]))
125
- return {
126
- key: args.key ?? existing.key,
127
- url: args.url ?? existing.url ?? 'https://gitdone.eu',
128
- interval: args.interval ?? existing.interval ?? 30,
129
- machineId: existing.machineId ?? randomUUID(),
130
- hostname: existing.hostname ?? hostname(),
131
- roots: mergedRoots,
132
- }
133
- }
134
-
135
- // ─── Windows auto-start ───────────────────────────────────────────────────────
136
-
137
- function getStartupDir() {
138
- return join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
139
- }
140
-
141
- function getVbsPath() {
142
- return join(getStartupDir(), 'gitdone-agent.vbs')
143
- }
144
-
145
- function getNodePath() {
146
- try { return execSync('where node', { encoding: 'utf8' }).trim().split('\n')[0].trim() } catch {}
147
- try { return execSync('which node', { encoding: 'utf8' }).trim() } catch {}
148
- return process.execPath
149
- }
150
-
151
- // Locate the Claude Code CLI. Prefer a real executable (claude.exe from the
152
- // native installer) so spawn works WITHOUT a shell — that lets us pass a
153
- // multi-line, non-ASCII prompt as a single argv element with no escaping. An
154
- // npm shim (claude.cmd) needs shell:true, where we collapse the prompt to one
155
- // line to survive cmd.exe parsing. Returns `found:false` when nothing was
156
- // located, so callers can show a clear message instead of spawning bare
157
- // `claude` and letting cmd.exe emit "'claude' is not recognized…".
158
- function findClaude() {
159
- const tryCmd = (c) => {
160
- try {
161
- const out = execSync(c, { encoding: 'utf8' }).trim()
162
- return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
163
- } catch { return [] }
164
- }
165
- const cands = [...tryCmd('where claude'), ...tryCmd('which claude')]
166
-
167
- // The agent is auto-started at login, so its PATH is frozen at that moment —
168
- // a `claude` installed (or a PATH entry added) afterwards is invisible to
169
- // `where`/`which` above. Probe the well-known install locations directly so a
170
- // freshly-installed CLI works without a Windows re-login.
171
- const home = homedir()
172
- const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
173
- for (const p of [
174
- join(home, '.local', 'bin', 'claude.exe'), // Windows native installer
175
- join(appData, 'npm', 'claude.cmd'), // Windows npm global shim
176
- join(home, '.local', 'bin', 'claude'), // macOS/Linux native installer
177
- '/usr/local/bin/claude', // Homebrew (Intel) / manual install
178
- '/opt/homebrew/bin/claude', // Homebrew (Apple silicon)
179
- ]) {
180
- if (existsSync(p) && !cands.includes(p)) cands.push(p)
181
- }
182
-
183
- const exe = cands.find((p) => /\.exe$/i.test(p))
184
- if (exe) return { path: exe, shell: false, found: true }
185
- if (cands.length) return { path: cands[0], shell: true, found: true }
186
- return { path: 'claude', shell: true, found: false }
187
- }
188
-
189
- // ─── Single instance (gd-407) ─────────────────────────────────────────────────
190
- // Two agents on one machine (an old one surviving an update, or a manual
191
- // `npx gitdone-agent` next to the autostarted one) flap the machine's online
192
- // state and race dispatches. The running agent claims the machine via a pid
193
- // file; a newcomer that finds a LIVE agent behind it exits with EXIT_DUPLICATE,
194
- // which the supervisor treats as "do not restart me". First one wins.
195
- // 86 is outside node's own exit-code range (1-13) — keep in lockstep with the
196
- // run-agent.cmd template in installStartup().
197
- const EXIT_DUPLICATE = 86
198
- const PID_PATH = join(AGENT_DIR, 'agent.pid')
199
-
200
- // Command line of a live process, '' when unreadable (dead process, no rights).
201
- function processCmdline(pid) {
202
- try {
203
- if (process.platform === 'win32') {
204
- return execSync(
205
- `powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine"`,
206
- { encoding: 'utf8' },
207
- ).trim()
208
- }
209
- return execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8' }).trim()
210
- } catch { return '' }
211
- }
212
-
213
- function ensureSingleInstance() {
214
- try {
215
- const pid = parseInt(readFileSync(PID_PATH, 'utf8'), 10)
216
- if (pid && pid !== process.pid) {
217
- process.kill(pid, 0) // throws when that pid is no longer alive
218
- // PID reuse guard: only defer to a process that really looks like an
219
- // agent (stable copy agent.mjs, or any gitdone-agent npx/global run).
220
- if (/gitdone-agent|agent\.mjs/i.test(processCmdline(pid))) {
221
- log(`✗ друг gitdone-agent вече върви (PID ${pid}) — този процес излиза, за да няма два агента на машината`)
222
- process.exit(EXIT_DUPLICATE)
223
- }
224
- }
225
- } catch { /* no/stale pid file → the machine is free, we take over */ }
226
- try { ensureAgentDir(); writeFileSync(PID_PATH, String(process.pid)) } catch { /* best-effort */ }
227
- }
228
-
229
- // Kill any previously-running agent processes so an update applies WITHOUT a
230
- // Windows re-login. Targets node processes that look like a gitdone agent —
231
- // both the stable copy in ~/.gitdone-agent AND one running straight from the
232
- // npx cache / global install (its command line contains "gitdone-agent") —
233
- // excluding ourselves and our parent (the npx wrapper that launched us).
234
- // Best-effort; failures are non-fatal.
235
- function stopRunningAgents() {
236
- // Kill the supervisor loops (cmd.exe running run-agent.cmd) FIRST, then the
237
- // agents — the other order lets a still-alive supervisor immediately respawn
238
- // the agent we just killed, leaving two agents reporting after an update.
239
- const ps = [
240
- "$ErrorActionPreference='SilentlyContinue'",
241
- "Get-CimInstance Win32_Process -Filter \"Name='cmd.exe'\" |",
242
- " Where-Object { $_.CommandLine -like '*run-agent.cmd*' } |",
243
- ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
244
- "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" |",
245
- ` Where-Object { ($_.CommandLine -like '*gitdone-agent*' -or $_.CommandLine -like '*agent.mjs*') -and $_.ProcessId -ne ${process.pid} -and $_.ProcessId -ne ${process.ppid || 0} } |`,
246
- ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
247
- ].join('\n')
248
- try {
249
- ensureAgentDir()
250
- const scriptPath = join(AGENT_DIR, 'stop-agents.ps1')
251
- writeFileSync(scriptPath, ps, 'utf8')
252
- execSync(`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, { stdio: 'ignore' })
253
- } catch { /* best-effort — at worst the old agent lingers until next login */ }
254
- }
255
-
256
- function installStartup() {
257
- ensureAgentDir()
258
- const nodePath = getNodePath()
259
-
260
- // Stop the previously-running agent(s) first so we don't end up with the old
261
- // build still reporting alongside the new one until the next Windows login.
262
- stopRunningAgents()
263
-
264
- // Copy this script to a stable location. process.argv[1] may point inside an
265
- // npx cache dir that gets purged later — referencing it from autostart would
266
- // silently break on the next login. A stable copy survives.
267
- try {
268
- copyFileSync(resolve(process.argv[1]), STABLE_AGENT)
269
- } catch (err) {
270
- console.error(`Warning: could not copy agent to ${STABLE_AGENT}: ${err.message}`)
271
- }
272
- const agentPath = existsSync(STABLE_AGENT) ? STABLE_AGENT : resolve(process.argv[1])
273
-
274
- // The autostart used to launch the agent DIRECTLY — one crash and the machine
275
- // stayed offline until the next Windows login (gd-403). Now the VBS launches a
276
- // tiny supervisor loop instead: run the agent, and if it ever exits, note it
277
- // in agent-crash.log and start it again after 10s. stderr is captured too, so
278
- // node-level failures (corrupt file, bad path) finally leave a trace. If the
279
- // node path recorded at install time vanished (node upgraded/moved), fall
280
- // back to whatever `node` is on PATH.
281
- //
282
- // Restart forever on normal crashes, but STOP on process-START failures
283
- // (0xc0000142 STATUS_DLL_INIT_FAILED, 0xc0000135 missing DLL): those mean the
284
- // login session itself is broken (e.g. after a hardware crash), every attempt
285
- // pops a blocking "Application Error" dialog, and a retry can never succeed —
286
- // the loop turned into an endless dialog storm (gd-405). Same for the
287
- // ping-as-sleep: if even ping can't start, bail instead of spinning with no
288
- // delay at all. A Windows re-login restarts the supervisor cleanly.
289
- const crashLog = join(AGENT_DIR, 'agent-crash.log')
290
- const cmd = [
291
- '@echo off',
292
- 'rem gitdone-agent supervisor — restarts the agent if it ever dies (gd-403)',
293
- `set "NODE=${nodePath}"`,
294
- 'if not exist "%NODE%" set "NODE=node"',
295
- ':loop',
296
- `"%NODE%" "${agentPath}" 2>> "${crashLog}"`,
297
- 'set CODE=%errorlevel%',
298
- `echo [%date% %time%] agent exited (code %CODE%) - restart in 10s >> "${crashLog}"`,
299
- 'if "%CODE%"=="86" goto duplicate',
300
- 'if "%CODE%"=="-1073741502" goto dead',
301
- 'if "%CODE%"=="-1073741515" goto dead',
302
- 'ping -n 11 127.0.0.1 >nul 2>nul || goto dead',
303
- 'goto loop',
304
- ':dead',
305
- `echo [%date% %time%] node cannot start (code %CODE%) - supervisor giving up until next login >> "${crashLog}"`,
306
- 'exit /b',
307
- ':duplicate',
308
- `echo [%date% %time%] another agent already runs this machine (code 86) - this supervisor exits >> "${crashLog}"`,
309
- ].join('\r\n')
310
- const cmdPath = join(AGENT_DIR, 'run-agent.cmd')
311
- writeFileSync(cmdPath, cmd, 'utf8')
312
-
313
- // VBScript runs the supervisor with window style 0 so nothing pops up on login.
314
- const vbs = [
315
- 'Set WshShell = CreateObject("WScript.Shell")',
316
- `WshShell.Run """${cmdPath}""", 0, False`,
317
- ].join('\r\n')
318
-
319
- writeFileSync(getVbsPath(), vbs, 'utf8')
320
- }
321
-
322
- function uninstallStartup() {
323
- const vbsPath = getVbsPath()
324
- if (existsSync(vbsPath)) {
325
- unlinkSync(vbsPath)
326
- console.log(`✓ Премахнат автостарт за gitdone-agent`)
327
- } else {
328
- console.log(`Не е намерен автостарт (${vbsPath})`)
329
- }
330
- const cmdPath = join(AGENT_DIR, 'run-agent.cmd')
331
- if (existsSync(cmdPath)) unlinkSync(cmdPath)
332
- // Also stop the live supervisor + agent — otherwise they keep running (and
333
- // the supervisor keeps resurrecting the agent) until the next reboot.
334
- stopRunningAgents()
335
- }
336
-
337
- // ─── Doctor ────────────────────────────────────────────────────────────────────
338
-
339
- function runDoctor() {
340
- const cfg = readConfig()
341
- console.log('gitdone-agent doctor')
342
- console.log(` node : ${getNodePath()}`)
343
- console.log(` agent dir : ${AGENT_DIR} ${existsSync(AGENT_DIR) ? '(ok)' : '(MISSING)'}`)
344
- console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
345
- console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
346
- console.log(` supervisor : ${join(AGENT_DIR, 'run-agent.cmd')} ${existsSync(join(AGENT_DIR, 'run-agent.cmd')) ? '(ok)' : '(not installed — пусни --install за авторестарт при crash)'}`)
347
- console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
348
- console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
349
- console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
350
- const claude = findClaude()
351
- console.log(` claude cli : ${claude.found ? claude.path : 'NOT FOUND — инсталирай от claude.ai/code и рестартирай агента'}`)
352
- if (cfg) {
353
- console.log(` machineId : ${cfg.machineId}`)
354
- console.log(` server : ${cfg.url}`)
355
- console.log(` interval : ${cfg.interval}s`)
356
- console.log(` roots :`)
357
- for (const r of cfg.roots ?? []) console.log(` - ${r} ${existsSync(r) ? '' : '(MISSING)'}`)
358
- if (cfg.roots?.length) {
359
- const found = scanRepos(cfg.roots)
360
- console.log(` found repos : ${found.length}`)
361
- for (const r of found) console.log(` - ${r.name} (${r.path})`)
362
- }
363
- }
364
- }
365
-
366
- // ─── Git helpers ─────────────────────────────────────────────────────────────
367
-
368
- // Room for large command output. `git status --porcelain -uall` (gd-369) lists
369
- // every untracked file individually, so a repo with a big new/untracked tree can
370
- // blow past execSync's default 1 MB buffer — which would throw and silently
371
- // return '' (an EMPTY snapshot, worse than the collapsed view). 64 MB is plenty.
372
- const GIT_MAX_BUFFER = 64 * 1024 * 1024
373
-
374
- // Cap for synthesising an untracked file's full-content diff (gd-370) — above
375
- // this the file keeps the "no diff" placeholder so a huge/binary blob can't
376
- // bloat the snapshot payload.
377
- const UNTRACKED_DIFF_MAX_BYTES = 1024 * 1024
378
-
379
- // Synthesising a per-file diff spawns a synchronous `git diff --no-index` per
380
- // untracked file. Individually cheap, but a repo with a huge untracked/scratch
381
- // tree (e.g. extracted game assets — thousands of files) would spawn thousands
382
- // of blocking git processes and freeze the single-threaded agent for MINUTES on
383
- // every snapshot, starving the 30s sync heartbeat so the machine flips offline.
384
- // Bound the synthesis two ways — a file-count cap and a wall-clock budget — so
385
- // no single repo can ever monopolise the event loop. Anything past either keeps
386
- // the "no diff" placeholder, identical to the size-cap path above.
387
- const UNTRACKED_DIFF_MAX_FILES = 200
388
- const UNTRACKED_DIFF_TIME_BUDGET_MS = 3000
389
-
390
- // Every git call here is execSync — synchronous, on the ONLY thread. A git that
391
- // stops to ask for credentials (a repo whose remote lost its token → Git
392
- // Credential Manager pops an invisible dialog) blocks the whole agent forever:
393
- // no ticks, no heartbeat, machine flips offline until someone restarts it
394
- // (gd-403). Two guards: never allow interactive prompts, and hard-timeout every
395
- // call so the worst case is one failed command, not a dead agent. Local
396
- // commands get 2 min; push/pull (network) get 5.
397
- const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
398
- const GIT_TIMEOUT_MS = 2 * 60 * 1000
399
- const GIT_NET_TIMEOUT_MS = 5 * 60 * 1000
400
-
401
- function git(cmd, cwd) {
402
- try {
403
- return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, env: GIT_ENV }).trim()
404
- } catch {
405
- return ''
406
- }
407
- }
408
-
409
- // Like git(), but returns the RAW bytes instead of a UTF-8 string. Needed for
410
- // `git diff`, whose payload can be Windows-1251 (CP1251) Cyrillic — decoding
411
- // those bytes as UTF-8 up front would corrupt them to „�"/„?" irreversibly
412
- // (gd-276). Callers decode per-file via decodeDiffText().
413
- function gitRaw(cmd, cwd) {
414
- try {
415
- return execSync(cmd, { cwd, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, env: GIT_ENV })
416
- } catch {
417
- return Buffer.alloc(0)
418
- }
419
- }
420
-
421
- // Full-content diff of a single UNTRACKED file (against /dev/null), so the
422
- // console can show a new file's whole content as an all-added diff — like
423
- // GitHub Desktop (gd-370). Uses execFileSync (argv, no shell) so paths with
424
- // spaces / Cyrillic reach git intact, unlike a shell command string. `--no-index`
425
- // exits 1 when the file differs from empty (i.e. always) — that's expected, and
426
- // its stdout still holds the diff. Returns raw bytes for parseDiffByFile.
427
- function gitDiffUntracked(file, cwd) {
428
- try {
429
- return execFileSync('git', ['diff', '--no-index', '--', '/dev/null', file], {
430
- cwd, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, env: GIT_ENV,
431
- })
432
- } catch (err) {
433
- return err && err.stdout && err.stdout.length ? err.stdout : Buffer.alloc(0)
434
- }
435
- }
436
-
437
- // Like git(), but surfaces failures (with stderr) instead of swallowing them —
438
- // used for push/pull where we need to detect auth rejection.
439
- function gitTry(cmd, cwd) {
440
- try {
441
- const out = execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: GIT_NET_TIMEOUT_MS, env: GIT_ENV }).trim()
442
- return { ok: true, out }
443
- } catch (err) {
444
- const timedOut = err.signal === 'SIGTERM' && err.code == null
445
- const out = (err.stderr || err.stdout || (timedOut ? `git не отговори ${GIT_NET_TIMEOUT_MS / 60000} мин и беше прекратен` : err.message) || '').toString().trim()
446
- return { ok: false, out }
447
- }
448
- }
449
-
450
- // Never let a token reach the log file or the server's command-result.
451
- function redact(text, token) {
452
- if (!text || !token) return text
453
- return text.split(token).join('***')
454
- }
455
-
456
- // https://x-access-token:<token>@github.com/<owner>/<repo>.git — an ad-hoc URL
457
- // passed straight to push/pull, so it never gets written into .git/config.
458
- function authUrl(auth) {
459
- const repo = auth.repo.replace(/\.git$/i, '')
460
- return `https://x-access-token:${auth.token}@github.com/${repo}.git`
461
- }
462
-
463
- // A push rejected because the remote branch moved ahead of us (someone else
464
- // pushed in the meantime). Git prints "non-fast-forward" / "fetch first" /
465
- // "tip of your current branch is behind". We recover by pulling+rebasing.
466
- const NON_FAST_FORWARD = /non-fast-forward|fetch first|tip of your current branch is behind|failed to push some refs/i
467
-
468
- // Point the local remote-tracking ref (e.g. refs/remotes/origin/master) at the
469
- // commit we just synced. We push/pull through the ad-hoc token URL, and — unlike
470
- // `git push origin` — pushing/pulling by URL leaves refs/remotes/origin/* UNTOUCHED.
471
- // getSnapshot() then measures "ahead"/"behind" against that stale tracking ref
472
- // (git rev-list @{u}..HEAD), so already-pushed commits keep showing as pending
473
- // forever: the ahead badge never clears, GitHub Desktop shows the same "N↑", and
474
- // the user pushes again and again while local commits pile up (gd-273). Fast-
475
- // forwarding the tracking ref to the true remote tip resets ahead/behind to reality.
476
- // Best-effort: git() swallows failures so this never breaks the push/pull itself.
477
- function updateTrackingRef(repoPath, branch, commitish) {
478
- // Prefer the branch's configured upstream (usually origin/<branch>); fall back
479
- // to origin/<branch> when no upstream is set.
480
- const upstream = git('git rev-parse --abbrev-ref --symbolic-full-name @{u}', repoPath)
481
- const ref = upstream ? `refs/remotes/${upstream}` : `refs/remotes/origin/${branch}`
482
- git(`git update-ref "${ref}" ${commitish}`, repoPath)
483
- }
484
-
485
- // Push/pull using the server-issued token when we have one; otherwise fall back
486
- // to the machine's own git credentials (old behaviour). Throws on failure.
487
- function pushOrPull(type, repoPath, auth) {
488
- const branch = git('git branch --show-current', repoPath) || 'HEAD'
489
- const url = auth ? authUrl(auth) : null
490
- const pushCmd = url ? `git push "${url}" HEAD:${branch}` : 'git push'
491
-
492
- if (type === 'pull') {
493
- const r = gitTry(url ? `git pull "${url}" ${branch}` : 'git pull', repoPath)
494
- if (!r.ok) throw new Error(r.out || 'pull failed')
495
- // Pulling by URL never advanced origin/<branch>; point it at the fetched tip
496
- // (FETCH_HEAD) so a later push isn't seen as being "ahead" of a stale ref.
497
- if (url) updateTrackingRef(repoPath, branch, 'FETCH_HEAD')
498
- return r.out || 'pulled'
499
- }
500
-
501
- let r = gitTry(pushCmd, repoPath)
502
- if (r.ok) {
503
- // We just pushed HEAD to the remote branch, so the remote tip == HEAD. Sync
504
- // the local tracking ref to clear the "ahead" count (gd-273).
505
- if (url) updateTrackingRef(repoPath, branch, 'HEAD')
506
- return r.out || 'pushed'
507
- }
508
-
509
- // Push rejected because the remote is ahead (gd-272): commit succeeded but the
510
- // push bounced with non-fast-forward, so the files looked "pushed" while the
511
- // UI showed a scary git error. Pull with --rebase to replay our commits on top
512
- // of the remote ones, then push again — what the user means by "push my files".
513
- if (NON_FAST_FORWARD.test(r.out)) {
514
- const pull = gitTry(url ? `git pull --rebase "${url}" ${branch}` : 'git pull --rebase', repoPath)
515
- if (!pull.ok) {
516
- // Rebase couldn't apply cleanly (conflicts) — abort so the repo isn't left
517
- // mid-rebase, and surface an actionable message instead of guessing.
518
- gitTry('git rebase --abort', repoPath)
519
- throw new Error(`отдалеченият клон е напред и има конфликт при обединяване — дръпни (Sync) и слей ръчно, после пусни пак.\n${pull.out}`)
520
- }
521
- r = gitTry(pushCmd, repoPath)
522
- if (!r.ok) throw new Error(r.out || 'push failed')
523
- if (url) updateTrackingRef(repoPath, branch, 'HEAD')
524
- return `дръпнах новите промени от сървъра и пушнах наново.\n${r.out}`.trim()
525
- }
526
-
527
- throw new Error(r.out || 'push failed')
528
- }
529
-
530
- const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
531
-
532
- // Discard all local changes to a SINGLE file, so it disappears from the repo's
533
- // "changes". Mirrors GitHub Desktop's "Discard changes": unstage first, then
534
- // either restore the file from HEAD (tracked) or delete it (new/untracked).
535
- function discardFile(repoPath, file) {
536
- if (!file) throw new Error('no file')
537
- // Unstage so index + worktree get reverted together (no-op if not staged).
538
- gitTry(`git reset -q HEAD -- "${file}"`, repoPath)
539
- // Does the file exist in the last commit? If so we restore its content; if
540
- // not, it's a newly-added/untracked file and discarding means removing it.
541
- const inHead = gitTry(`git cat-file -e "HEAD:${file}"`, repoPath).ok
542
- if (inHead) {
543
- const r = gitTry(`git checkout HEAD -- "${file}"`, repoPath)
544
- if (!r.ok) throw new Error(r.out || 'checkout failed')
545
- return 'discarded'
546
- }
547
- // New/untracked file (now unstaged) — drop it from the working tree.
548
- const r = gitTry(`git clean -fdq -- "${file}"`, repoPath)
549
- if (!r.ok) throw new Error(r.out || 'clean failed')
550
- return 'discarded (new file removed)'
551
- }
552
-
553
- // ─── Windows-1251 (CP1251) aware diff decoding (gd-276) ───────────────────────
554
- // Legacy Cyrillic sources are often saved in CP1251. Their high bytes (0x80–0xFF,
555
- // single-byte) are invalid UTF-8, so reading the diff as UTF-8 shows „?"/„�"
556
- // instead of кирилица. We keep the raw bytes and decode each file's diff section
557
- // on its own: valid UTF-8 stays UTF-8; the rest goes through the CP1251 table
558
- // below — so a UTF-8 repo is untouched while CP1251 files finally read correctly.
559
-
560
- // CP1251 high range 0x80–0xFF → Unicode code points (0x00–0x7F is plain ASCII).
561
- // 0x98 is unassigned in CP1251 → U+FFFD.
562
- const CP1251_HIGH = [
563
- 0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
564
- 0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
565
- 0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
566
- 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
567
- 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F,
568
- 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F,
569
- 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F,
570
- 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F,
571
- ]
572
-
573
- function decodeCp1251(buf) {
574
- let out = ''
575
- for (const b of buf) out += String.fromCharCode(b < 0x80 ? b : CP1251_HIGH[b - 0x80])
576
- return out
577
- }
578
-
579
- // A strict UTF-8 decoder (always available even on small-ICU builds) — throws on
580
- // any invalid sequence, which is exactly how we tell UTF-8 apart from CP1251.
581
- const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true })
582
- function decodeDiffText(buf) {
583
- try {
584
- return UTF8_STRICT.decode(buf)
585
- } catch {
586
- return decodeCp1251(buf)
587
- }
588
- }
589
-
590
- // Git C-quotes paths with spaces / special chars / non-ASCII bytes, wrapping them
591
- // in double quotes with backslash escapes (`"export coca_cola/x.cs"`, octal `\NNN`
592
- // for raw bytes). Both `git status --porcelain` and the `diff --git` header do it.
593
- // Left unhandled, files with spaces (or Cyrillic) got mis-keyed and their diff was
594
- // dropped (gd-321). This reverses it back to a plain path so the diff key matches
595
- // the file name shown in the list.
596
- function unquoteGitPath(s) {
597
- if (typeof s !== 'string' || s.length < 2 || s[0] !== '"' || s[s.length - 1] !== '"') return s
598
- const body = s.slice(1, -1)
599
- const bytes = []
600
- for (let i = 0; i < body.length; i++) {
601
- if (body[i] === '\\' && i + 1 < body.length) {
602
- const n = body[i + 1]
603
- if (n === 'n') { bytes.push(10); i++ }
604
- else if (n === 't') { bytes.push(9); i++ }
605
- else if (n === 'r') { bytes.push(13); i++ }
606
- else if (n === '"') { bytes.push(34); i++ }
607
- else if (n === '\\') { bytes.push(92); i++ }
608
- else if (n >= '0' && n <= '7') { bytes.push(parseInt(body.substr(i + 1, 3), 8) & 0xff); i += 3 }
609
- else { bytes.push(body.charCodeAt(i)) }
610
- } else {
611
- bytes.push(body.charCodeAt(i) & 0xff)
612
- }
613
- }
614
- return Buffer.from(bytes).toString('utf8')
615
- }
616
-
617
- // Read the b-side path from a `diff --git` header line, handling git's quoting
618
- // of paths with spaces / special chars (gd-321).
619
- function diffHeaderPath(hdr) {
620
- const q = hdr.match(/ ("b\/.*")$/) // quoted: "a/x" "b/x"
621
- if (q) return unquoteGitPath(q[1]).replace(/^b\//, '')
622
- const u = hdr.match(/ b\/(.*)$/) // plain: a/x b/x
623
- return u ? u[1] : null
624
- }
625
-
626
- function parseDiffByFile(diffBuf) {
627
- const files = {}
628
- if (!diffBuf || diffBuf.length === 0) return files
629
- // Latin1 is a lossless 1-byte⇢1-char mapping, so the ASCII "diff --git" split
630
- // markers match while every original byte survives for the per-section decode.
631
- const raw = Buffer.isBuffer(diffBuf) ? diffBuf.toString('latin1') : String(diffBuf)
632
- const sections = raw.split(/(?=^diff --git )/m)
633
- for (const section of sections) {
634
- if (!section.trim()) continue
635
- // Decode THIS file's bytes on their own (UTF-8 or CP1251), then read the
636
- // filename from the decoded header (quoted or not).
637
- const text = decodeDiffText(Buffer.from(section, 'latin1'))
638
- const hdr = text.match(/^diff --git (.+)$/m)
639
- const name = hdr ? diffHeaderPath(hdr[1]) : null
640
- if (name) files[name] = text
641
- }
642
- return files
643
- }
644
-
645
- function getSnapshot(repoPath) {
646
- const branch = git('git branch --show-current', repoPath) || 'HEAD'
647
-
648
- // -uall lists every untracked file individually instead of collapsing a new
649
- // directory into a single `dir/` entry — so the change list matches what
650
- // GitHub Desktop shows, file-for-file (gd-369).
651
- const statusLines = git('git status --porcelain -uall', repoPath).split('\n').filter(Boolean)
652
- const modified = []
653
- const staged = []
654
- const statuses = {}
655
- for (const line of statusLines) {
656
- const xy = line.slice(0, 2)
657
- const file = unquoteGitPath(line.slice(3))
658
- statuses[file] = xy
659
- if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
660
- if (xy[1] !== ' ') modified.push(file)
661
- }
662
-
663
- const aheadBy = parseInt(git('git rev-list --count @{u}..HEAD', repoPath), 10) || 0
664
- const behindBy = parseInt(git('git rev-list --count HEAD..@{u}', repoPath), 10) || 0
665
-
666
- const logLine = git('git log -1 --pretty=format:%H|%s|%an|%aI', repoPath)
667
- let lastCommit = null
668
- if (logLine) {
669
- const [sha, message, author, date] = logLine.split('|')
670
- lastCommit = { sha, message, author, date }
671
- }
672
-
673
- const diffs = parseDiffByFile(gitRaw('git diff HEAD', repoPath))
674
-
675
- // Untracked files (`??`) aren't in `git diff HEAD`, so they had no content to
676
- // show ("Нов или untracked файл — няма diff"). Synthesise an all-added diff for
677
- // each from /dev/null so the console shows the whole new file — like GitHub
678
- // Desktop (gd-370). Skip anything over the size cap so a stray big/binary blob
679
- // can't bloat the snapshot; those keep the "no diff" placeholder.
680
- const untracked = Object.keys(statuses).filter((f) => statuses[f] === '??' && !diffs[f])
681
- const diffDeadline = Date.now() + UNTRACKED_DIFF_TIME_BUDGET_MS
682
- let synthesised = 0
683
- for (const file of untracked) {
684
- // Stop before either bound so a scratch tree can't starve the heartbeat; the
685
- // rest keep the "no diff" placeholder. Log once so it's visible why.
686
- if (synthesised >= UNTRACKED_DIFF_MAX_FILES || Date.now() > diffDeadline) {
687
- log(`⚠ untracked diff cap reached @ ${repoPath} (${synthesised}/${untracked.length} synthesised) — rest shown without preview`)
688
- break
689
- }
690
- let st
691
- try { st = statSync(join(repoPath, file)) } catch { continue }
692
- if (!st.isFile() || st.size > UNTRACKED_DIFF_MAX_BYTES) continue
693
- synthesised++
694
- const parsed = parseDiffByFile(gitDiffUntracked(file, repoPath))
695
- const val = parsed[file] ?? Object.values(parsed)[0]
696
- if (val) diffs[file] = val
697
- }
698
-
699
- // Origin remote → lets the server auto-detect the GitHub repo for the History tab.
700
- const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
701
-
702
- return { branch, modified, staged, aheadBy, behindBy, lastCommit, statuses, diffs, remoteUrl }
703
- }
704
-
705
- // ─── Repo discovery ─────────────────────────────────────────────────────────────
706
- // Walk each root looking for directories that contain a `.git` entry. Stop
707
- // descending once a repo is found (don't recurse into submodules/nested repos),
708
- // skip noisy dirs, and cap depth so a huge tree can't hang a tick.
709
-
710
- const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', '.cache', 'vendor', '.venv', '__pycache__'])
711
-
712
- function scanRepos(roots, maxDepth = 5) {
713
- const found = []
714
- const seen = new Set()
715
-
716
- function walk(dir, depth) {
717
- if (depth > maxDepth) return
718
- let entries
719
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
720
-
721
- if (entries.some((e) => e.name === '.git')) {
722
- const path = resolve(dir)
723
- if (!seen.has(path)) {
724
- seen.add(path)
725
- found.push({ name: path.split(/[\\/]/).pop() ?? path, path })
726
- }
727
- return // a repo — don't descend further
728
- }
729
-
730
- for (const e of entries) {
731
- if (!e.isDirectory()) continue
732
- if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
733
- walk(join(dir, e.name), depth + 1)
734
- }
735
- }
736
-
737
- for (const root of roots) walk(resolve(root), 0)
738
- return found
739
- }
740
-
741
- // ─── Server sync + commands ─────────────────────────────────────────────────────
742
-
743
- async function api(cfg, path, body) {
744
- const res = await fetch(`${cfg.url}${path}`, {
745
- method: 'POST',
746
- headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
747
- body: JSON.stringify(body),
748
- })
749
- if (!res.ok) {
750
- const text = await res.text().catch(() => '')
751
- throw new Error(`HTTP ${res.status} ${path}: ${text}`)
752
- }
753
- return res.json().catch(() => ({}))
754
- }
755
-
756
- async function reportCommandResult(cfg, id, status, result) {
757
- await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
758
- }
759
-
760
- // Post a batch of console events (and optional lifecycle status) for an AiRun.
761
- // `usage` is sent once on the final (done/error) post so the server can record
762
- // how many tokens the run cost.
763
- async function postRunEvents(cfg, runId, events, status, result, usage) {
764
- await api(cfg, '/api/v1/agent/ai-run/events', {
765
- runId,
766
- events,
767
- ...(status ? { status } : {}),
768
- ...(result !== undefined ? { result } : {}),
769
- ...(usage ? { usage } : {}),
770
- }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
771
- }
772
-
773
- // Post transcript lines (and optional turn status / Claude session id) for an
774
- // interactive AiSession chat turn.
775
- async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText) {
776
- await api(cfg, '/api/v1/agent/ai-session/events', {
777
- sessionId,
778
- events,
779
- ...(status ? { status } : {}),
780
- ...(claudeSessionId ? { claudeSessionId } : {}),
781
- // Explicit '' clears the live preview; `undefined` leaves it untouched.
782
- ...(streamingText !== undefined ? { streamingText } : {}),
783
- }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
784
- }
785
-
786
- // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
787
- // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
788
- // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
789
- // With `--include-partial-messages`, Claude also emits `stream_event` lines
790
- // carrying incremental text deltas — onDelta gets those so chat sessions can
791
- // show the reply being written live (gd-302).
792
- // "12.3k" / "1.2M" compaction for the console token summary line.
793
- function fmtTokens(n) {
794
- n = Number(n) || 0
795
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
796
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
797
- return String(n)
798
- }
799
-
800
- function parseStreamLine(line, push, onInit, onDelta, onMeta) {
801
- let ev
802
- try { ev = JSON.parse(line) } catch { return }
803
- if (ev.type === 'system') {
804
- if (ev.subtype === 'init') {
805
- push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
806
- // Surface Claude's own session id so chat sessions can --resume it.
807
- if (ev.session_id && typeof onInit === 'function') onInit(ev.session_id)
808
- // Remember which model ran, for the token/cost record (gd-334).
809
- if (ev.model && typeof onMeta === 'function') onMeta({ model: ev.model })
810
- }
811
- return
812
- }
813
- // Partial-message stream: only the model's visible text deltas feed the live
814
- // preview. Tool-input JSON deltas and thinking deltas are ignored here — the
815
- // full block still arrives as a normal `assistant` event below.
816
- if (ev.type === 'stream_event' && typeof onDelta === 'function') {
817
- const e = ev.event
818
- if (e?.type === 'content_block_delta' && e.delta?.type === 'text_delta' && e.delta.text) {
819
- onDelta(e.delta.text)
820
- }
821
- return
822
- }
823
- if (ev.type === 'assistant' && ev.message?.content) {
824
- for (const block of ev.message.content) {
825
- if (block.type === 'text' && block.text?.trim()) {
826
- push('TEXT', block.text.trim())
827
- } else if (block.type === 'tool_use') {
828
- const input = block.input ? JSON.stringify(block.input) : ''
829
- push('TOOL_CALL', `${block.name}${input ? ` ${input.slice(0, 400)}` : ''}`)
830
- }
831
- }
832
- return
833
- }
834
- if (ev.type === 'result') {
835
- if (ev.subtype && ev.subtype !== 'success') push('SYSTEM', `Резултат: ${ev.subtype}`)
836
- // claude's final result carries cumulative token usage + its own cost.
837
- // Prefer `modelUsage` (summed over every model/subagent turn) which is the
838
- // true cumulative; top-level `usage` is often just the last turn. Fall back
839
- // to `usage` when modelUsage is absent (older CLI).
840
- if (typeof onMeta === 'function' && (ev.modelUsage || ev.usage || typeof ev.total_cost_usd === 'number')) {
841
- let usage
842
- let model
843
- if (ev.modelUsage && typeof ev.modelUsage === 'object') {
844
- const acc = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 }
845
- for (const [name, mu] of Object.entries(ev.modelUsage)) {
846
- if (!model) model = name
847
- acc.inputTokens += mu.inputTokens ?? mu.input_tokens ?? 0
848
- acc.outputTokens += mu.outputTokens ?? mu.output_tokens ?? 0
849
- acc.cacheReadTokens += mu.cacheReadInputTokens ?? mu.cache_read_input_tokens ?? 0
850
- acc.cacheCreateTokens += mu.cacheCreationInputTokens ?? mu.cache_creation_input_tokens ?? 0
851
- }
852
- usage = acc
853
- } else if (ev.usage) {
854
- const u = ev.usage
855
- usage = {
856
- inputTokens: u.input_tokens ?? 0,
857
- outputTokens: u.output_tokens ?? 0,
858
- cacheReadTokens: u.cache_read_input_tokens ?? 0,
859
- cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
860
- }
861
- }
862
- onMeta({
863
- ...(usage ? { usage } : {}),
864
- ...(model ? { model } : {}),
865
- costUsd: typeof ev.total_cost_usd === 'number' ? ev.total_cost_usd : undefined,
866
- })
867
- }
868
- }
869
- }
870
-
871
- // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
872
- // (Bash stdout, edit results, …) to the run's console, plus the settings file
873
- // that registers it. The hook reads GITDONE_* from its env (set per run on the
874
- // claude process). Returns the settings path to pass via --settings.
875
- const AI_HOOK_SCRIPT = join(AGENT_DIR, 'ai-run-hook.mjs')
876
- const AI_SETTINGS_PATH = join(AGENT_DIR, 'ai-run-settings.json')
877
-
878
- function ensureAiHookFiles() {
879
- ensureAgentDir()
880
- const hookSrc = [
881
- "import process from 'node:process'",
882
- "let data = ''",
883
- "process.stdin.setEncoding('utf8')",
884
- "process.stdin.on('data', (c) => { data += c })",
885
- 'process.stdin.on(\'end\', async () => {',
886
- ' try {',
887
- " const ev = JSON.parse(data || '{}')",
888
- ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, runId = process.env.GITDONE_RUN_ID',
889
- ' if (url && key && runId) {',
890
- " const name = ev.tool_name || 'tool'",
891
- ' const o = ev.tool_output',
892
- " let out = ''",
893
- " if (typeof o === 'string') out = o",
894
- " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
895
- " out = String(out || '').slice(0, 2000)",
896
- " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
897
- " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
898
- " await fetch(url + '/api/v1/agent/ai-run/events', {",
899
- " method: 'POST',",
900
- " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
901
- " body: JSON.stringify({ runId, events: [{ kind: 'TOOL_RESULT', text }] }),",
902
- ' }).catch(() => {})',
903
- ' }',
904
- ' } catch {}',
905
- ' process.exit(0)',
906
- '})',
907
- ].join('\n')
908
- writeFileSync(AI_HOOK_SCRIPT, hookSrc, 'utf8')
909
-
910
- const nodePath = getNodePath()
911
- const settings = {
912
- hooks: {
913
- PostToolUse: [
914
- { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_HOOK_SCRIPT}"` }] },
915
- ],
916
- },
917
- }
918
- writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
919
- return AI_SETTINGS_PATH
920
- }
921
-
922
- // Same PostToolUse hook, for interactive chat sessions: posts raw tool output to
923
- // the session endpoint, reading GITDONE_SESSION_ID from the per-turn env.
924
- const AI_SESSION_HOOK_SCRIPT = join(AGENT_DIR, 'ai-session-hook.mjs')
925
- const AI_SESSION_SETTINGS_PATH = join(AGENT_DIR, 'ai-session-settings.json')
926
-
927
- function ensureAiSessionHookFiles() {
928
- ensureAgentDir()
929
- const hookSrc = [
930
- "import process from 'node:process'",
931
- "let data = ''",
932
- "process.stdin.setEncoding('utf8')",
933
- "process.stdin.on('data', (c) => { data += c })",
934
- 'process.stdin.on(\'end\', async () => {',
935
- ' try {',
936
- " const ev = JSON.parse(data || '{}')",
937
- ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, sessionId = process.env.GITDONE_SESSION_ID',
938
- ' if (url && key && sessionId) {',
939
- " const name = ev.tool_name || 'tool'",
940
- ' const o = ev.tool_output',
941
- " let out = ''",
942
- " if (typeof o === 'string') out = o",
943
- " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
944
- " out = String(out || '').slice(0, 2000)",
945
- " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
946
- " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
947
- " await fetch(url + '/api/v1/agent/ai-session/events', {",
948
- " method: 'POST',",
949
- " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
950
- " body: JSON.stringify({ sessionId, events: [{ role: 'TOOL_RESULT', text }] }),",
951
- ' }).catch(() => {})',
952
- ' }',
953
- ' } catch {}',
954
- ' process.exit(0)',
955
- '})',
956
- ].join('\n')
957
- writeFileSync(AI_SESSION_HOOK_SCRIPT, hookSrc, 'utf8')
958
-
959
- const nodePath = getNodePath()
960
- const settings = {
961
- hooks: {
962
- PostToolUse: [
963
- { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_SESSION_HOOK_SCRIPT}"` }] },
964
- ],
965
- },
966
- }
967
- writeFileSync(AI_SESSION_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
968
- return AI_SESSION_SETTINGS_PATH
969
- }
970
-
971
- // gitdone MCP config for headless sessions (gd-308). We provide it ourselves —
972
- // with an X-Gitdone-Machine-Id header — so the server can attribute the AI agents
973
- // this session registers (ai_agent_start) to THIS computer, and the „АИ Агенти"
974
- // panel shows one sub-panel per machine instead of a single „Локален агент".
975
- // Used with --strict-mcp-config so the session uses exactly this server. The
976
- // agent's own key (a gdo_ key) authenticates the MCP endpoint.
977
- const AI_MCP_CONFIG_PATH = join(AGENT_DIR, 'ai-mcp-config.json')
978
-
979
- function ensureAiMcpConfig(cfg) {
980
- ensureAgentDir()
981
- const config = {
982
- mcpServers: {
983
- gitdone: {
984
- type: 'http',
985
- url: `${cfg.url}/api/mcp`,
986
- headers: {
987
- Authorization: `Bearer ${cfg.key}`,
988
- 'X-Gitdone-Machine-Id': cfg.machineId,
989
- },
990
- },
991
- },
992
- }
993
- writeFileSync(AI_MCP_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8')
994
- return AI_MCP_CONFIG_PATH
995
- }
996
-
997
- // Sanitise the model value from a command payload into a safe `--model`
998
- // argument. gitDone sends a stable CLI alias ("opus"/"sonnet"/"haiku"), but we
999
- // also allow a full model id (letters, digits, dot, dash). Anything else — or a
1000
- // missing value — yields null, so the agent falls back to the machine's own
1001
- // default model. Bounds the length as a belt-and-braces guard (gd-354).
1002
- function aiModelArg(raw) {
1003
- if (typeof raw !== 'string') return null
1004
- const m = raw.trim()
1005
- if (!m || m.length > 100) return null
1006
- return /^[A-Za-z0-9._-]+$/.test(m) ? m : null
1007
- }
1008
-
1009
- // Run a headless Claude Code session for an `ai_run` command and stream its
1010
- // output back. Long-running and fire-and-forget: it wires up async handlers and
1011
- // returns immediately so the agent's snapshot loop is never blocked.
1012
- function runAiCommand(cfg, cmd, repoPath) {
1013
- const runId = cmd.payload?.runId
1014
- const prompt = cmd.payload?.prompt ?? ''
1015
- const allowCommit = cmd.payload?.allowCommit === true
1016
- const model = aiModelArg(cmd.payload?.model)
1017
- if (!runId || !prompt) {
1018
- reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
1019
- return
1020
- }
1021
-
1022
- const { path: claudePath, shell, found } = findClaude()
1023
- if (!found) {
1024
- const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1025
- log(`✗ ai_run ${runId}: claude not found`)
1026
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', 'claude not found')
1027
- reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
1028
- return
1029
- }
1030
-
1031
- let settingsPath
1032
- try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
1033
- let mcpConfigPath
1034
- try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1035
-
1036
- // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
1037
- // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
1038
- // mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
1039
- // it), so Claude received no prompt and fell back to an empty stdin
1040
- // ("no stdin data received…"), then ran blind off whatever was in the repo.
1041
- // Piping it to stdin is robust for both the native exe and the shim.
1042
- const args = [
1043
- '-p',
1044
- '--output-format', 'stream-json',
1045
- '--verbose',
1046
- // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1047
- ...(model ? ['--model', model] : []),
1048
- '--permission-mode', 'acceptEdits',
1049
- '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1050
- // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1051
- ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1052
- ...(settingsPath ? ['--settings', settingsPath] : []),
1053
- // Our own gitdone MCP, tagged with this machine (gd-308).
1054
- ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1055
- ]
1056
-
1057
- // The PostToolUse hook reads these from its env to post raw tool output.
1058
- const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
1059
-
1060
- // Batch events on a timer so we don't hammer the server per token/line.
1061
- let pending = []
1062
- let flushing = false
1063
- const flush = async () => {
1064
- if (flushing || pending.length === 0) return
1065
- flushing = true
1066
- const batch = pending; pending = []
1067
- await postRunEvents(cfg, runId, batch)
1068
- flushing = false
1069
- }
1070
- const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
1071
- const timer = setInterval(flush, 800)
1072
-
1073
- // Accumulate model + token usage across the stream (model from init, usage
1074
- // from the final result event) to report once on exit (gd-334).
1075
- const meta = { model: undefined, usage: undefined, costUsd: undefined }
1076
- const onMeta = (m) => {
1077
- if (m.model) meta.model = m.model
1078
- if (m.usage) meta.usage = m.usage
1079
- if (typeof m.costUsd === 'number') meta.costUsd = m.costUsd
1080
- }
1081
-
1082
- log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
1083
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
1084
-
1085
- let child
1086
- try {
1087
- child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1088
- } catch (err) {
1089
- clearInterval(timer)
1090
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
1091
- reportCommandResult(cfg, cmd.id, 'error', err.message)
1092
- return
1093
- }
1094
-
1095
- // Write the prompt to stdin and close it so Claude reads it as the task.
1096
- // Swallow EPIPE in case the process exits before we finish writing.
1097
- if (child.stdin) {
1098
- child.stdin.on('error', () => {})
1099
- try { child.stdin.write(prompt); child.stdin.end() }
1100
- catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
1101
- }
1102
-
1103
- let buf = ''
1104
- child.stdout.on('data', (d) => {
1105
- buf += d.toString()
1106
- let nl
1107
- while ((nl = buf.indexOf('\n')) >= 0) {
1108
- const line = buf.slice(0, nl).trim()
1109
- buf = buf.slice(nl + 1)
1110
- if (line) parseStreamLine(line, push, undefined, undefined, onMeta)
1111
- }
1112
- })
1113
- child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
1114
- child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1115
- child.on('close', async (code) => {
1116
- clearInterval(timer)
1117
- if (buf.trim()) parseStreamLine(buf.trim(), push, undefined, undefined, onMeta)
1118
- await flush()
1119
- const ok = code === 0
1120
- // Build the usage payload + a console summary line from what we saw.
1121
- const usage = meta.usage
1122
- ? { model: meta.model, ...meta.usage, ...(typeof meta.costUsd === 'number' ? { costUsd: meta.costUsd } : {}) }
1123
- : undefined
1124
- const events = [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }]
1125
- if (usage) events.push({ kind: 'SYSTEM', text: `📊 Токени: ${fmtTokens(usage.inputTokens)} вход · ${fmtTokens(usage.outputTokens)} изход${usage.cacheReadTokens ? ` · ${fmtTokens(usage.cacheReadTokens)} кеш` : ''}${typeof usage.costUsd === 'number' ? ` · $${usage.costUsd.toFixed(4)}` : ''}` })
1126
- await postRunEvents(
1127
- cfg, runId,
1128
- events,
1129
- ok ? 'done' : 'error',
1130
- ok ? 'ok' : `exit ${code}`,
1131
- usage,
1132
- )
1133
- reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
1134
- log(`■ ai_run ${runId} приключи (code ${code})`)
1135
- })
1136
- }
1137
-
1138
- // Download the images attached to a chat turn to a per-session temp dir and
1139
- // return their local paths. `claude -p` can't take image URLs on stdin, so we
1140
- // fetch them locally and point Claude at the files (its Read tool renders
1141
- // images). Best-effort: a failed download is skipped, not fatal.
1142
- async function downloadSessionImages(sessionId, urls) {
1143
- const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
1144
- mkdirSync(dir, { recursive: true })
1145
- const paths = []
1146
- for (let i = 0; i < urls.length; i++) {
1147
- try {
1148
- const res = await fetch(urls[i])
1149
- if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
1150
- const buf = Buffer.from(await res.arrayBuffer())
1151
- const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
1152
- const ext = m ? m[1].toLowerCase() : 'png'
1153
- const file = join(dir, `image-${i + 1}.${ext}`)
1154
- writeFileSync(file, buf)
1155
- paths.push(file)
1156
- } catch (e) {
1157
- log(`✗ image download failed (${urls[i]}): ${e.message}`)
1158
- }
1159
- }
1160
- return { dir, paths }
1161
- }
1162
-
1163
- // Live chat turns, keyed by sessionId → their spawned `claude` child. Lets an
1164
- // `ai_chat_stop` command find and kill the turn a user asked to interrupt
1165
- // (gd-303). A turn registers itself on spawn and removes itself on close.
1166
- const chatChildren = new Map()
1167
-
1168
- // Kill a process AND its descendants. `claude` spawns its own children (bash,
1169
- // node hooks), so killing just the top pid would orphan them. Windows needs
1170
- // `taskkill /T`; POSIX kills the process group, falling back to a hard kill.
1171
- function killTree(child) {
1172
- const pid = child?.pid
1173
- if (!pid) return
1174
- if (process.platform === 'win32') {
1175
- try { spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }) } catch { /* best-effort */ }
1176
- } else {
1177
- try { process.kill(-pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } }
1178
- }
1179
- }
1180
-
1181
- // Run one turn of an interactive chat session: `claude -p` (resuming the prior
1182
- // Claude session when known) with the user's message on stdin, streaming the
1183
- // reply back to the session console. Long-running + fire-and-forget like ai_run.
1184
- async function runAiChat(cfg, cmd, repoPath) {
1185
- const sessionId = cmd.payload?.sessionId
1186
- const prompt = cmd.payload?.prompt ?? ''
1187
- const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
1188
- const claudeSessionId = cmd.payload?.claudeSessionId || null
1189
- const allowCommit = cmd.payload?.allowCommit === true
1190
- const model = aiModelArg(cmd.payload?.model)
1191
- // A turn may be text-only, image-only, or both — but needs at least one.
1192
- if (!sessionId || (!prompt && images.length === 0)) {
1193
- reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
1194
- return
1195
- }
1196
-
1197
- const { path: claudePath, shell, found } = findClaude()
1198
- if (!found) {
1199
- const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1200
- log(`✗ ai_chat ${sessionId}: claude not found`)
1201
- postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
1202
- reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
1203
- return
1204
- }
1205
-
1206
- let settingsPath
1207
- try { settingsPath = ensureAiSessionHookFiles() } catch (e) { log(`✗ ai session hook setup failed: ${e.message}`) }
1208
- let mcpConfigPath
1209
- try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1210
-
1211
- const args = [
1212
- '-p',
1213
- '--output-format', 'stream-json',
1214
- '--verbose',
1215
- // Stream the reply token-by-token so the console can show it being written
1216
- // live, not only once each block is complete (gd-302).
1217
- '--include-partial-messages',
1218
- // Model resolved for this session (picker / project default); omitted
1219
- // machine default (gd-354). Kept consistent across the session's turns.
1220
- ...(model ? ['--model', model] : []),
1221
- '--permission-mode', 'acceptEdits',
1222
- '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1223
- // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1224
- ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1225
- ...(claudeSessionId ? ['--resume', claudeSessionId] : []),
1226
- ...(settingsPath ? ['--settings', settingsPath] : []),
1227
- // Our own gitdone MCP, tagged with this machine so ai_agent_start attributes
1228
- // the agents to this computer (gd-308). --strict = use exactly this server.
1229
- ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1230
- ]
1231
-
1232
- const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
1233
-
1234
- // Map console kinds → session transcript roles (model text → ASSISTANT).
1235
- const roleFor = (kind) => (kind === 'TEXT' ? 'ASSISTANT' : kind)
1236
- let pending = []
1237
- let capturedSession = null // Claude's session id, sent (idempotently) for --resume
1238
- let flushing = false
1239
- let liveText = '' // in-progress text of the current assistant block (live preview)
1240
- let sentLive = '' // last streamingText we posted — so we only push on change
1241
- let lastPostAt = 0 // Date.now() of the last post, for the keep-alive heartbeat
1242
- const flush = async () => {
1243
- if (flushing) return
1244
- const hasEvents = pending.length > 0
1245
- const liveChanged = liveText !== sentLive
1246
- // Heartbeat: with nothing new for a while, still post (bumps lastActivityAt)
1247
- // so the console shows honest "still working" and can spot a real stall.
1248
- const heartbeat = !hasEvents && !liveChanged && Date.now() - lastPostAt > 2500
1249
- if (!hasEvents && !liveChanged && !heartbeat) return
1250
- flushing = true
1251
- const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
1252
- const streamingText = liveChanged ? liveText : undefined
1253
- sentLive = liveText
1254
- await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined, streamingText)
1255
- lastPostAt = Date.now()
1256
- flushing = false
1257
- }
1258
- const push = (kind, text) => {
1259
- if (text == null || String(text) === '') return
1260
- // A completed text block becomes a real event drop its live preview so the
1261
- // finalised bubble and the cleared preview swap in on the same flush.
1262
- if (kind === 'TEXT') liveText = ''
1263
- pending.push({ kind, text: String(text) })
1264
- }
1265
- const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
1266
- const onDelta = (chunk) => { liveText += chunk }
1267
- const timer = setInterval(flush, 500)
1268
-
1269
- // Fetch any attached images locally and fold their paths into the prompt so
1270
- // Claude reads them with its Read tool (URLs on stdin don't render).
1271
- let imgDir = null
1272
- let fullPrompt = prompt
1273
- if (images.length > 0) {
1274
- const dl = await downloadSessionImages(sessionId, images)
1275
- imgDir = dl.dir
1276
- if (dl.paths.length > 0) {
1277
- const list = dl.paths.map((p) => `- ${p}`).join('\n')
1278
- const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
1279
- fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1280
- }
1281
- }
1282
-
1283
- log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}, images=${images.length}) via ${claudePath}`)
1284
-
1285
- let child
1286
- try {
1287
- child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1288
- } catch (err) {
1289
- clearInterval(timer)
1290
- if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
1291
- postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
1292
- reportCommandResult(cfg, cmd.id, 'error', err.message)
1293
- return
1294
- }
1295
-
1296
- // Track this turn so an `ai_chat_stop` command can find and kill it (gd-303).
1297
- chatChildren.set(sessionId, child)
1298
-
1299
- if (child.stdin) {
1300
- child.stdin.on('error', () => {})
1301
- try { child.stdin.write(fullPrompt); child.stdin.end() }
1302
- catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
1303
- }
1304
-
1305
- let buf = ''
1306
- child.stdout.on('data', (d) => {
1307
- buf += d.toString()
1308
- let nl
1309
- while ((nl = buf.indexOf('\n')) >= 0) {
1310
- const line = buf.slice(0, nl).trim()
1311
- buf = buf.slice(nl + 1)
1312
- if (line) parseStreamLine(line, push, onInit, onDelta)
1313
- }
1314
- })
1315
- child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
1316
- child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1317
- child.on('close', async (code) => {
1318
- clearInterval(timer)
1319
- chatChildren.delete(sessionId)
1320
- if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
1321
- if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta)
1322
- liveText = '' // turn is over — drop any lingering live preview
1323
- await flush()
1324
- // User-initiated stop (gd-303): land on idle with a friendly note, not an
1325
- // "exited with code N" error the kill's non-zero code is expected here.
1326
- if (child.__stopped) {
1327
- await postSessionEvents(
1328
- cfg, sessionId,
1329
- [{ role: 'SYSTEM', text: '⏹ Спряно. Напиши още нещо, за да продължим разговора.' }],
1330
- 'idle', capturedSession || undefined, '',
1331
- )
1332
- reportCommandResult(cfg, cmd.id, 'done', 'stopped by user')
1333
- log(`⏹ ai_chat ${sessionId} спряно от потребителя`)
1334
- return
1335
- }
1336
- const ok = code === 0
1337
- await postSessionEvents(
1338
- cfg, sessionId,
1339
- ok ? [] : [{ role: 'SYSTEM', text: `✗ Ходът приключи с код ${code}.` }],
1340
- ok ? 'idle' : 'error',
1341
- capturedSession || undefined,
1342
- '', // clear the live preview on the terminal post
1343
- )
1344
- reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
1345
- log(`■ ai_chat ${sessionId} приключи (code ${code})`)
1346
- })
1347
- }
1348
-
1349
- // ai_chat_stop interrupt a running chat turn the user asked to stop (gd-303).
1350
- // Kills the tracked child (its `close` handler then posts the "⏹ Спряно" note and
1351
- // flips the session to idle). If nothing is running, just idles the session so
1352
- // the console unlocks.
1353
- function stopAiChat(cfg, cmd) {
1354
- const sessionId = cmd.payload?.sessionId
1355
- const child = sessionId ? chatChildren.get(sessionId) : null
1356
- if (!child) {
1357
- if (sessionId) postSessionEvents(cfg, sessionId, [], 'idle', undefined, '')
1358
- reportCommandResult(cfg, cmd.id, 'done', 'no active turn')
1359
- return
1360
- }
1361
- child.__stopped = true
1362
- killTree(child)
1363
- log(`⏹ ai_chat_stop ${sessionId}kill signalled`)
1364
- reportCommandResult(cfg, cmd.id, 'done', 'stop signalled')
1365
- }
1366
-
1367
- // deploy run the project's deploy script (scripts/deploy.sh) in the repo.
1368
- // Long-running (git pull + npm build + service restart), so we spawn it detached
1369
- // from the poll loop and report the result on exit. The agent is its OWN process
1370
- // (not part of any gitdone systemd service), so a `systemctl restart` inside the
1371
- // script doesn't kill the deploy mid-flight — which a server-side spawn would.
1372
- function runDeployCommand(cfg, cmd, repoPath) {
1373
- const script = 'scripts/deploy.sh'
1374
- if (!existsSync(join(repoPath, script))) {
1375
- reportCommandResult(cfg, cmd.id, 'error', `няма ${script} в repo-то — няма какво да деплойна`)
1376
- log(`✗ deploy @ ${repoPath}: липсва ${script}`)
1377
- return
1378
- }
1379
- log(`▸ deploy стартиран @ ${repoPath}`)
1380
- const child = spawn('bash', [script], { cwd: repoPath, stdio: ['ignore', 'pipe', 'pipe'] })
1381
- // Keep only the tail of the output — enough to explain a failure without
1382
- // shipping a whole build log back.
1383
- let tail = ''
1384
- const capture = (d) => { tail = (tail + d.toString()).slice(-2000) }
1385
- child.stdout.on('data', capture)
1386
- child.stderr.on('data', capture)
1387
- child.on('error', (err) => {
1388
- reportCommandResult(cfg, cmd.id, 'error', `процесна грешка: ${err.message}`)
1389
- log(`✗ deploy failed @ ${repoPath}: ${err.message}`)
1390
- })
1391
- child.on('close', (code) => {
1392
- const ok = code === 0
1393
- const detail = tail.trim() ? `\n${tail.trim()}` : ''
1394
- reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}${detail}`.slice(0, 4000))
1395
- log(`${ok ? '✓' : '✗'} deploy @ ${repoPath} приключи (code ${code})`)
1396
- })
1397
- }
1398
-
1399
- async function executeCommand(cfg, cmd, repoPath) {
1400
- // ai_run is long-running + streaming — launch it in the background and return
1401
- // so the snapshot loop keeps going. It reports its own result on exit.
1402
- if (cmd.type === 'ai_run') {
1403
- runAiCommand(cfg, cmd, repoPath)
1404
- return
1405
- }
1406
- // ai_chat one interactive turn, same fire-and-forget model.
1407
- if (cmd.type === 'ai_chat') {
1408
- runAiChat(cfg, cmd, repoPath)
1409
- return
1410
- }
1411
- // ai_chat_stop interrupt the running turn for a session (gd-303). Handled
1412
- // inline (it just signals a kill) and reports its own result.
1413
- if (cmd.type === 'ai_chat_stop') {
1414
- stopAiChat(cfg, cmd)
1415
- return
1416
- }
1417
- // deploy run the repo's deploy script. Long-running (pull + build + service
1418
- // restart), so it's launched in the background and reports its own result on
1419
- // exit, same as ai_run.
1420
- if (cmd.type === 'deploy') {
1421
- runDeployCommand(cfg, cmd, repoPath)
1422
- return
1423
- }
1424
-
1425
- let result = 'ok'
1426
- let status = 'done'
1427
- const auth = cfg.auth?.[repoPath] ?? null
1428
- try {
1429
- if (cmd.type === 'commit') {
1430
- const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
1431
- // When the UI sends a list of selected files, stage ONLY those so the
1432
- // resulting commit (and the push that follows) contains just the checked
1433
- // files. No list → stage everything (old behaviour / older UIs).
1434
- const files = Array.isArray(cmd.payload?.files)
1435
- ? cmd.payload.files.filter((f) => typeof f === 'string' && f.trim())
1436
- : []
1437
- if (files.length) {
1438
- const quoted = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ')
1439
- git(`git add -- ${quoted}`, repoPath)
1440
- } else {
1441
- git('git add -A', repoPath)
1442
- }
1443
- result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
1444
- } else if (cmd.type === 'push' || cmd.type === 'pull') {
1445
- result = pushOrPull(cmd.type, repoPath, auth)
1446
- } else if (cmd.type === 'discard') {
1447
- result = discardFile(repoPath, cmd.payload?.file)
1448
- } else {
1449
- // Unknown command type almost always a newer server feature (e.g. the
1450
- // gd-255 `deploy` command) reaching an agent too old to handle it. Do NOT
1451
- // silently mark it done: an older agent used to fall through here and
1452
- // report status=done/result=ok, so a queued deploy looked "completed" while
1453
- // nothing ran. Surface it as an error so the failure is visible and the
1454
- // user knows to update the agent.
1455
- throw new Error(`непозната команда „${cmd.type}" — обнови gitdone-agent (npm i -g gitdone-agent@latest)`)
1456
- }
1457
- log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
1458
- } catch (err) {
1459
- status = 'error'
1460
- result = redact(err.message, auth?.token)
1461
- // Cached token rejected (e.g. rotated/expired on the server) → drop it so
1462
- // the next snapshot reports hasGithubAuth:false and the server reissues one.
1463
- if (auth && AUTH_FAIL.test(err.message)) {
1464
- delete cfg.auth[repoPath]
1465
- writeConfig(cfg)
1466
- result += ' — токенът е изчистен, пробвай командата пак'
1467
- }
1468
- log(`✗ ${cmd.type} failed @ ${repoPath}: ${result}`)
1469
- }
1470
- await reportCommandResult(cfg, cmd.id, status, result)
1471
- }
1472
-
1473
- // Report discovered repos + machine info, get back which repos to track.
1474
- // ─── Claude plan usage (gd-355) ────────────────────────────────────────────
1475
- // Claude Code persists an OAuth token at ~/.claude/.credentials.json and keeps
1476
- // it fresh. We reuse it to ask Anthropic for the SAME plan-usage numbers the
1477
- // /usage command shows (account-level: a 5-hour "session" window + a weekly
1478
- // limit). The АИ Конзола then renders these live. Best-effort: on a missing or
1479
- // expired token, being offline, or any error we return null and the console
1480
- // simply keeps the last snapshot. Cached ~60s so the 30s sync loop doesn't hit
1481
- // the endpoint twice as often as it changes.
1482
- let usageCache = { at: 0, data: null }
1483
- async function readClaudeUsage() {
1484
- const now = Date.now()
1485
- if (usageCache.data && now - usageCache.at < 60_000) return usageCache.data
1486
- try {
1487
- const creds = JSON.parse(readFileSync(join(homedir(), '.claude', '.credentials.json'), 'utf8'))
1488
- const token = creds?.claudeAiOauth?.accessToken
1489
- if (!token) return null
1490
- const ctrl = new AbortController()
1491
- const to = setTimeout(() => ctrl.abort(), 8000)
1492
- let res
1493
- try {
1494
- res = await fetch('https://api.anthropic.com/api/oauth/usage', {
1495
- headers: {
1496
- Authorization: `Bearer ${token}`,
1497
- 'Content-Type': 'application/json',
1498
- 'anthropic-beta': 'oauth-2025-04-20',
1499
- 'User-Agent': 'claude-cli',
1500
- },
1501
- signal: ctrl.signal,
1502
- })
1503
- } finally { clearTimeout(to) }
1504
- if (!res.ok) return null // 401 = token expired; Claude Code refreshes it — skip this tick
1505
- const j = await res.json()
1506
- const data = {
1507
- sessionPct: Math.round(j?.five_hour?.utilization ?? 0),
1508
- sessionResetsAt: j?.five_hour?.resets_at ?? null,
1509
- weeklyPct: Math.round(j?.seven_day?.utilization ?? 0),
1510
- weeklyResetsAt: j?.seven_day?.resets_at ?? null,
1511
- }
1512
- usageCache = { at: now, data }
1513
- return data
1514
- } catch {
1515
- return null // no creds file / malformed / offline — stay quiet
1516
- }
1517
- }
1518
-
1519
- async function sync(cfg, discovered) {
1520
- const usage = await readClaudeUsage()
1521
- const data = await api(cfg, '/api/v1/agent/sync', {
1522
- machineId: cfg.machineId,
1523
- hostname: cfg.hostname,
1524
- agentVersion: AGENT_VERSION,
1525
- roots: cfg.roots,
1526
- repos: discovered,
1527
- ...(usage ? { usage } : {}),
1528
- })
1529
- return data.tracked ?? []
1530
- }
1531
-
1532
- // Push one tracked repo's snapshot and run any pending commands for it.
1533
- async function pushSnapshot(cfg, repo) {
1534
- const snapshot = getSnapshot(repo.path)
1535
- const data = await api(cfg, '/api/v1/agent/snapshot', {
1536
- machineId: cfg.machineId,
1537
- path: repo.path,
1538
- repoName: repo.name,
1539
- // Tells the server whether we already hold a push token for this repo.
1540
- // Always a boolean marks us as a token-capable agent.
1541
- hasGithubAuth: !!cfg.auth?.[repo.path],
1542
- ...snapshot,
1543
- })
1544
- // Server issued a (fresh) push token — cache it on disk so we ask only once.
1545
- if (data.githubAuth?.token && data.githubAuth?.repo) {
1546
- cfg.auth = cfg.auth ?? {}
1547
- cfg.auth[repo.path] = { token: data.githubAuth.token, repo: data.githubAuth.repo }
1548
- writeConfig(cfg)
1549
- }
1550
- for (const cmd of data.commands ?? []) {
1551
- await executeCommand(cfg, cmd, repo.path)
1552
- }
1553
- return snapshot
1554
- }
1555
-
1556
- // ─── Low-latency command channel (gd-274) ───────────────────────────────────────
1557
- // The 30s snapshot poll is fine for reflecting git state, but makes user actions
1558
- // (Push/Pull/Commit/АИ) feel sluggish: a queued command waits up to a full tick
1559
- // before the agent even asks for it. So we ALSO hold a persistent SSE connection
1560
- // to the server and drain+run commands the instant it says "wake". The snapshot
1561
- // poll stays as the reliable fallback (and cron heartbeat) if the stream drops.
1562
-
1563
- const GIT_STATE_CMDS = new Set(['commit', 'push', 'pull', 'discard'])
1564
-
1565
- // Pull this machine's pending commands in one shot (no git/snapshot work server-
1566
- // side) and run them. Serialised via a tiny mutex so overlapping wakes don't
1567
- // double-drain; a wake arriving mid-drain sets a flag to run once more after.
1568
- let draining = false
1569
- let drainAgain = false
1570
- async function drainCommands(cfg) {
1571
- if (draining) { drainAgain = true; return }
1572
- draining = true
1573
- try {
1574
- do {
1575
- drainAgain = false
1576
- let data
1577
- try {
1578
- data = await api(cfg, '/api/v1/agent/commands', { machineId: cfg.machineId })
1579
- } catch (err) {
1580
- log(`✗ command drain failed: ${err.message}`)
1581
- return
1582
- }
1583
- const commands = data.commands ?? []
1584
- if (commands.length === 0) continue
1585
-
1586
- // Cache any push tokens the server issued for these commands' repos.
1587
- if (data.githubAuth && typeof data.githubAuth === 'object') {
1588
- cfg.auth = cfg.auth ?? {}
1589
- for (const [path, a] of Object.entries(data.githubAuth)) {
1590
- if (a && a.token && a.repo) cfg.auth[path] = a
1591
- }
1592
- writeConfig(cfg)
1593
- }
1594
-
1595
- // Run each command against its own repo path, and remember which repos had
1596
- // their git state changed so we can push a fresh snapshot right after —
1597
- // that's what makes the UI update instantly instead of on the next tick.
1598
- const touched = new Map()
1599
- for (const cmd of commands) {
1600
- const repoPath = cmd.path
1601
- if (!repoPath) { log(`✗ command ${cmd.id} has no path — skipped`); continue }
1602
- await executeCommand(cfg, cmd, repoPath)
1603
- if (GIT_STATE_CMDS.has(cmd.type)) touched.set(repoPath, cmd.repoName || repoPath)
1604
- }
1605
- for (const [path, name] of touched) {
1606
- try { await pushSnapshot(cfg, { path, name }) }
1607
- catch (err) { log(`✗ post-command snapshot failed @ ${path}: ${err.message}`) }
1608
- }
1609
- } while (drainAgain)
1610
- } finally {
1611
- draining = false
1612
- }
1613
- }
1614
-
1615
- // Hold a persistent SSE connection open and drain the moment the server pushes a
1616
- // `wake`. Reconnects forever with a short backoff; the server also recycles the
1617
- // connection every few minutes (each reconnect re-sends an initial wake, so
1618
- // anything queued while we were away is picked up). Never throws.
1619
- const STREAM_RECONNECT_MS = 3000
1620
- async function streamCommands(cfg) {
1621
- const url = `${cfg.url}/api/v1/agent/commands/stream?machineId=${encodeURIComponent(cfg.machineId)}`
1622
- for (;;) {
1623
- try {
1624
- const res = await fetch(url, { headers: { Authorization: `Bearer ${cfg.key}` } })
1625
- if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
1626
- log('▶ command stream connected')
1627
- const reader = res.body.getReader()
1628
- const decoder = new TextDecoder()
1629
- let buf = ''
1630
- for (;;) {
1631
- const { value, done } = await reader.read()
1632
- if (done) break
1633
- buf += decoder.decode(value, { stream: true })
1634
- // SSE events are separated by a blank line; a `wake` means "drain now".
1635
- let idx
1636
- while ((idx = buf.indexOf('\n\n')) >= 0) {
1637
- const frame = buf.slice(0, idx)
1638
- buf = buf.slice(idx + 2)
1639
- if (/(^|\n)event:\s*wake/.test(frame)) drainCommands(cfg)
1640
- }
1641
- }
1642
- } catch (err) {
1643
- log(`… command stream disconnected (${err.message}); reconnecting`)
1644
- }
1645
- await new Promise((r) => setTimeout(r, STREAM_RECONNECT_MS))
1646
- }
1647
- }
1648
-
1649
- // ─── Main ────────────────────────────────────────────────────────────────────
1650
-
1651
- async function runLoop(cfg) {
1652
- ensureSingleInstance()
1653
- log(`gitdone-agent started machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
1654
- log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
1655
-
1656
- async function tick() {
1657
- try {
1658
- const discovered = scanRepos(cfg.roots)
1659
- const tracked = await sync(cfg, discovered)
1660
- let pushed = 0
1661
- for (const repo of tracked) {
1662
- try { await pushSnapshot(cfg, repo); pushed++ }
1663
- catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
1664
- }
1665
- log(`✓ tick discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
1666
- } catch (err) {
1667
- log(`✗ sync error: ${err.message}`)
1668
- }
1669
- }
1670
-
1671
- // Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
1672
- // its own reconnect loop and never rejects.
1673
- streamCommands(cfg)
1674
-
1675
- await tick()
1676
- setInterval(tick, cfg.interval * 1000)
1677
- }
1678
-
1679
- async function main() {
1680
- const args = parseArgs()
1681
-
1682
- if (args.doctor) {
1683
- runDoctor()
1684
- process.exit(0)
1685
- }
1686
-
1687
- if (args.uninstall) {
1688
- uninstallStartup()
1689
- process.exit(0)
1690
- }
1691
-
1692
- // Setup / install path: merge CLI args into config, optionally (re)install.
1693
- if (args.install || args.key || args.roots.length || args.url || args.interval) {
1694
- const cfg = buildConfig(args)
1695
- if (!cfg.key) {
1696
- console.error('Error: --key is required on first setup (e.g. --key=gdo_xxx)')
1697
- process.exit(1)
1698
- }
1699
- writeConfig(cfg)
1700
-
1701
- if (args.install) {
1702
- installStartup()
1703
- console.log(`✓ Инсталиран! При следващото влизане в Windows агентът ще стартира автоматично.`)
1704
- console.log(` Агент : ${STABLE_AGENT}`)
1705
- console.log(` Config : ${CONFIG_PATH}`)
1706
- console.log(` Лог : ${LOG_PATH}`)
1707
- console.log(` Roots :`)
1708
- for (const r of cfg.roots) console.log(` - ${r}`)
1709
- console.log()
1710
-
1711
- // Launch silently in the background right now.
1712
- const child = spawn('wscript.exe', [getVbsPath()], { detached: true, stdio: 'ignore' })
1713
- child.unref()
1714
- console.log('✓ Агентът е стартиран в заден план — можеш да затвориш прозореца.')
1715
- console.log(' Сега избери кои репота да следи от gitdone.eu/github.')
1716
- process.exit(0)
1717
- }
1718
-
1719
- // --key/--root without --install: just run the loop in the foreground.
1720
- await runLoop(cfg)
1721
- return
1722
- }
1723
-
1724
- // No args running mode (this is how autostart launches us): read config.
1725
- const cfg = readConfig()
1726
- if (!cfg || !cfg.key) {
1727
- console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
1728
- process.exit(1)
1729
- }
1730
- await runLoop(cfg)
1731
- }
1732
-
1733
- 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, execFileSync, spawn } from 'node:child_process'
13
+ import {
14
+ existsSync, writeFileSync, readFileSync, unlinkSync, renameSync,
15
+ mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync, statSync,
16
+ } from 'node:fs'
17
+ import { resolve, join } from 'node:path'
18
+ import { homedir, hostname, tmpdir } 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.7.0'
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
+ // Keep agent.log from growing forever: past 5 MB it rolls to agent.log.old
42
+ // (replacing the previous roll). Checked every 500 lines, not per line.
43
+ const LOG_MAX_BYTES = 5 * 1024 * 1024
44
+ let logLinesSinceCheck = 0
45
+ function rotateLogIfNeeded() {
46
+ try {
47
+ if (statSync(LOG_PATH).size > LOG_MAX_BYTES) renameSync(LOG_PATH, LOG_PATH + '.old')
48
+ } catch { /* no log yet / roll failed — keep appending */ }
49
+ }
50
+
51
+ function log(msg) {
52
+ const line = `[${new Date().toISOString()}] ${msg}`
53
+ console.log(line)
54
+ try {
55
+ ensureAgentDir()
56
+ if (logLinesSinceCheck++ % 500 === 0) rotateLogIfNeeded()
57
+ appendFileSync(LOG_PATH, line + '\n')
58
+ } catch { /* best-effort */ }
59
+ }
60
+
61
+ // ─── Crash safety ─────────────────────────────────────────────────────────────
62
+ // Node ≥15 KILLS the process on any unhandled promise rejection, and an uncaught
63
+ // exception always did — and the autostart used to be one-shot, so a single
64
+ // stray error left the machine offline until the next Windows login (gd-403).
65
+ // Log and keep running instead; the loops here are stateless enough that
66
+ // surviving is strictly better than dying. If errors come in a storm (>20/min —
67
+ // something is genuinely broken), exit(1) and let the supervisor loop restart
68
+ // us with a clean slate.
69
+ let crashBurst = { count: 0, since: Date.now() }
70
+ function survive(kind, err) {
71
+ const now = Date.now()
72
+ if (now - crashBurst.since > 60_000) crashBurst = { count: 0, since: now }
73
+ crashBurst.count++
74
+ log(`✗ ${kind}: ${err?.stack || err?.message || err}`)
75
+ if (crashBurst.count > 20) {
76
+ log('✗ over 20 crashes in 60s — exiting so the supervisor restarts us clean')
77
+ process.exit(1)
78
+ }
79
+ }
80
+ process.on('uncaughtException', (err) => survive('uncaught exception', err))
81
+ process.on('unhandledRejection', (err) => survive('unhandled rejection', err))
82
+
83
+ function readConfig() {
84
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
85
+ }
86
+
87
+ function writeConfig(cfg) {
88
+ ensureAgentDir()
89
+ // Write-then-rename so a crash / reboot mid-write can never leave a truncated
90
+ // config.json — a corrupt config makes every subsequent autostart exit
91
+ // immediately, which reads as "агентът умря и рестартът не помага" (gd-403).
92
+ const tmp = CONFIG_PATH + '.tmp'
93
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2), 'utf8')
94
+ renameSync(tmp, CONFIG_PATH)
95
+ }
96
+
97
+ // ─── Arg parsing ─────────────────────────────────────────────────────────────
98
+
99
+ function parseArgs() {
100
+ const argv = process.argv.slice(2)
101
+ const flags = {}
102
+ const roots = []
103
+ for (const a of argv) {
104
+ if (!a.startsWith('--')) continue
105
+ const [k, ...rest] = a.slice(2).split('=')
106
+ const v = rest.join('=')
107
+ if (k === 'root') { if (v) roots.push(resolve(v)) }
108
+ else flags[k] = v
109
+ }
110
+ return {
111
+ key: flags.key,
112
+ roots,
113
+ interval: flags.interval ? Number(flags.interval) : undefined,
114
+ url: flags.url ? flags.url.replace(/\/$/, '') : undefined,
115
+ install: 'install' in flags,
116
+ uninstall: 'uninstall' in flags,
117
+ doctor: 'doctor' in flags,
118
+ }
119
+ }
120
+
121
+ // Merge CLI args into the persisted config, generating a machineId on first use.
122
+ function buildConfig(args) {
123
+ const existing = readConfig() ?? {}
124
+ const mergedRoots = Array.from(new Set([...(existing.roots ?? []), ...args.roots]))
125
+ return {
126
+ key: args.key ?? existing.key,
127
+ url: args.url ?? existing.url ?? 'https://gitdone.eu',
128
+ interval: args.interval ?? existing.interval ?? 30,
129
+ machineId: existing.machineId ?? randomUUID(),
130
+ hostname: existing.hostname ?? hostname(),
131
+ roots: mergedRoots,
132
+ }
133
+ }
134
+
135
+ // ─── Windows auto-start ───────────────────────────────────────────────────────
136
+
137
+ function getStartupDir() {
138
+ return join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
139
+ }
140
+
141
+ function getVbsPath() {
142
+ return join(getStartupDir(), 'gitdone-agent.vbs')
143
+ }
144
+
145
+ function getNodePath() {
146
+ try { return execSync('where node', { encoding: 'utf8' }).trim().split('\n')[0].trim() } catch {}
147
+ try { return execSync('which node', { encoding: 'utf8' }).trim() } catch {}
148
+ return process.execPath
149
+ }
150
+
151
+ // Locate the Claude Code CLI. Prefer a real executable (claude.exe from the
152
+ // native installer) so spawn works WITHOUT a shell — that lets us pass a
153
+ // multi-line, non-ASCII prompt as a single argv element with no escaping. An
154
+ // npm shim (claude.cmd) needs shell:true, where we collapse the prompt to one
155
+ // line to survive cmd.exe parsing. Returns `found:false` when nothing was
156
+ // located, so callers can show a clear message instead of spawning bare
157
+ // `claude` and letting cmd.exe emit "'claude' is not recognized…".
158
+ function findClaude() {
159
+ const tryCmd = (c) => {
160
+ try {
161
+ const out = execSync(c, { encoding: 'utf8' }).trim()
162
+ return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
163
+ } catch { return [] }
164
+ }
165
+ const cands = [...tryCmd('where claude'), ...tryCmd('which claude')]
166
+
167
+ // The agent is auto-started at login, so its PATH is frozen at that moment —
168
+ // a `claude` installed (or a PATH entry added) afterwards is invisible to
169
+ // `where`/`which` above. Probe the well-known install locations directly so a
170
+ // freshly-installed CLI works without a Windows re-login.
171
+ const home = homedir()
172
+ const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
173
+ for (const p of [
174
+ join(home, '.local', 'bin', 'claude.exe'), // Windows native installer
175
+ join(appData, 'npm', 'claude.cmd'), // Windows npm global shim
176
+ join(home, '.local', 'bin', 'claude'), // macOS/Linux native installer
177
+ '/usr/local/bin/claude', // Homebrew (Intel) / manual install
178
+ '/opt/homebrew/bin/claude', // Homebrew (Apple silicon)
179
+ ]) {
180
+ if (existsSync(p) && !cands.includes(p)) cands.push(p)
181
+ }
182
+
183
+ const exe = cands.find((p) => /\.exe$/i.test(p))
184
+ if (exe) return { path: exe, shell: false, found: true }
185
+ if (cands.length) return { path: cands[0], shell: true, found: true }
186
+ return { path: 'claude', shell: true, found: false }
187
+ }
188
+
189
+ // ─── Single instance (gd-407) ─────────────────────────────────────────────────
190
+ // Two agents on one machine (an old one surviving an update, or a manual
191
+ // `npx gitdone-agent` next to the autostarted one) flap the machine's online
192
+ // state and race dispatches. The running agent claims the machine via a pid
193
+ // file; a newcomer that finds a LIVE agent behind it exits with EXIT_DUPLICATE,
194
+ // which the supervisor treats as "do not restart me". First one wins.
195
+ // 86 is outside node's own exit-code range (1-13) — keep in lockstep with the
196
+ // run-agent.cmd template in installStartup().
197
+ const EXIT_DUPLICATE = 86
198
+ const PID_PATH = join(AGENT_DIR, 'agent.pid')
199
+
200
+ // Command line of a live process, '' when unreadable (dead process, no rights).
201
+ function processCmdline(pid) {
202
+ try {
203
+ if (process.platform === 'win32') {
204
+ return execSync(
205
+ `powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine"`,
206
+ { encoding: 'utf8' },
207
+ ).trim()
208
+ }
209
+ return execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8' }).trim()
210
+ } catch { return '' }
211
+ }
212
+
213
+ function ensureSingleInstance() {
214
+ try {
215
+ const pid = parseInt(readFileSync(PID_PATH, 'utf8'), 10)
216
+ if (pid && pid !== process.pid) {
217
+ process.kill(pid, 0) // throws when that pid is no longer alive
218
+ // PID reuse guard: only defer to a process that really looks like an
219
+ // agent (stable copy agent.mjs, or any gitdone-agent npx/global run).
220
+ if (/gitdone-agent|agent\.mjs/i.test(processCmdline(pid))) {
221
+ log(`✗ друг gitdone-agent вече върви (PID ${pid}) — този процес излиза, за да няма два агента на машината`)
222
+ process.exit(EXIT_DUPLICATE)
223
+ }
224
+ }
225
+ } catch { /* no/stale pid file → the machine is free, we take over */ }
226
+ try { ensureAgentDir(); writeFileSync(PID_PATH, String(process.pid)) } catch { /* best-effort */ }
227
+ }
228
+
229
+ // Kill any previously-running agent processes so an update applies WITHOUT a
230
+ // Windows re-login. Targets node processes that look like a gitdone agent —
231
+ // both the stable copy in ~/.gitdone-agent AND one running straight from the
232
+ // npx cache / global install (its command line contains "gitdone-agent") —
233
+ // excluding ourselves and our parent (the npx wrapper that launched us).
234
+ // Best-effort; failures are non-fatal.
235
+ function stopRunningAgents() {
236
+ // Kill the supervisor loops (cmd.exe running run-agent.cmd) FIRST, then the
237
+ // agents — the other order lets a still-alive supervisor immediately respawn
238
+ // the agent we just killed, leaving two agents reporting after an update.
239
+ const ps = [
240
+ "$ErrorActionPreference='SilentlyContinue'",
241
+ "Get-CimInstance Win32_Process -Filter \"Name='cmd.exe'\" |",
242
+ " Where-Object { $_.CommandLine -like '*run-agent.cmd*' } |",
243
+ ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
244
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" |",
245
+ ` Where-Object { ($_.CommandLine -like '*gitdone-agent*' -or $_.CommandLine -like '*agent.mjs*') -and $_.ProcessId -ne ${process.pid} -and $_.ProcessId -ne ${process.ppid || 0} } |`,
246
+ ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
247
+ ].join('\n')
248
+ try {
249
+ ensureAgentDir()
250
+ const scriptPath = join(AGENT_DIR, 'stop-agents.ps1')
251
+ writeFileSync(scriptPath, ps, 'utf8')
252
+ execSync(`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, { stdio: 'ignore' })
253
+ } catch { /* best-effort — at worst the old agent lingers until next login */ }
254
+ }
255
+
256
+ function installStartup() {
257
+ ensureAgentDir()
258
+ const nodePath = getNodePath()
259
+
260
+ // Stop the previously-running agent(s) first so we don't end up with the old
261
+ // build still reporting alongside the new one until the next Windows login.
262
+ stopRunningAgents()
263
+
264
+ // Copy this script to a stable location. process.argv[1] may point inside an
265
+ // npx cache dir that gets purged later — referencing it from autostart would
266
+ // silently break on the next login. A stable copy survives.
267
+ try {
268
+ copyFileSync(resolve(process.argv[1]), STABLE_AGENT)
269
+ } catch (err) {
270
+ console.error(`Warning: could not copy agent to ${STABLE_AGENT}: ${err.message}`)
271
+ }
272
+ const agentPath = existsSync(STABLE_AGENT) ? STABLE_AGENT : resolve(process.argv[1])
273
+
274
+ // The autostart used to launch the agent DIRECTLY — one crash and the machine
275
+ // stayed offline until the next Windows login (gd-403). Now the VBS launches a
276
+ // tiny supervisor loop instead: run the agent, and if it ever exits, note it
277
+ // in agent-crash.log and start it again after 10s. stderr is captured too, so
278
+ // node-level failures (corrupt file, bad path) finally leave a trace. If the
279
+ // node path recorded at install time vanished (node upgraded/moved), fall
280
+ // back to whatever `node` is on PATH.
281
+ //
282
+ // Restart forever on normal crashes, but STOP on process-START failures
283
+ // (0xc0000142 STATUS_DLL_INIT_FAILED, 0xc0000135 missing DLL): those mean the
284
+ // login session itself is broken (e.g. after a hardware crash), every attempt
285
+ // pops a blocking "Application Error" dialog, and a retry can never succeed —
286
+ // the loop turned into an endless dialog storm (gd-405). Same for the
287
+ // ping-as-sleep: if even ping can't start, bail instead of spinning with no
288
+ // delay at all. A Windows re-login restarts the supervisor cleanly.
289
+ const crashLog = join(AGENT_DIR, 'agent-crash.log')
290
+ const cmd = [
291
+ '@echo off',
292
+ 'rem gitdone-agent supervisor — restarts the agent if it ever dies (gd-403)',
293
+ `set "NODE=${nodePath}"`,
294
+ 'if not exist "%NODE%" set "NODE=node"',
295
+ ':loop',
296
+ `"%NODE%" "${agentPath}" 2>> "${crashLog}"`,
297
+ 'set CODE=%errorlevel%',
298
+ `echo [%date% %time%] agent exited (code %CODE%) - restart in 10s >> "${crashLog}"`,
299
+ 'if "%CODE%"=="86" goto duplicate',
300
+ 'if "%CODE%"=="-1073741502" goto dead',
301
+ 'if "%CODE%"=="-1073741515" goto dead',
302
+ 'ping -n 11 127.0.0.1 >nul 2>nul || goto dead',
303
+ 'goto loop',
304
+ ':dead',
305
+ `echo [%date% %time%] node cannot start (code %CODE%) - supervisor giving up until next login >> "${crashLog}"`,
306
+ 'exit /b',
307
+ ':duplicate',
308
+ `echo [%date% %time%] another agent already runs this machine (code 86) - this supervisor exits >> "${crashLog}"`,
309
+ ].join('\r\n')
310
+ const cmdPath = join(AGENT_DIR, 'run-agent.cmd')
311
+ writeFileSync(cmdPath, cmd, 'utf8')
312
+
313
+ // VBScript runs the supervisor with window style 0 so nothing pops up on login.
314
+ const vbs = [
315
+ 'Set WshShell = CreateObject("WScript.Shell")',
316
+ `WshShell.Run """${cmdPath}""", 0, False`,
317
+ ].join('\r\n')
318
+
319
+ writeFileSync(getVbsPath(), vbs, 'utf8')
320
+ }
321
+
322
+ function uninstallStartup() {
323
+ const vbsPath = getVbsPath()
324
+ if (existsSync(vbsPath)) {
325
+ unlinkSync(vbsPath)
326
+ console.log(`✓ Премахнат автостарт за gitdone-agent`)
327
+ } else {
328
+ console.log(`Не е намерен автостарт (${vbsPath})`)
329
+ }
330
+ const cmdPath = join(AGENT_DIR, 'run-agent.cmd')
331
+ if (existsSync(cmdPath)) unlinkSync(cmdPath)
332
+ // Also stop the live supervisor + agent — otherwise they keep running (and
333
+ // the supervisor keeps resurrecting the agent) until the next reboot.
334
+ stopRunningAgents()
335
+ }
336
+
337
+ // ─── Doctor ────────────────────────────────────────────────────────────────────
338
+
339
+ function runDoctor() {
340
+ const cfg = readConfig()
341
+ console.log('gitdone-agent doctor')
342
+ console.log(` node : ${getNodePath()}`)
343
+ console.log(` agent dir : ${AGENT_DIR} ${existsSync(AGENT_DIR) ? '(ok)' : '(MISSING)'}`)
344
+ console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
345
+ console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
346
+ console.log(` supervisor : ${join(AGENT_DIR, 'run-agent.cmd')} ${existsSync(join(AGENT_DIR, 'run-agent.cmd')) ? '(ok)' : '(not installed — пусни --install за авторестарт при crash)'}`)
347
+ console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
348
+ console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
349
+ console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
350
+ const claude = findClaude()
351
+ console.log(` claude cli : ${claude.found ? claude.path : 'NOT FOUND — инсталирай от claude.ai/code и рестартирай агента'}`)
352
+ if (cfg) {
353
+ console.log(` machineId : ${cfg.machineId}`)
354
+ console.log(` server : ${cfg.url}`)
355
+ console.log(` interval : ${cfg.interval}s`)
356
+ console.log(` roots :`)
357
+ for (const r of cfg.roots ?? []) console.log(` - ${r} ${existsSync(r) ? '' : '(MISSING)'}`)
358
+ if (cfg.roots?.length) {
359
+ const found = scanRepos(cfg.roots)
360
+ console.log(` found repos : ${found.length}`)
361
+ for (const r of found) console.log(` - ${r.name} (${r.path})`)
362
+ }
363
+ }
364
+ }
365
+
366
+ // ─── Git helpers ─────────────────────────────────────────────────────────────
367
+
368
+ // Room for large command output. `git status --porcelain -uall` (gd-369) lists
369
+ // every untracked file individually, so a repo with a big new/untracked tree can
370
+ // blow past execSync's default 1 MB buffer — which would throw and silently
371
+ // return '' (an EMPTY snapshot, worse than the collapsed view). 64 MB is plenty.
372
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024
373
+
374
+ // Cap for synthesising an untracked file's full-content diff (gd-370) — above
375
+ // this the file keeps the "no diff" placeholder so a huge/binary blob can't
376
+ // bloat the snapshot payload.
377
+ const UNTRACKED_DIFF_MAX_BYTES = 1024 * 1024
378
+
379
+ // Synthesising a per-file diff spawns a synchronous `git diff --no-index` per
380
+ // untracked file. Individually cheap, but a repo with a huge untracked/scratch
381
+ // tree (e.g. extracted game assets — thousands of files) would spawn thousands
382
+ // of blocking git processes and freeze the single-threaded agent for MINUTES on
383
+ // every snapshot, starving the 30s sync heartbeat so the machine flips offline.
384
+ // Bound the synthesis two ways — a file-count cap and a wall-clock budget — so
385
+ // no single repo can ever monopolise the event loop. Anything past either keeps
386
+ // the "no diff" placeholder, identical to the size-cap path above.
387
+ const UNTRACKED_DIFF_MAX_FILES = 200
388
+ const UNTRACKED_DIFF_TIME_BUDGET_MS = 3000
389
+
390
+ // Every git call here is execSync — synchronous, on the ONLY thread. A git that
391
+ // stops to ask for credentials (a repo whose remote lost its token → Git
392
+ // Credential Manager pops an invisible dialog) blocks the whole agent forever:
393
+ // no ticks, no heartbeat, machine flips offline until someone restarts it
394
+ // (gd-403). Two guards: never allow interactive prompts, and hard-timeout every
395
+ // call so the worst case is one failed command, not a dead agent. Local
396
+ // commands get 2 min; push/pull (network) get 5.
397
+ const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
398
+ const GIT_TIMEOUT_MS = 2 * 60 * 1000
399
+ const GIT_NET_TIMEOUT_MS = 5 * 60 * 1000
400
+
401
+ function git(cmd, cwd) {
402
+ try {
403
+ return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, env: GIT_ENV }).trim()
404
+ } catch {
405
+ return ''
406
+ }
407
+ }
408
+
409
+ // Like git(), but returns the RAW bytes instead of a UTF-8 string. Needed for
410
+ // `git diff`, whose payload can be Windows-1251 (CP1251) Cyrillic — decoding
411
+ // those bytes as UTF-8 up front would corrupt them to „�"/„?" irreversibly
412
+ // (gd-276). Callers decode per-file via decodeDiffText().
413
+ function gitRaw(cmd, cwd) {
414
+ try {
415
+ return execSync(cmd, { cwd, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, env: GIT_ENV })
416
+ } catch {
417
+ return Buffer.alloc(0)
418
+ }
419
+ }
420
+
421
+ // Full-content diff of a single UNTRACKED file (against /dev/null), so the
422
+ // console can show a new file's whole content as an all-added diff — like
423
+ // GitHub Desktop (gd-370). Uses execFileSync (argv, no shell) so paths with
424
+ // spaces / Cyrillic reach git intact, unlike a shell command string. `--no-index`
425
+ // exits 1 when the file differs from empty (i.e. always) — that's expected, and
426
+ // its stdout still holds the diff. Returns raw bytes for parseDiffByFile.
427
+ function gitDiffUntracked(file, cwd) {
428
+ try {
429
+ return execFileSync('git', ['diff', '--no-index', '--', '/dev/null', file], {
430
+ cwd, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, env: GIT_ENV,
431
+ })
432
+ } catch (err) {
433
+ return err && err.stdout && err.stdout.length ? err.stdout : Buffer.alloc(0)
434
+ }
435
+ }
436
+
437
+ // Like git(), but surfaces failures (with stderr) instead of swallowing them —
438
+ // used for push/pull where we need to detect auth rejection.
439
+ function gitTry(cmd, cwd) {
440
+ try {
441
+ const out = execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: GIT_NET_TIMEOUT_MS, env: GIT_ENV }).trim()
442
+ return { ok: true, out }
443
+ } catch (err) {
444
+ const timedOut = err.signal === 'SIGTERM' && err.code == null
445
+ const out = (err.stderr || err.stdout || (timedOut ? `git не отговори ${GIT_NET_TIMEOUT_MS / 60000} мин и беше прекратен` : err.message) || '').toString().trim()
446
+ return { ok: false, out }
447
+ }
448
+ }
449
+
450
+ // Never let a token reach the log file or the server's command-result.
451
+ function redact(text, token) {
452
+ if (!text || !token) return text
453
+ return text.split(token).join('***')
454
+ }
455
+
456
+ // https://x-access-token:<token>@github.com/<owner>/<repo>.git — an ad-hoc URL
457
+ // passed straight to push/pull, so it never gets written into .git/config.
458
+ function authUrl(auth) {
459
+ const repo = auth.repo.replace(/\.git$/i, '')
460
+ return `https://x-access-token:${auth.token}@github.com/${repo}.git`
461
+ }
462
+
463
+ // A push rejected because the remote branch moved ahead of us (someone else
464
+ // pushed in the meantime). Git prints "non-fast-forward" / "fetch first" /
465
+ // "tip of your current branch is behind". We recover by pulling+rebasing.
466
+ const NON_FAST_FORWARD = /non-fast-forward|fetch first|tip of your current branch is behind|failed to push some refs/i
467
+
468
+ // Point the local remote-tracking ref (e.g. refs/remotes/origin/master) at the
469
+ // commit we just synced. We push/pull through the ad-hoc token URL, and — unlike
470
+ // `git push origin` — pushing/pulling by URL leaves refs/remotes/origin/* UNTOUCHED.
471
+ // getSnapshot() then measures "ahead"/"behind" against that stale tracking ref
472
+ // (git rev-list @{u}..HEAD), so already-pushed commits keep showing as pending
473
+ // forever: the ahead badge never clears, GitHub Desktop shows the same "N↑", and
474
+ // the user pushes again and again while local commits pile up (gd-273). Fast-
475
+ // forwarding the tracking ref to the true remote tip resets ahead/behind to reality.
476
+ // Best-effort: git() swallows failures so this never breaks the push/pull itself.
477
+ function updateTrackingRef(repoPath, branch, commitish) {
478
+ // Prefer the branch's configured upstream (usually origin/<branch>); fall back
479
+ // to origin/<branch> when no upstream is set.
480
+ const upstream = git('git rev-parse --abbrev-ref --symbolic-full-name @{u}', repoPath)
481
+ const ref = upstream ? `refs/remotes/${upstream}` : `refs/remotes/origin/${branch}`
482
+ git(`git update-ref "${ref}" ${commitish}`, repoPath)
483
+ }
484
+
485
+ // Push/pull using the server-issued token when we have one; otherwise fall back
486
+ // to the machine's own git credentials (old behaviour). Throws on failure.
487
+ function pushOrPull(type, repoPath, auth) {
488
+ const branch = git('git branch --show-current', repoPath) || 'HEAD'
489
+ const url = auth ? authUrl(auth) : null
490
+ const pushCmd = url ? `git push "${url}" HEAD:${branch}` : 'git push'
491
+
492
+ if (type === 'pull') {
493
+ const r = gitTry(url ? `git pull "${url}" ${branch}` : 'git pull', repoPath)
494
+ if (!r.ok) throw new Error(r.out || 'pull failed')
495
+ // Pulling by URL never advanced origin/<branch>; point it at the fetched tip
496
+ // (FETCH_HEAD) so a later push isn't seen as being "ahead" of a stale ref.
497
+ if (url) updateTrackingRef(repoPath, branch, 'FETCH_HEAD')
498
+ return r.out || 'pulled'
499
+ }
500
+
501
+ let r = gitTry(pushCmd, repoPath)
502
+ if (r.ok) {
503
+ // We just pushed HEAD to the remote branch, so the remote tip == HEAD. Sync
504
+ // the local tracking ref to clear the "ahead" count (gd-273).
505
+ if (url) updateTrackingRef(repoPath, branch, 'HEAD')
506
+ return r.out || 'pushed'
507
+ }
508
+
509
+ // Push rejected because the remote is ahead (gd-272): commit succeeded but the
510
+ // push bounced with non-fast-forward, so the files looked "pushed" while the
511
+ // UI showed a scary git error. Pull with --rebase to replay our commits on top
512
+ // of the remote ones, then push again — what the user means by "push my files".
513
+ if (NON_FAST_FORWARD.test(r.out)) {
514
+ const pull = gitTry(url ? `git pull --rebase "${url}" ${branch}` : 'git pull --rebase', repoPath)
515
+ if (!pull.ok) {
516
+ // Rebase couldn't apply cleanly (conflicts) — abort so the repo isn't left
517
+ // mid-rebase, and surface an actionable message instead of guessing.
518
+ gitTry('git rebase --abort', repoPath)
519
+ throw new Error(`отдалеченият клон е напред и има конфликт при обединяване — дръпни (Sync) и слей ръчно, после пусни пак.\n${pull.out}`)
520
+ }
521
+ r = gitTry(pushCmd, repoPath)
522
+ if (!r.ok) throw new Error(r.out || 'push failed')
523
+ if (url) updateTrackingRef(repoPath, branch, 'HEAD')
524
+ return `дръпнах новите промени от сървъра и пушнах наново.\n${r.out}`.trim()
525
+ }
526
+
527
+ throw new Error(r.out || 'push failed')
528
+ }
529
+
530
+ const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
531
+
532
+ // Discard all local changes to a SINGLE file, so it disappears from the repo's
533
+ // "changes". Mirrors GitHub Desktop's "Discard changes": unstage first, then
534
+ // either restore the file from HEAD (tracked) or delete it (new/untracked).
535
+ function discardFile(repoPath, file) {
536
+ if (!file) throw new Error('no file')
537
+ // Unstage so index + worktree get reverted together (no-op if not staged).
538
+ gitTry(`git reset -q HEAD -- "${file}"`, repoPath)
539
+ // Does the file exist in the last commit? If so we restore its content; if
540
+ // not, it's a newly-added/untracked file and discarding means removing it.
541
+ const inHead = gitTry(`git cat-file -e "HEAD:${file}"`, repoPath).ok
542
+ if (inHead) {
543
+ const r = gitTry(`git checkout HEAD -- "${file}"`, repoPath)
544
+ if (!r.ok) throw new Error(r.out || 'checkout failed')
545
+ return 'discarded'
546
+ }
547
+ // New/untracked file (now unstaged) — drop it from the working tree.
548
+ const r = gitTry(`git clean -fdq -- "${file}"`, repoPath)
549
+ if (!r.ok) throw new Error(r.out || 'clean failed')
550
+ return 'discarded (new file removed)'
551
+ }
552
+
553
+ // ─── Windows-1251 (CP1251) aware diff decoding (gd-276) ───────────────────────
554
+ // Legacy Cyrillic sources are often saved in CP1251. Their high bytes (0x80–0xFF,
555
+ // single-byte) are invalid UTF-8, so reading the diff as UTF-8 shows „?"/„�"
556
+ // instead of кирилица. We keep the raw bytes and decode each file's diff section
557
+ // on its own: valid UTF-8 stays UTF-8; the rest goes through the CP1251 table
558
+ // below — so a UTF-8 repo is untouched while CP1251 files finally read correctly.
559
+
560
+ // CP1251 high range 0x80–0xFF → Unicode code points (0x00–0x7F is plain ASCII).
561
+ // 0x98 is unassigned in CP1251 → U+FFFD.
562
+ const CP1251_HIGH = [
563
+ 0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
564
+ 0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
565
+ 0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
566
+ 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
567
+ 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F,
568
+ 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F,
569
+ 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F,
570
+ 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F,
571
+ ]
572
+
573
+ function decodeCp1251(buf) {
574
+ let out = ''
575
+ for (const b of buf) out += String.fromCharCode(b < 0x80 ? b : CP1251_HIGH[b - 0x80])
576
+ return out
577
+ }
578
+
579
+ // A strict UTF-8 decoder (always available even on small-ICU builds) — throws on
580
+ // any invalid sequence, which is exactly how we tell UTF-8 apart from CP1251.
581
+ const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true })
582
+ function decodeDiffText(buf) {
583
+ try {
584
+ return UTF8_STRICT.decode(buf)
585
+ } catch {
586
+ return decodeCp1251(buf)
587
+ }
588
+ }
589
+
590
+ // Git C-quotes paths with spaces / special chars / non-ASCII bytes, wrapping them
591
+ // in double quotes with backslash escapes (`"export coca_cola/x.cs"`, octal `\NNN`
592
+ // for raw bytes). Both `git status --porcelain` and the `diff --git` header do it.
593
+ // Left unhandled, files with spaces (or Cyrillic) got mis-keyed and their diff was
594
+ // dropped (gd-321). This reverses it back to a plain path so the diff key matches
595
+ // the file name shown in the list.
596
+ function unquoteGitPath(s) {
597
+ if (typeof s !== 'string' || s.length < 2 || s[0] !== '"' || s[s.length - 1] !== '"') return s
598
+ const body = s.slice(1, -1)
599
+ const bytes = []
600
+ for (let i = 0; i < body.length; i++) {
601
+ if (body[i] === '\\' && i + 1 < body.length) {
602
+ const n = body[i + 1]
603
+ if (n === 'n') { bytes.push(10); i++ }
604
+ else if (n === 't') { bytes.push(9); i++ }
605
+ else if (n === 'r') { bytes.push(13); i++ }
606
+ else if (n === '"') { bytes.push(34); i++ }
607
+ else if (n === '\\') { bytes.push(92); i++ }
608
+ else if (n >= '0' && n <= '7') { bytes.push(parseInt(body.substr(i + 1, 3), 8) & 0xff); i += 3 }
609
+ else { bytes.push(body.charCodeAt(i)) }
610
+ } else {
611
+ bytes.push(body.charCodeAt(i) & 0xff)
612
+ }
613
+ }
614
+ return Buffer.from(bytes).toString('utf8')
615
+ }
616
+
617
+ // Read the b-side path from a `diff --git` header line, handling git's quoting
618
+ // of paths with spaces / special chars (gd-321).
619
+ function diffHeaderPath(hdr) {
620
+ const q = hdr.match(/ ("b\/.*")$/) // quoted: "a/x" "b/x"
621
+ if (q) return unquoteGitPath(q[1]).replace(/^b\//, '')
622
+ const u = hdr.match(/ b\/(.*)$/) // plain: a/x b/x
623
+ return u ? u[1] : null
624
+ }
625
+
626
+ function parseDiffByFile(diffBuf) {
627
+ const files = {}
628
+ if (!diffBuf || diffBuf.length === 0) return files
629
+ // Latin1 is a lossless 1-byte⇢1-char mapping, so the ASCII "diff --git" split
630
+ // markers match while every original byte survives for the per-section decode.
631
+ const raw = Buffer.isBuffer(diffBuf) ? diffBuf.toString('latin1') : String(diffBuf)
632
+ const sections = raw.split(/(?=^diff --git )/m)
633
+ for (const section of sections) {
634
+ if (!section.trim()) continue
635
+ // Decode THIS file's bytes on their own (UTF-8 or CP1251), then read the
636
+ // filename from the decoded header (quoted or not).
637
+ const text = decodeDiffText(Buffer.from(section, 'latin1'))
638
+ const hdr = text.match(/^diff --git (.+)$/m)
639
+ const name = hdr ? diffHeaderPath(hdr[1]) : null
640
+ if (name) files[name] = text
641
+ }
642
+ return files
643
+ }
644
+
645
+ function getSnapshot(repoPath) {
646
+ const branch = git('git branch --show-current', repoPath) || 'HEAD'
647
+
648
+ // -uall lists every untracked file individually instead of collapsing a new
649
+ // directory into a single `dir/` entry — so the change list matches what
650
+ // GitHub Desktop shows, file-for-file (gd-369).
651
+ const statusLines = git('git status --porcelain -uall', repoPath).split('\n').filter(Boolean)
652
+ const modified = []
653
+ const staged = []
654
+ const statuses = {}
655
+ for (const line of statusLines) {
656
+ const xy = line.slice(0, 2)
657
+ const file = unquoteGitPath(line.slice(3))
658
+ statuses[file] = xy
659
+ if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
660
+ if (xy[1] !== ' ') modified.push(file)
661
+ }
662
+
663
+ const aheadBy = parseInt(git('git rev-list --count @{u}..HEAD', repoPath), 10) || 0
664
+ const behindBy = parseInt(git('git rev-list --count HEAD..@{u}', repoPath), 10) || 0
665
+
666
+ const logLine = git('git log -1 --pretty=format:%H|%s|%an|%aI', repoPath)
667
+ let lastCommit = null
668
+ if (logLine) {
669
+ const [sha, message, author, date] = logLine.split('|')
670
+ lastCommit = { sha, message, author, date }
671
+ }
672
+
673
+ const diffs = parseDiffByFile(gitRaw('git diff HEAD', repoPath))
674
+
675
+ // Untracked files (`??`) aren't in `git diff HEAD`, so they had no content to
676
+ // show ("Нов или untracked файл — няма diff"). Synthesise an all-added diff for
677
+ // each from /dev/null so the console shows the whole new file — like GitHub
678
+ // Desktop (gd-370). Skip anything over the size cap so a stray big/binary blob
679
+ // can't bloat the snapshot; those keep the "no diff" placeholder.
680
+ const untracked = Object.keys(statuses).filter((f) => statuses[f] === '??' && !diffs[f])
681
+ const diffDeadline = Date.now() + UNTRACKED_DIFF_TIME_BUDGET_MS
682
+ let synthesised = 0
683
+ for (const file of untracked) {
684
+ // Stop before either bound so a scratch tree can't starve the heartbeat; the
685
+ // rest keep the "no diff" placeholder. Log once so it's visible why.
686
+ if (synthesised >= UNTRACKED_DIFF_MAX_FILES || Date.now() > diffDeadline) {
687
+ log(`⚠ untracked diff cap reached @ ${repoPath} (${synthesised}/${untracked.length} synthesised) — rest shown without preview`)
688
+ break
689
+ }
690
+ let st
691
+ try { st = statSync(join(repoPath, file)) } catch { continue }
692
+ if (!st.isFile() || st.size > UNTRACKED_DIFF_MAX_BYTES) continue
693
+ synthesised++
694
+ const parsed = parseDiffByFile(gitDiffUntracked(file, repoPath))
695
+ const val = parsed[file] ?? Object.values(parsed)[0]
696
+ if (val) diffs[file] = val
697
+ }
698
+
699
+ // Origin remote → lets the server auto-detect the GitHub repo for the History tab.
700
+ const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
701
+
702
+ return { branch, modified, staged, aheadBy, behindBy, lastCommit, statuses, diffs, remoteUrl }
703
+ }
704
+
705
+ // ─── Repo discovery ─────────────────────────────────────────────────────────────
706
+ // Walk each root looking for directories that contain a `.git` entry. Stop
707
+ // descending once a repo is found (don't recurse into submodules/nested repos),
708
+ // skip noisy dirs, and cap depth so a huge tree can't hang a tick.
709
+
710
+ const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', '.cache', 'vendor', '.venv', '__pycache__'])
711
+
712
+ function scanRepos(roots, maxDepth = 5) {
713
+ const found = []
714
+ const seen = new Set()
715
+
716
+ function walk(dir, depth) {
717
+ if (depth > maxDepth) return
718
+ let entries
719
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
720
+
721
+ if (entries.some((e) => e.name === '.git')) {
722
+ const path = resolve(dir)
723
+ if (!seen.has(path)) {
724
+ seen.add(path)
725
+ found.push({ name: path.split(/[\\/]/).pop() ?? path, path })
726
+ }
727
+ return // a repo — don't descend further
728
+ }
729
+
730
+ for (const e of entries) {
731
+ if (!e.isDirectory()) continue
732
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
733
+ walk(join(dir, e.name), depth + 1)
734
+ }
735
+ }
736
+
737
+ for (const root of roots) walk(resolve(root), 0)
738
+ return found
739
+ }
740
+
741
+ // ─── Server sync + commands ─────────────────────────────────────────────────────
742
+
743
+ async function api(cfg, path, body) {
744
+ const res = await fetch(`${cfg.url}${path}`, {
745
+ method: 'POST',
746
+ headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
747
+ body: JSON.stringify(body),
748
+ })
749
+ if (!res.ok) {
750
+ const text = await res.text().catch(() => '')
751
+ throw new Error(`HTTP ${res.status} ${path}: ${text}`)
752
+ }
753
+ return res.json().catch(() => ({}))
754
+ }
755
+
756
+ async function reportCommandResult(cfg, id, status, result) {
757
+ await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
758
+ }
759
+
760
+ // Post a batch of console events (and optional lifecycle status) for an AiRun.
761
+ // `usage` is sent once on the final (done/error) post so the server can record
762
+ // how many tokens the run cost. `streamingText` / `activityText` (gd-419) carry
763
+ // the live "being typed" preview and the latest thinking snippet — explicit ''
764
+ // clears them; `undefined` leaves them untouched.
765
+ async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText) {
766
+ await api(cfg, '/api/v1/agent/ai-run/events', {
767
+ runId,
768
+ events,
769
+ ...(status ? { status } : {}),
770
+ ...(result !== undefined ? { result } : {}),
771
+ ...(usage ? { usage } : {}),
772
+ ...(streamingText !== undefined ? { streamingText } : {}),
773
+ ...(activityText !== undefined ? { activityText } : {}),
774
+ }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
775
+ }
776
+
777
+ // Post transcript lines (and optional turn status / Claude session id) for an
778
+ // interactive AiSession chat turn.
779
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText, activityText) {
780
+ await api(cfg, '/api/v1/agent/ai-session/events', {
781
+ sessionId,
782
+ events,
783
+ ...(status ? { status } : {}),
784
+ ...(claudeSessionId ? { claudeSessionId } : {}),
785
+ // Explicit '' clears the live preview; `undefined` leaves it untouched.
786
+ ...(streamingText !== undefined ? { streamingText } : {}),
787
+ // Same semantics for the live thinking snippet (gd-419).
788
+ ...(activityText !== undefined ? { activityText } : {}),
789
+ }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
790
+ }
791
+
792
+ // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
793
+ // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
794
+ // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
795
+ // With `--include-partial-messages`, Claude also emits `stream_event` lines
796
+ // carrying incremental text deltas onDelta gets those so chat sessions can
797
+ // show the reply being written live (gd-302).
798
+ // "12.3k" / "1.2M" compaction for the console token summary line.
799
+ function fmtTokens(n) {
800
+ n = Number(n) || 0
801
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
802
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
803
+ return String(n)
804
+ }
805
+
806
+ function parseStreamLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
807
+ let ev
808
+ try { ev = JSON.parse(line) } catch { return }
809
+ if (ev.type === 'system') {
810
+ if (ev.subtype === 'init') {
811
+ push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
812
+ // Surface Claude's own session id so chat sessions can --resume it.
813
+ if (ev.session_id && typeof onInit === 'function') onInit(ev.session_id)
814
+ // Remember which model ran, for the token/cost record (gd-334).
815
+ if (ev.model && typeof onMeta === 'function') onMeta({ model: ev.model })
816
+ }
817
+ return
818
+ }
819
+ // Partial-message stream: the model's visible text deltas feed the live
820
+ // preview, its thinking deltas feed the gray "какво прави АИ-то" snippet
821
+ // (gd-419). Tool-input JSON deltas stay ignored — the full block still
822
+ // arrives as a normal `assistant` event below.
823
+ if (ev.type === 'stream_event' && typeof onDelta === 'function') {
824
+ const e = ev.event
825
+ if (e?.type === 'content_block_delta') {
826
+ if (e.delta?.type === 'text_delta' && e.delta.text) onDelta(e.delta.text, 'text')
827
+ else if (e.delta?.type === 'thinking_delta' && e.delta.thinking) onDelta(e.delta.thinking, 'thinking')
828
+ } else if (e?.type === 'content_block_start' && e.content_block?.type === 'thinking') {
829
+ // A fresh thought begins reset the snippet so old and new don't blend.
830
+ onDelta('', 'thinking-start')
831
+ }
832
+ return
833
+ }
834
+ if (ev.type === 'assistant' && ev.message?.content) {
835
+ for (const block of ev.message.content) {
836
+ if (block.type === 'text' && block.text?.trim()) {
837
+ push('TEXT', block.text.trim())
838
+ } else if (block.type === 'tool_use') {
839
+ const input = block.input ? JSON.stringify(block.input) : ''
840
+ push('TOOL_CALL', `${block.name}${input ? ` ${input.slice(0, 400)}` : ''}`)
841
+ }
842
+ }
843
+ return
844
+ }
845
+ if (ev.type === 'result') {
846
+ if (ev.subtype && ev.subtype !== 'success') push('SYSTEM', `Резултат: ${ev.subtype}`)
847
+ // claude's final result carries cumulative token usage + its own cost.
848
+ // Prefer `modelUsage` (summed over every model/subagent turn) which is the
849
+ // true cumulative; top-level `usage` is often just the last turn. Fall back
850
+ // to `usage` when modelUsage is absent (older CLI).
851
+ if (typeof onMeta === 'function' && (ev.modelUsage || ev.usage || typeof ev.total_cost_usd === 'number')) {
852
+ let usage
853
+ let model
854
+ if (ev.modelUsage && typeof ev.modelUsage === 'object') {
855
+ const acc = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 }
856
+ for (const [name, mu] of Object.entries(ev.modelUsage)) {
857
+ if (!model) model = name
858
+ acc.inputTokens += mu.inputTokens ?? mu.input_tokens ?? 0
859
+ acc.outputTokens += mu.outputTokens ?? mu.output_tokens ?? 0
860
+ acc.cacheReadTokens += mu.cacheReadInputTokens ?? mu.cache_read_input_tokens ?? 0
861
+ acc.cacheCreateTokens += mu.cacheCreationInputTokens ?? mu.cache_creation_input_tokens ?? 0
862
+ }
863
+ usage = acc
864
+ } else if (ev.usage) {
865
+ const u = ev.usage
866
+ usage = {
867
+ inputTokens: u.input_tokens ?? 0,
868
+ outputTokens: u.output_tokens ?? 0,
869
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
870
+ cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
871
+ }
872
+ }
873
+ onMeta({
874
+ ...(usage ? { usage } : {}),
875
+ ...(model ? { model } : {}),
876
+ costUsd: typeof ev.total_cost_usd === 'number' ? ev.total_cost_usd : undefined,
877
+ })
878
+ }
879
+ // Turn boundary (gd-421): in the persistent stream-json chat mode every
880
+ // completed reply ends with a `result` event while the process stays alive
881
+ // waiting for the next stdin message — this is what ends the chat turn.
882
+ if (typeof onTurnEnd === 'function') onTurnEnd(ev.subtype)
883
+ }
884
+ }
885
+
886
+ // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
887
+ // (Bash stdout, edit results, …) to the run's console, plus the settings file
888
+ // that registers it. The hook reads GITDONE_* from its env (set per run on the
889
+ // claude process). Returns the settings path to pass via --settings.
890
+ const AI_HOOK_SCRIPT = join(AGENT_DIR, 'ai-run-hook.mjs')
891
+ const AI_SETTINGS_PATH = join(AGENT_DIR, 'ai-run-settings.json')
892
+
893
+ function ensureAiHookFiles() {
894
+ ensureAgentDir()
895
+ const hookSrc = [
896
+ "import process from 'node:process'",
897
+ "let data = ''",
898
+ "process.stdin.setEncoding('utf8')",
899
+ "process.stdin.on('data', (c) => { data += c })",
900
+ 'process.stdin.on(\'end\', async () => {',
901
+ ' try {',
902
+ " const ev = JSON.parse(data || '{}')",
903
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, runId = process.env.GITDONE_RUN_ID',
904
+ ' if (url && key && runId) {',
905
+ " const name = ev.tool_name || 'tool'",
906
+ ' const o = ev.tool_output',
907
+ " let out = ''",
908
+ " if (typeof o === 'string') out = o",
909
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
910
+ " out = String(out || '').slice(0, 2000)",
911
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
912
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
913
+ " await fetch(url + '/api/v1/agent/ai-run/events', {",
914
+ " method: 'POST',",
915
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
916
+ " body: JSON.stringify({ runId, events: [{ kind: 'TOOL_RESULT', text }] }),",
917
+ ' }).catch(() => {})',
918
+ ' }',
919
+ ' } catch {}',
920
+ ' process.exit(0)',
921
+ '})',
922
+ ].join('\n')
923
+ writeFileSync(AI_HOOK_SCRIPT, hookSrc, 'utf8')
924
+
925
+ const nodePath = getNodePath()
926
+ const settings = {
927
+ hooks: {
928
+ PostToolUse: [
929
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_HOOK_SCRIPT}"` }] },
930
+ ],
931
+ },
932
+ }
933
+ writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
934
+ return AI_SETTINGS_PATH
935
+ }
936
+
937
+ // Same PostToolUse hook, for interactive chat sessions: posts raw tool output to
938
+ // the session endpoint, reading GITDONE_SESSION_ID from the per-turn env.
939
+ const AI_SESSION_HOOK_SCRIPT = join(AGENT_DIR, 'ai-session-hook.mjs')
940
+ const AI_SESSION_SETTINGS_PATH = join(AGENT_DIR, 'ai-session-settings.json')
941
+
942
+ function ensureAiSessionHookFiles() {
943
+ ensureAgentDir()
944
+ const hookSrc = [
945
+ "import process from 'node:process'",
946
+ "let data = ''",
947
+ "process.stdin.setEncoding('utf8')",
948
+ "process.stdin.on('data', (c) => { data += c })",
949
+ 'process.stdin.on(\'end\', async () => {',
950
+ ' try {',
951
+ " const ev = JSON.parse(data || '{}')",
952
+ ' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, sessionId = process.env.GITDONE_SESSION_ID',
953
+ ' if (url && key && sessionId) {',
954
+ " const name = ev.tool_name || 'tool'",
955
+ ' const o = ev.tool_output',
956
+ " let out = ''",
957
+ " if (typeof o === 'string') out = o",
958
+ " else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
959
+ " out = String(out || '').slice(0, 2000)",
960
+ " const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
961
+ " const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
962
+ " await fetch(url + '/api/v1/agent/ai-session/events', {",
963
+ " method: 'POST',",
964
+ " headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
965
+ " body: JSON.stringify({ sessionId, events: [{ role: 'TOOL_RESULT', text }] }),",
966
+ ' }).catch(() => {})',
967
+ ' }',
968
+ ' } catch {}',
969
+ ' process.exit(0)',
970
+ '})',
971
+ ].join('\n')
972
+ writeFileSync(AI_SESSION_HOOK_SCRIPT, hookSrc, 'utf8')
973
+
974
+ const nodePath = getNodePath()
975
+ const settings = {
976
+ hooks: {
977
+ PostToolUse: [
978
+ { matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_SESSION_HOOK_SCRIPT}"` }] },
979
+ ],
980
+ },
981
+ }
982
+ writeFileSync(AI_SESSION_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
983
+ return AI_SESSION_SETTINGS_PATH
984
+ }
985
+
986
+ // gitdone MCP config for headless sessions (gd-308). We provide it ourselves —
987
+ // with an X-Gitdone-Machine-Id header — so the server can attribute the AI agents
988
+ // this session registers (ai_agent_start) to THIS computer, and the „АИ Агенти"
989
+ // panel shows one sub-panel per machine instead of a single „Локален агент".
990
+ // Used with --strict-mcp-config so the session uses exactly this server. The
991
+ // agent's own key (a gdo_ key) authenticates the MCP endpoint.
992
+ const AI_MCP_CONFIG_PATH = join(AGENT_DIR, 'ai-mcp-config.json')
993
+
994
+ function ensureAiMcpConfig(cfg) {
995
+ ensureAgentDir()
996
+ const config = {
997
+ mcpServers: {
998
+ gitdone: {
999
+ type: 'http',
1000
+ url: `${cfg.url}/api/mcp`,
1001
+ headers: {
1002
+ Authorization: `Bearer ${cfg.key}`,
1003
+ 'X-Gitdone-Machine-Id': cfg.machineId,
1004
+ },
1005
+ },
1006
+ },
1007
+ }
1008
+ writeFileSync(AI_MCP_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8')
1009
+ return AI_MCP_CONFIG_PATH
1010
+ }
1011
+
1012
+ // Sanitise the model value from a command payload into a safe `--model`
1013
+ // argument. gitDone sends a stable CLI alias ("opus"/"sonnet"/"haiku"), but we
1014
+ // also allow a full model id (letters, digits, dot, dash). Anything else — or a
1015
+ // missing value yields null, so the agent falls back to the machine's own
1016
+ // default model. Bounds the length as a belt-and-braces guard (gd-354).
1017
+ function aiModelArg(raw) {
1018
+ if (typeof raw !== 'string') return null
1019
+ const m = raw.trim()
1020
+ if (!m || m.length > 100) return null
1021
+ return /^[A-Za-z0-9._-]+$/.test(m) ? m : null
1022
+ }
1023
+
1024
+ // Run a headless Claude Code session for an `ai_run` command and stream its
1025
+ // output back. Long-running and fire-and-forget: it wires up async handlers and
1026
+ // returns immediately so the agent's snapshot loop is never blocked.
1027
+ function runAiCommand(cfg, cmd, repoPath) {
1028
+ const runId = cmd.payload?.runId
1029
+ const prompt = cmd.payload?.prompt ?? ''
1030
+ const allowCommit = cmd.payload?.allowCommit === true
1031
+ const model = aiModelArg(cmd.payload?.model)
1032
+ if (!runId || !prompt) {
1033
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
1034
+ return
1035
+ }
1036
+
1037
+ const { path: claudePath, shell, found } = findClaude()
1038
+ if (!found) {
1039
+ const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1040
+ log(`✗ ai_run ${runId}: claude not found`)
1041
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', 'claude not found')
1042
+ reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
1043
+ return
1044
+ }
1045
+
1046
+ let settingsPath
1047
+ try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
1048
+ let mcpConfigPath
1049
+ try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1050
+
1051
+ // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
1052
+ // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
1053
+ // mangled under the claude.cmd npm shim (shell:true cmd.exe splits/strips
1054
+ // it), so Claude received no prompt and fell back to an empty stdin
1055
+ // ("no stdin data received…"), then ran blind off whatever was in the repo.
1056
+ // Piping it to stdin is robust for both the native exe and the shim.
1057
+ const args = [
1058
+ '-p',
1059
+ '--output-format', 'stream-json',
1060
+ '--verbose',
1061
+ // Stream text token-by-token + thinking deltas so the terminal shows the
1062
+ // reply being written live, ред по ред, not in whole-block batches (gd-419).
1063
+ '--include-partial-messages',
1064
+ // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1065
+ ...(model ? ['--model', model] : []),
1066
+ '--permission-mode', 'acceptEdits',
1067
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1068
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1069
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1070
+ ...(settingsPath ? ['--settings', settingsPath] : []),
1071
+ // Our own gitdone MCP, tagged with this machine (gd-308).
1072
+ ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1073
+ ]
1074
+
1075
+ // The PostToolUse hook reads these from its env to post raw tool output.
1076
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
1077
+
1078
+ // Batch events on a timer so we don't hammer the server per token/line.
1079
+ let pending = []
1080
+ let flushing = false
1081
+ let liveText = '' // in-progress text of the current assistant block (live preview)
1082
+ let sentLive = '' // last streamingText we posted — only push on change
1083
+ let liveActivity = '' // latest thinking snippet "какво прави АИ-то" (gd-419)
1084
+ let sentActivity = ''
1085
+ const flush = async () => {
1086
+ if (flushing) return
1087
+ const liveChanged = liveText !== sentLive
1088
+ const activityChanged = liveActivity !== sentActivity
1089
+ if (pending.length === 0 && !liveChanged && !activityChanged) return
1090
+ flushing = true
1091
+ const batch = pending; pending = []
1092
+ const streamingText = liveChanged ? liveText : undefined
1093
+ const activityText = activityChanged ? liveActivity : undefined
1094
+ sentLive = liveText
1095
+ sentActivity = liveActivity
1096
+ await postRunEvents(cfg, runId, batch, undefined, undefined, undefined, streamingText, activityText)
1097
+ flushing = false
1098
+ }
1099
+ const push = (kind, text) => {
1100
+ if (text == null || String(text) === '') return
1101
+ // A completed text block becomes a real event — drop its live preview; any
1102
+ // new event also supersedes the last thinking snippet.
1103
+ if (kind === 'TEXT') liveText = ''
1104
+ liveActivity = ''
1105
+ pending.push({ kind, text: String(text) })
1106
+ }
1107
+ const onDelta = (chunk, type) => {
1108
+ if (type === 'thinking-start') { liveActivity = ''; return }
1109
+ // Keep only the tail — the freshest thought is what the gray line shows.
1110
+ if (type === 'thinking') { liveActivity = (liveActivity + chunk).slice(-4000); return }
1111
+ liveText += chunk
1112
+ }
1113
+ const timer = setInterval(flush, 500)
1114
+
1115
+ // Accumulate model + token usage across the stream (model from init, usage
1116
+ // from the final result event) to report once on exit (gd-334).
1117
+ const meta = { model: undefined, usage: undefined, costUsd: undefined }
1118
+ const onMeta = (m) => {
1119
+ if (m.model) meta.model = m.model
1120
+ if (m.usage) meta.usage = m.usage
1121
+ if (typeof m.costUsd === 'number') meta.costUsd = m.costUsd
1122
+ }
1123
+
1124
+ log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
1125
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
1126
+
1127
+ let child
1128
+ try {
1129
+ child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1130
+ } catch (err) {
1131
+ clearInterval(timer)
1132
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
1133
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
1134
+ return
1135
+ }
1136
+
1137
+ // Write the prompt to stdin and close it so Claude reads it as the task.
1138
+ // Swallow EPIPE in case the process exits before we finish writing.
1139
+ if (child.stdin) {
1140
+ child.stdin.on('error', () => {})
1141
+ try { child.stdin.write(prompt); child.stdin.end() }
1142
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
1143
+ }
1144
+
1145
+ let buf = ''
1146
+ child.stdout.on('data', (d) => {
1147
+ buf += d.toString()
1148
+ let nl
1149
+ while ((nl = buf.indexOf('\n')) >= 0) {
1150
+ const line = buf.slice(0, nl).trim()
1151
+ buf = buf.slice(nl + 1)
1152
+ if (line) parseStreamLine(line, push, undefined, onDelta, onMeta)
1153
+ }
1154
+ })
1155
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
1156
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1157
+ child.on('close', async (code) => {
1158
+ clearInterval(timer)
1159
+ if (buf.trim()) parseStreamLine(buf.trim(), push, undefined, onDelta, onMeta)
1160
+ liveText = '' // run is over — drop any lingering live preview / thought
1161
+ liveActivity = ''
1162
+ await flush()
1163
+ const ok = code === 0
1164
+ // Build the usage payload + a console summary line from what we saw.
1165
+ const usage = meta.usage
1166
+ ? { model: meta.model, ...meta.usage, ...(typeof meta.costUsd === 'number' ? { costUsd: meta.costUsd } : {}) }
1167
+ : undefined
1168
+ const events = [{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }]
1169
+ if (usage) events.push({ kind: 'SYSTEM', text: `📊 Токени: ${fmtTokens(usage.inputTokens)} вход · ${fmtTokens(usage.outputTokens)} изход${usage.cacheReadTokens ? ` · ${fmtTokens(usage.cacheReadTokens)} кеш` : ''}${typeof usage.costUsd === 'number' ? ` · $${usage.costUsd.toFixed(4)}` : ''}` })
1170
+ await postRunEvents(
1171
+ cfg, runId,
1172
+ events,
1173
+ ok ? 'done' : 'error',
1174
+ ok ? 'ok' : `exit ${code}`,
1175
+ usage,
1176
+ )
1177
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
1178
+ log(`■ ai_run ${runId} приключи (code ${code})`)
1179
+ })
1180
+ }
1181
+
1182
+ // Download the images attached to a chat turn to a per-session temp dir and
1183
+ // return their local paths. `claude -p` can't take image URLs on stdin, so we
1184
+ // fetch them locally and point Claude at the files (its Read tool renders
1185
+ // images). Best-effort: a failed download is skipped, not fatal.
1186
+ async function downloadSessionImages(sessionId, urls) {
1187
+ const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
1188
+ mkdirSync(dir, { recursive: true })
1189
+ const paths = []
1190
+ for (let i = 0; i < urls.length; i++) {
1191
+ try {
1192
+ const res = await fetch(urls[i])
1193
+ if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
1194
+ const buf = Buffer.from(await res.arrayBuffer())
1195
+ const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
1196
+ const ext = m ? m[1].toLowerCase() : 'png'
1197
+ const file = join(dir, `image-${i + 1}.${ext}`)
1198
+ writeFileSync(file, buf)
1199
+ paths.push(file)
1200
+ } catch (e) {
1201
+ log(`✗ image download failed (${urls[i]}): ${e.message}`)
1202
+ }
1203
+ }
1204
+ return { dir, paths }
1205
+ }
1206
+
1207
+ // ─── Persistent chat processes (gd-421) ───────────────────────────────────────
1208
+ // One long-lived `claude` process per chat session, keyed by sessionId.
1209
+ // Spawning a fresh CLI per turn (`claude -p --resume`) cost 5–15s of process
1210
+ // start + transcript reload + MCP handshake on EVERY message, which read as
1211
+ // „конзолата е бавна". Now the first message of a session spawns claude with
1212
+ // --input-format stream-json and KEEPS STDIN OPEN; each following message is a
1213
+ // single JSON line, so the reply starts within seconds — like a local cmd. Each
1214
+ // completed reply emits a `result` event (the turn boundary); the process waits
1215
+ // for the next message. A pool entry:
1216
+ // { sessionId, child, busy, stopped, lastUsedAt, allowCommit,
1217
+ // capturedSession, turn }
1218
+ // where `turn` (non-null only while a reply is streaming) holds the per-turn
1219
+ // stream state: { cmdId, imgDir, startedAt, timer, flushing, pending,
1220
+ // liveText, sentLive, liveActivity, sentActivity, lastPostAt }.
1221
+ const chatProcs = new Map()
1222
+ const CHAT_PROC_MAX = 3 // each live claude holds real RAM — LRU-evict past this
1223
+ const CHAT_PROC_IDLE_MS = 30 * 60 * 1000 // idle processes die after 30 min
1224
+ const CHAT_TURN_MAX_MS = 60 * 60 * 1000 // a turn stuck past 1h → kill (watchdog)
1225
+
1226
+ // Map console kinds session transcript roles (model text → ASSISTANT).
1227
+ const chatRoleFor = (kind) => (kind === 'TEXT' ? 'ASSISTANT' : kind)
1228
+
1229
+ // Reap idle/stuck session processes. Killing between turns is silent (the
1230
+ // session is already idle server-side); the next message respawns with
1231
+ // --resume. Started lazily with the first spawn so setup CLI paths never tick.
1232
+ let chatSweepTimer = null
1233
+ function ensureChatSweep() {
1234
+ if (chatSweepTimer) return
1235
+ chatSweepTimer = setInterval(() => {
1236
+ const now = Date.now()
1237
+ for (const entry of chatProcs.values()) {
1238
+ if (!entry.busy && now - entry.lastUsedAt > CHAT_PROC_IDLE_MS) {
1239
+ log(`… ai_chat proc ${entry.sessionId} бездейства ${Math.round(CHAT_PROC_IDLE_MS / 60000)} мин спирам го (следващият ход ще го върне с --resume)`)
1240
+ killTree(entry.child)
1241
+ } else if (entry.busy && entry.turn && now - entry.turn.startedAt > CHAT_TURN_MAX_MS) {
1242
+ log(`✗ ai_chat ход в ${entry.sessionId} върви над ${Math.round(CHAT_TURN_MAX_MS / 60000)} мин — убивам процеса`)
1243
+ killTree(entry.child)
1244
+ }
1245
+ }
1246
+ }, 60_000)
1247
+ }
1248
+
1249
+ // Periodic flush of one running turn's stream state to the server (250ms —
1250
+ // what makes the console feel live). Includes the 2.5s keep-alive heartbeat.
1251
+ async function flushChatTurn(cfg, entry) {
1252
+ const t = entry.turn
1253
+ if (!t || t.flushing) return
1254
+ const hasEvents = t.pending.length > 0
1255
+ const liveChanged = t.liveText !== t.sentLive
1256
+ const activityChanged = t.liveActivity !== t.sentActivity
1257
+ const heartbeat = !hasEvents && !liveChanged && !activityChanged && Date.now() - t.lastPostAt > 2500
1258
+ if (!hasEvents && !liveChanged && !activityChanged && !heartbeat) return
1259
+ t.flushing = true
1260
+ const batch = t.pending.map((e) => ({ role: chatRoleFor(e.kind), text: e.text })); t.pending = []
1261
+ const streamingText = liveChanged ? t.liveText : undefined
1262
+ const activityText = activityChanged ? t.liveActivity : undefined
1263
+ t.sentLive = t.liveText
1264
+ t.sentActivity = t.liveActivity
1265
+ await postSessionEvents(cfg, entry.sessionId, batch, 'running', entry.capturedSession || undefined, streamingText, activityText)
1266
+ t.lastPostAt = Date.now()
1267
+ t.flushing = false
1268
+ }
1269
+
1270
+ // End the running turn of a session process: post the tail events + terminal
1271
+ // status, report the command, and put the entry back to idle (process alive).
1272
+ // outcome: { ok: true } | { ok: false, code } | { stopped: true }.
1273
+ async function finishChatTurn(cfg, entry, outcome) {
1274
+ const t = entry.turn
1275
+ if (!t) return
1276
+ entry.turn = null
1277
+ entry.busy = false
1278
+ entry.lastUsedAt = Date.now()
1279
+ clearInterval(t.timer)
1280
+ if (t.imgDir) { try { rmSync(t.imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
1281
+ // Let an in-flight periodic flush land first, so its 'running' post can't
1282
+ // race past the terminal 'idle' post and re-lock the console.
1283
+ for (let i = 0; i < 20 && t.flushing; i++) await new Promise((r) => setTimeout(r, 100))
1284
+ const batch = t.pending.map((e) => ({ role: chatRoleFor(e.kind), text: e.text }))
1285
+ t.pending = []
1286
+ if (outcome.stopped) {
1287
+ batch.push({ role: 'SYSTEM', text: '⏹ Спряно. Напиши още нещо, за да продължим разговора.' })
1288
+ await postSessionEvents(cfg, entry.sessionId, batch, 'idle', entry.capturedSession || undefined, '', '')
1289
+ reportCommandResult(cfg, t.cmdId, 'done', 'stopped by user')
1290
+ log(`⏹ ai_chat ${entry.sessionId} спряно от потребителя`)
1291
+ return
1292
+ }
1293
+ if (!outcome.ok) {
1294
+ batch.push({ role: 'SYSTEM', text: `✗ Ходът приключи с код ${outcome.code}.` })
1295
+ await postSessionEvents(cfg, entry.sessionId, batch, 'error', entry.capturedSession || undefined, '', '')
1296
+ reportCommandResult(cfg, t.cmdId, 'error', `exit ${outcome.code}`)
1297
+ log(`✗ ai_chat ход в ${entry.sessionId} падна (code ${outcome.code})`)
1298
+ return
1299
+ }
1300
+ await postSessionEvents(cfg, entry.sessionId, batch, 'idle', entry.capturedSession || undefined, '', '')
1301
+ reportCommandResult(cfg, t.cmdId, 'done', 'ok')
1302
+ log(`✓ ai_chat ход в ${entry.sessionId} приключи (процесът остава жив)`)
1303
+ }
1304
+
1305
+ // Spawn the persistent claude process for one session and wire its stream
1306
+ // handlers once. Turns come and go via entry.turn; the process stays.
1307
+ function spawnChatProc(cfg, opts) {
1308
+ const { sessionId, repoPath, model, allowCommit, resumeId, claudePath, shell, settingsPath, mcpConfigPath } = opts
1309
+
1310
+ // Room in the pool: evict the least-recently-used idle process first.
1311
+ if (chatProcs.size >= CHAT_PROC_MAX) {
1312
+ const idle = [...chatProcs.values()].filter((e) => !e.busy).sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0]
1313
+ if (idle) { log(`… ai_chat pool пълен — спирам най-стария процес (${idle.sessionId})`); killTree(idle.child) }
1314
+ }
1315
+
1316
+ const args = [
1317
+ '-p',
1318
+ // The whole point (gd-421): messages arrive as JSON lines on an OPEN stdin,
1319
+ // so one process serves every turn of the session — no cold start per turn.
1320
+ '--input-format', 'stream-json',
1321
+ '--output-format', 'stream-json',
1322
+ '--verbose',
1323
+ // Stream the reply token-by-token + thinking deltas (gd-302, gd-419).
1324
+ '--include-partial-messages',
1325
+ // Model resolved for this session (picker / project default); omitted
1326
+ // machine default (gd-354). Kept consistent across the session's turns.
1327
+ ...(model ? ['--model', model] : []),
1328
+ '--permission-mode', 'acceptEdits',
1329
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1330
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1331
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1332
+ ...(resumeId ? ['--resume', resumeId] : []),
1333
+ ...(settingsPath ? ['--settings', settingsPath] : []),
1334
+ // Our own gitdone MCP, tagged with this machine so ai_agent_start attributes
1335
+ // the agents to this computer (gd-308). --strict = use exactly this server.
1336
+ ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1337
+ ]
1338
+
1339
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
1340
+ const child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1341
+ const entry = {
1342
+ sessionId, child, busy: false, stopped: false, lastUsedAt: Date.now(),
1343
+ allowCommit, capturedSession: resumeId || null, turn: null,
1344
+ }
1345
+
1346
+ if (child.stdin) child.stdin.on('error', () => {})
1347
+
1348
+ const push = (kind, text) => {
1349
+ const t = entry.turn
1350
+ if (!t || text == null || String(text) === '') return
1351
+ // A completed text block becomes a real event drop its live preview so the
1352
+ // finalised bubble and the cleared preview swap in on the same flush. Any
1353
+ // new event also supersedes the last thinking snippet (gd-419).
1354
+ if (kind === 'TEXT') t.liveText = ''
1355
+ t.liveActivity = ''
1356
+ t.pending.push({ kind, text: String(text) })
1357
+ }
1358
+ const onInit = (sid) => { if (sid && !entry.capturedSession) entry.capturedSession = sid }
1359
+ const onDelta = (chunk, type) => {
1360
+ const t = entry.turn
1361
+ if (!t) return
1362
+ if (type === 'thinking-start') { t.liveActivity = ''; return }
1363
+ // Keep only the tail the freshest thought is what the gray line shows.
1364
+ if (type === 'thinking') { t.liveActivity = (t.liveActivity + chunk).slice(-4000); return }
1365
+ t.liveText += chunk
1366
+ }
1367
+ const onTurnEnd = () => { finishChatTurn(cfg, entry, { ok: true }) }
1368
+
1369
+ let buf = ''
1370
+ child.stdout.on('data', (d) => {
1371
+ buf += d.toString()
1372
+ let nl
1373
+ while ((nl = buf.indexOf('\n')) >= 0) {
1374
+ const line = buf.slice(0, nl).trim()
1375
+ buf = buf.slice(nl + 1)
1376
+ if (line) parseStreamLine(line, push, onInit, onDelta, undefined, onTurnEnd)
1377
+ }
1378
+ })
1379
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
1380
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1381
+ child.on('close', async (code) => {
1382
+ if (chatProcs.get(sessionId) === entry) chatProcs.delete(sessionId)
1383
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta)
1384
+ // A turn was in flight when the process died: user stop → idle with the
1385
+ // friendly note; a crash → error. No turn → quiet cleanup (idle reap).
1386
+ if (entry.turn) {
1387
+ await finishChatTurn(cfg, entry, entry.stopped ? { stopped: true } : { ok: false, code })
1388
+ }
1389
+ log(`■ ai_chat proc ${sessionId} приключи (code ${code})`)
1390
+ })
1391
+
1392
+ chatProcs.set(sessionId, entry)
1393
+ ensureChatSweep()
1394
+ return entry
1395
+ }
1396
+
1397
+ // Kill a process AND its descendants. `claude` spawns its own children (bash,
1398
+ // node hooks), so killing just the top pid would orphan them. Windows needs
1399
+ // `taskkill /T`; POSIX kills the process group, falling back to a hard kill.
1400
+ function killTree(child) {
1401
+ const pid = child?.pid
1402
+ if (!pid) return
1403
+ if (process.platform === 'win32') {
1404
+ try { spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }) } catch { /* best-effort */ }
1405
+ } else {
1406
+ try { process.kill(-pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } }
1407
+ }
1408
+ }
1409
+
1410
+ // Run one turn of an interactive chat session (gd-421): reuse the session's
1411
+ // persistent claude process when it's alive the message is one stream-json
1412
+ // line on its stdin and the reply starts within seconds. Only the FIRST turn
1413
+ // (or a turn after a kill / policy change) pays the spawn + --resume cost.
1414
+ // Long-running + fire-and-forget like ai_run.
1415
+ async function runAiChat(cfg, cmd, repoPath) {
1416
+ const sessionId = cmd.payload?.sessionId
1417
+ const prompt = cmd.payload?.prompt ?? ''
1418
+ const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
1419
+ const claudeSessionId = cmd.payload?.claudeSessionId || null
1420
+ const allowCommit = cmd.payload?.allowCommit === true
1421
+ const model = aiModelArg(cmd.payload?.model)
1422
+ // A turn may be text-only, image-only, or both — but needs at least one.
1423
+ if (!sessionId || (!prompt && images.length === 0)) {
1424
+ reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
1425
+ return
1426
+ }
1427
+
1428
+ let entry = chatProcs.get(sessionId)
1429
+ // The pooled process can't serve this turn: it already exited, or the repo's
1430
+ // commit policy flipped since spawn (the flags are frozen on the command
1431
+ // line). Kill it and respawn with --resume context carries over.
1432
+ if (entry && (entry.child.exitCode !== null || entry.allowCommit !== allowCommit)) {
1433
+ killTree(entry.child)
1434
+ chatProcs.delete(sessionId)
1435
+ entry = null
1436
+ }
1437
+ // The server refuses a message while a turn RUNs, so this is belt-and-braces
1438
+ // against a duplicate command delivery.
1439
+ if (entry && entry.busy) {
1440
+ reportCommandResult(cfg, cmd.id, 'error', 'turn already running')
1441
+ return
1442
+ }
1443
+
1444
+ if (!entry) {
1445
+ const { path: claudePath, shell, found } = findClaude()
1446
+ if (!found) {
1447
+ const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1448
+ log(`✗ ai_chat ${sessionId}: claude not found`)
1449
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
1450
+ reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
1451
+ return
1452
+ }
1453
+ let settingsPath
1454
+ try { settingsPath = ensureAiSessionHookFiles() } catch (e) { log(`✗ ai session hook setup failed: ${e.message}`) }
1455
+ let mcpConfigPath
1456
+ try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1457
+ try {
1458
+ entry = spawnChatProc(cfg, { sessionId, repoPath, model, allowCommit, resumeId: claudeSessionId, claudePath, shell, settingsPath, mcpConfigPath })
1459
+ } catch (err) {
1460
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
1461
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
1462
+ return
1463
+ }
1464
+ log(`▶ ai_chat proc за ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
1465
+ }
1466
+
1467
+ // Fetch any attached images locally and fold their paths into the prompt so
1468
+ // Claude reads them with its Read tool (URLs on stdin don't render).
1469
+ let imgDir = null
1470
+ let fullPrompt = prompt
1471
+ if (images.length > 0) {
1472
+ const dl = await downloadSessionImages(sessionId, images)
1473
+ imgDir = dl.dir
1474
+ if (dl.paths.length > 0) {
1475
+ const list = dl.paths.map((p) => `- ${p}`).join('\n')
1476
+ const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
1477
+ fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1478
+ }
1479
+ }
1480
+
1481
+ // Open the turn BEFORE writing the message, so even the earliest output
1482
+ // (the init line of a fresh spawn) lands in the turn's stream state.
1483
+ entry.busy = true
1484
+ entry.stopped = false
1485
+ entry.lastUsedAt = Date.now()
1486
+ entry.turn = {
1487
+ cmdId: cmd.id, imgDir, startedAt: Date.now(), timer: null, flushing: false,
1488
+ pending: [], liveText: '', sentLive: '', liveActivity: '', sentActivity: '', lastPostAt: 0,
1489
+ }
1490
+ entry.turn.timer = setInterval(() => flushChatTurn(cfg, entry), 250)
1491
+
1492
+ // The message itself: one JSON line; stdin STAYS OPEN for the next turn.
1493
+ try {
1494
+ entry.child.stdin.write(JSON.stringify({
1495
+ type: 'user',
1496
+ message: { role: 'user', content: [{ type: 'text', text: fullPrompt }] },
1497
+ }) + '\n')
1498
+ } catch (err) {
1499
+ // Pipe already broken (process died as we wrote) — fail the turn; the next
1500
+ // message respawns cleanly with --resume.
1501
+ killTree(entry.child)
1502
+ await finishChatTurn(cfg, entry, { ok: false, code: `stdin: ${err.message}` })
1503
+ return
1504
+ }
1505
+ log(`▶ ai_chat ход ${sessionId} (images=${images.length})`)
1506
+ }
1507
+
1508
+ // ai_chat_stop interrupt a running chat turn the user asked to stop (gd-303).
1509
+ // Kills the session's process (its `close` handler posts the "⏹ Спряно" note
1510
+ // and flips the session to idle); the next message respawns it with --resume.
1511
+ // If nothing is running, just idles the session so the console unlocks.
1512
+ function stopAiChat(cfg, cmd) {
1513
+ const sessionId = cmd.payload?.sessionId
1514
+ const entry = sessionId ? chatProcs.get(sessionId) : null
1515
+ if (!entry || !entry.busy) {
1516
+ if (sessionId) postSessionEvents(cfg, sessionId, [], 'idle', undefined, '', '')
1517
+ reportCommandResult(cfg, cmd.id, 'done', 'no active turn')
1518
+ return
1519
+ }
1520
+ entry.stopped = true
1521
+ killTree(entry.child)
1522
+ log(`⏹ ai_chat_stop ${sessionId} — kill signalled`)
1523
+ reportCommandResult(cfg, cmd.id, 'done', 'stop signalled')
1524
+ }
1525
+
1526
+ // deploy — run the project's deploy script (scripts/deploy.sh) in the repo.
1527
+ // Long-running (git pull + npm build + service restart), so we spawn it detached
1528
+ // from the poll loop and report the result on exit. The agent is its OWN process
1529
+ // (not part of any gitdone systemd service), so a `systemctl restart` inside the
1530
+ // script doesn't kill the deploy mid-flight — which a server-side spawn would.
1531
+ function runDeployCommand(cfg, cmd, repoPath) {
1532
+ const script = 'scripts/deploy.sh'
1533
+ if (!existsSync(join(repoPath, script))) {
1534
+ reportCommandResult(cfg, cmd.id, 'error', `няма ${script} в repo-то — няма какво да деплойна`)
1535
+ log(`✗ deploy @ ${repoPath}: липсва ${script}`)
1536
+ return
1537
+ }
1538
+ log(`▸ deploy стартиран @ ${repoPath}`)
1539
+ const child = spawn('bash', [script], { cwd: repoPath, stdio: ['ignore', 'pipe', 'pipe'] })
1540
+ // Keep only the tail of the output — enough to explain a failure without
1541
+ // shipping a whole build log back.
1542
+ let tail = ''
1543
+ const capture = (d) => { tail = (tail + d.toString()).slice(-2000) }
1544
+ child.stdout.on('data', capture)
1545
+ child.stderr.on('data', capture)
1546
+ child.on('error', (err) => {
1547
+ reportCommandResult(cfg, cmd.id, 'error', `процесна грешка: ${err.message}`)
1548
+ log(`✗ deploy failed @ ${repoPath}: ${err.message}`)
1549
+ })
1550
+ child.on('close', (code) => {
1551
+ const ok = code === 0
1552
+ const detail = tail.trim() ? `\n${tail.trim()}` : ''
1553
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}${detail}`.slice(0, 4000))
1554
+ log(`${ok ? '✓' : '✗'} deploy @ ${repoPath} приключи (code ${code})`)
1555
+ })
1556
+ }
1557
+
1558
+ async function executeCommand(cfg, cmd, repoPath) {
1559
+ // ai_run is long-running + streaming launch it in the background and return
1560
+ // so the snapshot loop keeps going. It reports its own result on exit.
1561
+ if (cmd.type === 'ai_run') {
1562
+ runAiCommand(cfg, cmd, repoPath)
1563
+ return
1564
+ }
1565
+ // ai_chat one interactive turn, same fire-and-forget model.
1566
+ if (cmd.type === 'ai_chat') {
1567
+ runAiChat(cfg, cmd, repoPath)
1568
+ return
1569
+ }
1570
+ // ai_chat_stop — interrupt the running turn for a session (gd-303). Handled
1571
+ // inline (it just signals a kill) and reports its own result.
1572
+ if (cmd.type === 'ai_chat_stop') {
1573
+ stopAiChat(cfg, cmd)
1574
+ return
1575
+ }
1576
+ // deploy — run the repo's deploy script. Long-running (pull + build + service
1577
+ // restart), so it's launched in the background and reports its own result on
1578
+ // exit, same as ai_run.
1579
+ if (cmd.type === 'deploy') {
1580
+ runDeployCommand(cfg, cmd, repoPath)
1581
+ return
1582
+ }
1583
+
1584
+ let result = 'ok'
1585
+ let status = 'done'
1586
+ const auth = cfg.auth?.[repoPath] ?? null
1587
+ try {
1588
+ if (cmd.type === 'commit') {
1589
+ const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
1590
+ // When the UI sends a list of selected files, stage ONLY those so the
1591
+ // resulting commit (and the push that follows) contains just the checked
1592
+ // files. No list → stage everything (old behaviour / older UIs).
1593
+ const files = Array.isArray(cmd.payload?.files)
1594
+ ? cmd.payload.files.filter((f) => typeof f === 'string' && f.trim())
1595
+ : []
1596
+ if (files.length) {
1597
+ const quoted = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ')
1598
+ git(`git add -- ${quoted}`, repoPath)
1599
+ } else {
1600
+ git('git add -A', repoPath)
1601
+ }
1602
+ result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
1603
+ } else if (cmd.type === 'push' || cmd.type === 'pull') {
1604
+ result = pushOrPull(cmd.type, repoPath, auth)
1605
+ } else if (cmd.type === 'discard') {
1606
+ result = discardFile(repoPath, cmd.payload?.file)
1607
+ } else {
1608
+ // Unknown command type — almost always a newer server feature (e.g. the
1609
+ // gd-255 `deploy` command) reaching an agent too old to handle it. Do NOT
1610
+ // silently mark it done: an older agent used to fall through here and
1611
+ // report status=done/result=ok, so a queued deploy looked "completed" while
1612
+ // nothing ran. Surface it as an error so the failure is visible and the
1613
+ // user knows to update the agent.
1614
+ throw new Error(`непозната команда „${cmd.type}" — обнови gitdone-agent (npm i -g gitdone-agent@latest)`)
1615
+ }
1616
+ log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
1617
+ } catch (err) {
1618
+ status = 'error'
1619
+ result = redact(err.message, auth?.token)
1620
+ // Cached token rejected (e.g. rotated/expired on the server) → drop it so
1621
+ // the next snapshot reports hasGithubAuth:false and the server reissues one.
1622
+ if (auth && AUTH_FAIL.test(err.message)) {
1623
+ delete cfg.auth[repoPath]
1624
+ writeConfig(cfg)
1625
+ result += ' токенът е изчистен, пробвай командата пак'
1626
+ }
1627
+ log(`✗ ${cmd.type} failed @ ${repoPath}: ${result}`)
1628
+ }
1629
+ await reportCommandResult(cfg, cmd.id, status, result)
1630
+ }
1631
+
1632
+ // Report discovered repos + machine info, get back which repos to track.
1633
+ // ─── Claude plan usage (gd-355) ────────────────────────────────────────────
1634
+ // Claude Code persists an OAuth token at ~/.claude/.credentials.json and keeps
1635
+ // it fresh. We reuse it to ask Anthropic for the SAME plan-usage numbers the
1636
+ // /usage command shows (account-level: a 5-hour "session" window + a weekly
1637
+ // limit). The АИ Конзола then renders these live. Best-effort: on a missing or
1638
+ // expired token, being offline, or any error we return null and the console
1639
+ // simply keeps the last snapshot. Cached ~60s so the 30s sync loop doesn't hit
1640
+ // the endpoint twice as often as it changes.
1641
+ let usageCache = { at: 0, data: null }
1642
+ async function readClaudeUsage() {
1643
+ const now = Date.now()
1644
+ if (usageCache.data && now - usageCache.at < 60_000) return usageCache.data
1645
+ try {
1646
+ const creds = JSON.parse(readFileSync(join(homedir(), '.claude', '.credentials.json'), 'utf8'))
1647
+ const token = creds?.claudeAiOauth?.accessToken
1648
+ if (!token) return null
1649
+ const ctrl = new AbortController()
1650
+ const to = setTimeout(() => ctrl.abort(), 8000)
1651
+ let res
1652
+ try {
1653
+ res = await fetch('https://api.anthropic.com/api/oauth/usage', {
1654
+ headers: {
1655
+ Authorization: `Bearer ${token}`,
1656
+ 'Content-Type': 'application/json',
1657
+ 'anthropic-beta': 'oauth-2025-04-20',
1658
+ 'User-Agent': 'claude-cli',
1659
+ },
1660
+ signal: ctrl.signal,
1661
+ })
1662
+ } finally { clearTimeout(to) }
1663
+ if (!res.ok) return null // 401 = token expired; Claude Code refreshes it — skip this tick
1664
+ const j = await res.json()
1665
+ const data = {
1666
+ sessionPct: Math.round(j?.five_hour?.utilization ?? 0),
1667
+ sessionResetsAt: j?.five_hour?.resets_at ?? null,
1668
+ weeklyPct: Math.round(j?.seven_day?.utilization ?? 0),
1669
+ weeklyResetsAt: j?.seven_day?.resets_at ?? null,
1670
+ }
1671
+ usageCache = { at: now, data }
1672
+ return data
1673
+ } catch {
1674
+ return null // no creds file / malformed / offline — stay quiet
1675
+ }
1676
+ }
1677
+
1678
+ async function sync(cfg, discovered) {
1679
+ const usage = await readClaudeUsage()
1680
+ const data = await api(cfg, '/api/v1/agent/sync', {
1681
+ machineId: cfg.machineId,
1682
+ hostname: cfg.hostname,
1683
+ agentVersion: AGENT_VERSION,
1684
+ roots: cfg.roots,
1685
+ repos: discovered,
1686
+ ...(usage ? { usage } : {}),
1687
+ })
1688
+ return data.tracked ?? []
1689
+ }
1690
+
1691
+ // Push one tracked repo's snapshot and run any pending commands for it.
1692
+ async function pushSnapshot(cfg, repo) {
1693
+ const snapshot = getSnapshot(repo.path)
1694
+ const data = await api(cfg, '/api/v1/agent/snapshot', {
1695
+ machineId: cfg.machineId,
1696
+ path: repo.path,
1697
+ repoName: repo.name,
1698
+ // Tells the server whether we already hold a push token for this repo.
1699
+ // Always a boolean → marks us as a token-capable agent.
1700
+ hasGithubAuth: !!cfg.auth?.[repo.path],
1701
+ ...snapshot,
1702
+ })
1703
+ // Server issued a (fresh) push token cache it on disk so we ask only once.
1704
+ if (data.githubAuth?.token && data.githubAuth?.repo) {
1705
+ cfg.auth = cfg.auth ?? {}
1706
+ cfg.auth[repo.path] = { token: data.githubAuth.token, repo: data.githubAuth.repo }
1707
+ writeConfig(cfg)
1708
+ }
1709
+ for (const cmd of data.commands ?? []) {
1710
+ await executeCommand(cfg, cmd, repo.path)
1711
+ }
1712
+ return snapshot
1713
+ }
1714
+
1715
+ // ─── Low-latency command channel (gd-274) ───────────────────────────────────────
1716
+ // The 30s snapshot poll is fine for reflecting git state, but makes user actions
1717
+ // (Push/Pull/Commit/АИ) feel sluggish: a queued command waits up to a full tick
1718
+ // before the agent even asks for it. So we ALSO hold a persistent SSE connection
1719
+ // to the server and drain+run commands the instant it says "wake". The snapshot
1720
+ // poll stays as the reliable fallback (and cron heartbeat) if the stream drops.
1721
+
1722
+ const GIT_STATE_CMDS = new Set(['commit', 'push', 'pull', 'discard'])
1723
+
1724
+ // Pull this machine's pending commands in one shot (no git/snapshot work server-
1725
+ // side) and run them. Serialised via a tiny mutex so overlapping wakes don't
1726
+ // double-drain; a wake arriving mid-drain sets a flag to run once more after.
1727
+ let draining = false
1728
+ let drainAgain = false
1729
+ async function drainCommands(cfg) {
1730
+ if (draining) { drainAgain = true; return }
1731
+ draining = true
1732
+ try {
1733
+ do {
1734
+ drainAgain = false
1735
+ let data
1736
+ try {
1737
+ data = await api(cfg, '/api/v1/agent/commands', { machineId: cfg.machineId })
1738
+ } catch (err) {
1739
+ log(`✗ command drain failed: ${err.message}`)
1740
+ return
1741
+ }
1742
+ const commands = data.commands ?? []
1743
+ if (commands.length === 0) continue
1744
+
1745
+ // Cache any push tokens the server issued for these commands' repos.
1746
+ if (data.githubAuth && typeof data.githubAuth === 'object') {
1747
+ cfg.auth = cfg.auth ?? {}
1748
+ for (const [path, a] of Object.entries(data.githubAuth)) {
1749
+ if (a && a.token && a.repo) cfg.auth[path] = a
1750
+ }
1751
+ writeConfig(cfg)
1752
+ }
1753
+
1754
+ // Run each command against its own repo path, and remember which repos had
1755
+ // their git state changed so we can push a fresh snapshot right after —
1756
+ // that's what makes the UI update instantly instead of on the next tick.
1757
+ const touched = new Map()
1758
+ for (const cmd of commands) {
1759
+ const repoPath = cmd.path
1760
+ if (!repoPath) { log(`✗ command ${cmd.id} has no path — skipped`); continue }
1761
+ await executeCommand(cfg, cmd, repoPath)
1762
+ if (GIT_STATE_CMDS.has(cmd.type)) touched.set(repoPath, cmd.repoName || repoPath)
1763
+ }
1764
+ for (const [path, name] of touched) {
1765
+ try { await pushSnapshot(cfg, { path, name }) }
1766
+ catch (err) { log(`✗ post-command snapshot failed @ ${path}: ${err.message}`) }
1767
+ }
1768
+ } while (drainAgain)
1769
+ } finally {
1770
+ draining = false
1771
+ }
1772
+ }
1773
+
1774
+ // Hold a persistent SSE connection open and drain the moment the server pushes a
1775
+ // `wake`. Reconnects forever with a short backoff; the server also recycles the
1776
+ // connection every few minutes (each reconnect re-sends an initial wake, so
1777
+ // anything queued while we were away is picked up). Never throws.
1778
+ const STREAM_RECONNECT_MS = 3000
1779
+ async function streamCommands(cfg) {
1780
+ const url = `${cfg.url}/api/v1/agent/commands/stream?machineId=${encodeURIComponent(cfg.machineId)}`
1781
+ for (;;) {
1782
+ try {
1783
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${cfg.key}` } })
1784
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
1785
+ log('▶ command stream connected')
1786
+ const reader = res.body.getReader()
1787
+ const decoder = new TextDecoder()
1788
+ let buf = ''
1789
+ for (;;) {
1790
+ const { value, done } = await reader.read()
1791
+ if (done) break
1792
+ buf += decoder.decode(value, { stream: true })
1793
+ // SSE events are separated by a blank line; a `wake` means "drain now".
1794
+ let idx
1795
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
1796
+ const frame = buf.slice(0, idx)
1797
+ buf = buf.slice(idx + 2)
1798
+ if (/(^|\n)event:\s*wake/.test(frame)) drainCommands(cfg)
1799
+ }
1800
+ }
1801
+ } catch (err) {
1802
+ log(`… command stream disconnected (${err.message}); reconnecting`)
1803
+ }
1804
+ await new Promise((r) => setTimeout(r, STREAM_RECONNECT_MS))
1805
+ }
1806
+ }
1807
+
1808
+ // ─── Main ────────────────────────────────────────────────────────────────────
1809
+
1810
+ async function runLoop(cfg) {
1811
+ ensureSingleInstance()
1812
+ log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
1813
+ log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
1814
+
1815
+ async function tick() {
1816
+ try {
1817
+ const discovered = scanRepos(cfg.roots)
1818
+ const tracked = await sync(cfg, discovered)
1819
+ let pushed = 0
1820
+ for (const repo of tracked) {
1821
+ try { await pushSnapshot(cfg, repo); pushed++ }
1822
+ catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
1823
+ }
1824
+ log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
1825
+ } catch (err) {
1826
+ log(`✗ sync error: ${err.message}`)
1827
+ }
1828
+ }
1829
+
1830
+ // Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
1831
+ // its own reconnect loop and never rejects.
1832
+ streamCommands(cfg)
1833
+
1834
+ await tick()
1835
+ setInterval(tick, cfg.interval * 1000)
1836
+ }
1837
+
1838
+ async function main() {
1839
+ const args = parseArgs()
1840
+
1841
+ if (args.doctor) {
1842
+ runDoctor()
1843
+ process.exit(0)
1844
+ }
1845
+
1846
+ if (args.uninstall) {
1847
+ uninstallStartup()
1848
+ process.exit(0)
1849
+ }
1850
+
1851
+ // Setup / install path: merge CLI args into config, optionally (re)install.
1852
+ if (args.install || args.key || args.roots.length || args.url || args.interval) {
1853
+ const cfg = buildConfig(args)
1854
+ if (!cfg.key) {
1855
+ console.error('Error: --key is required on first setup (e.g. --key=gdo_xxx)')
1856
+ process.exit(1)
1857
+ }
1858
+ writeConfig(cfg)
1859
+
1860
+ if (args.install) {
1861
+ installStartup()
1862
+ console.log(`✓ Инсталиран! При следващото влизане в Windows агентът ще стартира автоматично.`)
1863
+ console.log(` Агент : ${STABLE_AGENT}`)
1864
+ console.log(` Config : ${CONFIG_PATH}`)
1865
+ console.log(` Лог : ${LOG_PATH}`)
1866
+ console.log(` Roots :`)
1867
+ for (const r of cfg.roots) console.log(` - ${r}`)
1868
+ console.log()
1869
+
1870
+ // Launch silently in the background right now.
1871
+ const child = spawn('wscript.exe', [getVbsPath()], { detached: true, stdio: 'ignore' })
1872
+ child.unref()
1873
+ console.log('✓ Агентът е стартиран в заден план — можеш да затвориш прозореца.')
1874
+ console.log(' Сега избери кои репота да следи от gitdone.eu/github.')
1875
+ process.exit(0)
1876
+ }
1877
+
1878
+ // --key/--root without --install: just run the loop in the foreground.
1879
+ await runLoop(cfg)
1880
+ return
1881
+ }
1882
+
1883
+ // No args → running mode (this is how autostart launches us): read config.
1884
+ const cfg = readConfig()
1885
+ if (!cfg || !cfg.key) {
1886
+ console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
1887
+ process.exit(1)
1888
+ }
1889
+ await runLoop(cfg)
1890
+ }
1891
+
1892
+ main()