gitdone-agent 0.6.17 → 0.7.0

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