gitdone-agent 0.6.17 → 0.7.1

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