gitdone-agent 0.6.15 → 0.6.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +128 -27
  2. package/package.json +15 -15
package/index.js CHANGED
@@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto'
27
27
  // Reported to the server on every sync so the web UI can flag outdated agents.
28
28
  // Keep in lockstep with packages/agent/package.json "version" AND
29
29
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
30
- const AGENT_VERSION = '0.6.15'
30
+ const AGENT_VERSION = '0.6.17'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -186,10 +186,52 @@ function findClaude() {
186
186
  return { path: 'claude', shell: true, found: false }
187
187
  }
188
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
+
189
229
  // Kill any previously-running agent processes so an update applies WITHOUT a
190
- // Windows re-login. Targets node processes launched from the agent dir
191
- // (~/.gitdone-agent), excluding ourselves the npx installer runs from the
192
- // npx cache, so it won't match. Best-effort; failures are non-fatal.
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.
193
235
  function stopRunningAgents() {
194
236
  // Kill the supervisor loops (cmd.exe running run-agent.cmd) FIRST, then the
195
237
  // agents — the other order lets a still-alive supervisor immediately respawn
@@ -200,7 +242,7 @@ function stopRunningAgents() {
200
242
  " Where-Object { $_.CommandLine -like '*run-agent.cmd*' } |",
201
243
  ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
202
244
  "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" |",
203
- ` Where-Object { $_.CommandLine -like '*.gitdone-agent*' -and $_.ProcessId -ne ${process.pid} } |`,
245
+ ` Where-Object { ($_.CommandLine -like '*gitdone-agent*' -or $_.CommandLine -like '*agent.mjs*') -and $_.ProcessId -ne ${process.pid} -and $_.ProcessId -ne ${process.ppid || 0} } |`,
204
246
  ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
205
247
  ].join('\n')
206
248
  try {
@@ -254,12 +296,16 @@ function installStartup() {
254
296
  `"%NODE%" "${agentPath}" 2>> "${crashLog}"`,
255
297
  'set CODE=%errorlevel%',
256
298
  `echo [%date% %time%] agent exited (code %CODE%) - restart in 10s >> "${crashLog}"`,
299
+ 'if "%CODE%"=="86" goto duplicate',
257
300
  'if "%CODE%"=="-1073741502" goto dead',
258
301
  'if "%CODE%"=="-1073741515" goto dead',
259
302
  'ping -n 11 127.0.0.1 >nul 2>nul || goto dead',
260
303
  'goto loop',
261
304
  ':dead',
262
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}"`,
263
309
  ].join('\r\n')
264
310
  const cmdPath = join(AGENT_DIR, 'run-agent.cmd')
265
311
  writeFileSync(cmdPath, cmd, 'utf8')
@@ -713,20 +759,24 @@ async function reportCommandResult(cfg, id, status, result) {
713
759
 
714
760
  // Post a batch of console events (and optional lifecycle status) for an AiRun.
715
761
  // `usage` is sent once on the final (done/error) post so the server can record
716
- // how many tokens the run cost.
717
- async function postRunEvents(cfg, runId, events, status, result, usage) {
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) {
718
766
  await api(cfg, '/api/v1/agent/ai-run/events', {
719
767
  runId,
720
768
  events,
721
769
  ...(status ? { status } : {}),
722
770
  ...(result !== undefined ? { result } : {}),
723
771
  ...(usage ? { usage } : {}),
772
+ ...(streamingText !== undefined ? { streamingText } : {}),
773
+ ...(activityText !== undefined ? { activityText } : {}),
724
774
  }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
725
775
  }
726
776
 
727
777
  // Post transcript lines (and optional turn status / Claude session id) for an
728
778
  // interactive AiSession chat turn.
729
- async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText) {
779
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText, activityText) {
730
780
  await api(cfg, '/api/v1/agent/ai-session/events', {
731
781
  sessionId,
732
782
  events,
@@ -734,6 +784,8 @@ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId
734
784
  ...(claudeSessionId ? { claudeSessionId } : {}),
735
785
  // Explicit '' clears the live preview; `undefined` leaves it untouched.
736
786
  ...(streamingText !== undefined ? { streamingText } : {}),
787
+ // Same semantics for the live thinking snippet (gd-419).
788
+ ...(activityText !== undefined ? { activityText } : {}),
737
789
  }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
738
790
  }
739
791
 
@@ -764,13 +816,18 @@ function parseStreamLine(line, push, onInit, onDelta, onMeta) {
764
816
  }
765
817
  return
766
818
  }
767
- // Partial-message stream: only the model's visible text deltas feed the live
768
- // preview. Tool-input JSON deltas and thinking deltas are ignored here — the
769
- // full block still arrives as a normal `assistant` event below.
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.
770
823
  if (ev.type === 'stream_event' && typeof onDelta === 'function') {
771
824
  const e = ev.event
772
- if (e?.type === 'content_block_delta' && e.delta?.type === 'text_delta' && e.delta.text) {
773
- onDelta(e.delta.text)
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')
774
831
  }
775
832
  return
776
833
  }
@@ -997,6 +1054,9 @@ function runAiCommand(cfg, cmd, repoPath) {
997
1054
  '-p',
998
1055
  '--output-format', 'stream-json',
999
1056
  '--verbose',
1057
+ // Stream text token-by-token + thinking deltas so the terminal shows the
1058
+ // reply being written live, ред по ред, not in whole-block batches (gd-419).
1059
+ '--include-partial-messages',
1000
1060
  // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1001
1061
  ...(model ? ['--model', model] : []),
1002
1062
  '--permission-mode', 'acceptEdits',
@@ -1014,15 +1074,39 @@ function runAiCommand(cfg, cmd, repoPath) {
1014
1074
  // Batch events on a timer so we don't hammer the server per token/line.
1015
1075
  let pending = []
1016
1076
  let flushing = false
1077
+ let liveText = '' // in-progress text of the current assistant block (live preview)
1078
+ let sentLive = '' // last streamingText we posted — only push on change
1079
+ let liveActivity = '' // latest thinking snippet — "какво прави АИ-то" (gd-419)
1080
+ let sentActivity = ''
1017
1081
  const flush = async () => {
1018
- if (flushing || pending.length === 0) return
1082
+ if (flushing) return
1083
+ const liveChanged = liveText !== sentLive
1084
+ const activityChanged = liveActivity !== sentActivity
1085
+ if (pending.length === 0 && !liveChanged && !activityChanged) return
1019
1086
  flushing = true
1020
1087
  const batch = pending; pending = []
1021
- await postRunEvents(cfg, runId, batch)
1088
+ const streamingText = liveChanged ? liveText : undefined
1089
+ const activityText = activityChanged ? liveActivity : undefined
1090
+ sentLive = liveText
1091
+ sentActivity = liveActivity
1092
+ await postRunEvents(cfg, runId, batch, undefined, undefined, undefined, streamingText, activityText)
1022
1093
  flushing = false
1023
1094
  }
1024
- const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
1025
- const timer = setInterval(flush, 800)
1095
+ const push = (kind, text) => {
1096
+ if (text == null || String(text) === '') return
1097
+ // A completed text block becomes a real event — drop its live preview; any
1098
+ // new event also supersedes the last thinking snippet.
1099
+ if (kind === 'TEXT') liveText = ''
1100
+ liveActivity = ''
1101
+ pending.push({ kind, text: String(text) })
1102
+ }
1103
+ const onDelta = (chunk, type) => {
1104
+ if (type === 'thinking-start') { liveActivity = ''; return }
1105
+ // Keep only the tail — the freshest thought is what the gray line shows.
1106
+ if (type === 'thinking') { liveActivity = (liveActivity + chunk).slice(-4000); return }
1107
+ liveText += chunk
1108
+ }
1109
+ const timer = setInterval(flush, 500)
1026
1110
 
1027
1111
  // Accumulate model + token usage across the stream (model from init, usage
1028
1112
  // from the final result event) to report once on exit (gd-334).
@@ -1061,14 +1145,16 @@ function runAiCommand(cfg, cmd, repoPath) {
1061
1145
  while ((nl = buf.indexOf('\n')) >= 0) {
1062
1146
  const line = buf.slice(0, nl).trim()
1063
1147
  buf = buf.slice(nl + 1)
1064
- if (line) parseStreamLine(line, push, undefined, undefined, onMeta)
1148
+ if (line) parseStreamLine(line, push, undefined, onDelta, onMeta)
1065
1149
  }
1066
1150
  })
1067
1151
  child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
1068
1152
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1069
1153
  child.on('close', async (code) => {
1070
1154
  clearInterval(timer)
1071
- if (buf.trim()) parseStreamLine(buf.trim(), push, undefined, undefined, onMeta)
1155
+ if (buf.trim()) parseStreamLine(buf.trim(), push, undefined, onDelta, onMeta)
1156
+ liveText = '' // run is over — drop any lingering live preview / thought
1157
+ liveActivity = ''
1072
1158
  await flush()
1073
1159
  const ok = code === 0
1074
1160
  // Build the usage payload + a console summary line from what we saw.
@@ -1192,32 +1278,44 @@ async function runAiChat(cfg, cmd, repoPath) {
1192
1278
  let flushing = false
1193
1279
  let liveText = '' // in-progress text of the current assistant block (live preview)
1194
1280
  let sentLive = '' // last streamingText we posted — so we only push on change
1281
+ let liveActivity = '' // latest thinking snippet — "какво прави АИ-то" (gd-419)
1282
+ let sentActivity = ''
1195
1283
  let lastPostAt = 0 // Date.now() of the last post, for the keep-alive heartbeat
1196
1284
  const flush = async () => {
1197
1285
  if (flushing) return
1198
1286
  const hasEvents = pending.length > 0
1199
1287
  const liveChanged = liveText !== sentLive
1288
+ const activityChanged = liveActivity !== sentActivity
1200
1289
  // Heartbeat: with nothing new for a while, still post (bumps lastActivityAt)
1201
1290
  // so the console shows honest "still working" and can spot a real stall.
1202
- const heartbeat = !hasEvents && !liveChanged && Date.now() - lastPostAt > 2500
1203
- if (!hasEvents && !liveChanged && !heartbeat) return
1291
+ const heartbeat = !hasEvents && !liveChanged && !activityChanged && Date.now() - lastPostAt > 2500
1292
+ if (!hasEvents && !liveChanged && !activityChanged && !heartbeat) return
1204
1293
  flushing = true
1205
1294
  const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
1206
1295
  const streamingText = liveChanged ? liveText : undefined
1296
+ const activityText = activityChanged ? liveActivity : undefined
1207
1297
  sentLive = liveText
1208
- await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined, streamingText)
1298
+ sentActivity = liveActivity
1299
+ await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined, streamingText, activityText)
1209
1300
  lastPostAt = Date.now()
1210
1301
  flushing = false
1211
1302
  }
1212
1303
  const push = (kind, text) => {
1213
1304
  if (text == null || String(text) === '') return
1214
1305
  // A completed text block becomes a real event — drop its live preview so the
1215
- // finalised bubble and the cleared preview swap in on the same flush.
1306
+ // finalised bubble and the cleared preview swap in on the same flush. Any
1307
+ // new event also supersedes the last thinking snippet (gd-419).
1216
1308
  if (kind === 'TEXT') liveText = ''
1309
+ liveActivity = ''
1217
1310
  pending.push({ kind, text: String(text) })
1218
1311
  }
1219
1312
  const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
1220
- const onDelta = (chunk) => { liveText += chunk }
1313
+ const onDelta = (chunk, type) => {
1314
+ if (type === 'thinking-start') { liveActivity = ''; return }
1315
+ // Keep only the tail — the freshest thought is what the gray line shows.
1316
+ if (type === 'thinking') { liveActivity = (liveActivity + chunk).slice(-4000); return }
1317
+ liveText += chunk
1318
+ }
1221
1319
  const timer = setInterval(flush, 500)
1222
1320
 
1223
1321
  // Fetch any attached images locally and fold their paths into the prompt so
@@ -1273,7 +1371,8 @@ async function runAiChat(cfg, cmd, repoPath) {
1273
1371
  chatChildren.delete(sessionId)
1274
1372
  if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
1275
1373
  if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta)
1276
- liveText = '' // turn is over — drop any lingering live preview
1374
+ liveText = '' // turn is over — drop any lingering live preview / thought
1375
+ liveActivity = ''
1277
1376
  await flush()
1278
1377
  // User-initiated stop (gd-303): land on idle with a friendly note, not an
1279
1378
  // "exited with code N" error — the kill's non-zero code is expected here.
@@ -1281,7 +1380,7 @@ async function runAiChat(cfg, cmd, repoPath) {
1281
1380
  await postSessionEvents(
1282
1381
  cfg, sessionId,
1283
1382
  [{ role: 'SYSTEM', text: '⏹ Спряно. Напиши още нещо, за да продължим разговора.' }],
1284
- 'idle', capturedSession || undefined, '',
1383
+ 'idle', capturedSession || undefined, '', '',
1285
1384
  )
1286
1385
  reportCommandResult(cfg, cmd.id, 'done', 'stopped by user')
1287
1386
  log(`⏹ ai_chat ${sessionId} спряно от потребителя`)
@@ -1294,6 +1393,7 @@ async function runAiChat(cfg, cmd, repoPath) {
1294
1393
  ok ? 'idle' : 'error',
1295
1394
  capturedSession || undefined,
1296
1395
  '', // clear the live preview on the terminal post
1396
+ '', // …and the thinking snippet (gd-419)
1297
1397
  )
1298
1398
  reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
1299
1399
  log(`■ ai_chat ${sessionId} приключи (code ${code})`)
@@ -1308,7 +1408,7 @@ function stopAiChat(cfg, cmd) {
1308
1408
  const sessionId = cmd.payload?.sessionId
1309
1409
  const child = sessionId ? chatChildren.get(sessionId) : null
1310
1410
  if (!child) {
1311
- if (sessionId) postSessionEvents(cfg, sessionId, [], 'idle', undefined, '')
1411
+ if (sessionId) postSessionEvents(cfg, sessionId, [], 'idle', undefined, '', '')
1312
1412
  reportCommandResult(cfg, cmd.id, 'done', 'no active turn')
1313
1413
  return
1314
1414
  }
@@ -1603,6 +1703,7 @@ async function streamCommands(cfg) {
1603
1703
  // ─── Main ────────────────────────────────────────────────────────────────────
1604
1704
 
1605
1705
  async function runLoop(cfg) {
1706
+ ensureSingleInstance()
1606
1707
  log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
1607
1708
  log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
1608
1709
 
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
- {
2
- "name": "gitdone-agent",
3
- "version": "0.6.15",
4
- "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
- "type": "module",
6
- "bin": {
7
- "gitdone-agent": "index.js"
8
- },
9
- "scripts": {
10
- "start": "node index.js"
11
- },
12
- "engines": {
13
- "node": "\u003e=18"
14
- }
15
- }
1
+ {
2
+ "name": "gitdone-agent",
3
+ "version": "0.6.17",
4
+ "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
+ "type": "module",
6
+ "bin": {
7
+ "gitdone-agent": "index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.js"
11
+ },
12
+ "engines": {
13
+ "node": "\u003e=18"
14
+ }
15
+ }