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.
- package/index.js +1892 -1787
- 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.
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
"
|
|
899
|
-
|
|
900
|
-
'
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
"
|
|
906
|
-
|
|
907
|
-
"
|
|
908
|
-
"
|
|
909
|
-
"
|
|
910
|
-
"
|
|
911
|
-
"
|
|
912
|
-
"
|
|
913
|
-
|
|
914
|
-
'
|
|
915
|
-
'
|
|
916
|
-
|
|
917
|
-
'})',
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
"
|
|
948
|
-
|
|
949
|
-
'
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
"
|
|
955
|
-
|
|
956
|
-
"
|
|
957
|
-
"
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
961
|
-
"
|
|
962
|
-
|
|
963
|
-
'
|
|
964
|
-
'
|
|
965
|
-
|
|
966
|
-
'})',
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
//
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
'--
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
'--
|
|
1064
|
-
//
|
|
1065
|
-
...(
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
let
|
|
1080
|
-
let
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
flushing
|
|
1087
|
-
const
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
let
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
//
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
//
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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()
|