gitdone-agent 0.6.2 → 0.6.4

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 +106 -9
  2. package/package.json +1 -1
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.2'
30
+ const AGENT_VERSION = '0.6.4'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -521,19 +521,24 @@ async function postRunEvents(cfg, runId, events, status, result) {
521
521
 
522
522
  // Post transcript lines (and optional turn status / Claude session id) for an
523
523
  // interactive AiSession chat turn.
524
- async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId) {
524
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText) {
525
525
  await api(cfg, '/api/v1/agent/ai-session/events', {
526
526
  sessionId,
527
527
  events,
528
528
  ...(status ? { status } : {}),
529
529
  ...(claudeSessionId ? { claudeSessionId } : {}),
530
+ // Explicit '' clears the live preview; `undefined` leaves it untouched.
531
+ ...(streamingText !== undefined ? { streamingText } : {}),
530
532
  }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
531
533
  }
532
534
 
533
535
  // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
534
536
  // what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
535
537
  // results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
536
- function parseStreamLine(line, push, onInit) {
538
+ // With `--include-partial-messages`, Claude also emits `stream_event` lines
539
+ // carrying incremental text deltas — onDelta gets those so chat sessions can
540
+ // show the reply being written live (gd-302).
541
+ function parseStreamLine(line, push, onInit, onDelta) {
537
542
  let ev
538
543
  try { ev = JSON.parse(line) } catch { return }
539
544
  if (ev.type === 'system') {
@@ -544,6 +549,16 @@ function parseStreamLine(line, push, onInit) {
544
549
  }
545
550
  return
546
551
  }
552
+ // Partial-message stream: only the model's visible text deltas feed the live
553
+ // preview. Tool-input JSON deltas and thinking deltas are ignored here — the
554
+ // full block still arrives as a normal `assistant` event below.
555
+ if (ev.type === 'stream_event' && typeof onDelta === 'function') {
556
+ const e = ev.event
557
+ if (e?.type === 'content_block_delta' && e.delta?.type === 'text_delta' && e.delta.text) {
558
+ onDelta(e.delta.text)
559
+ }
560
+ return
561
+ }
547
562
  if (ev.type === 'assistant' && ev.message?.content) {
548
563
  for (const block of ev.message.content) {
549
564
  if (block.type === 'text' && block.text?.trim()) {
@@ -791,6 +806,24 @@ async function downloadSessionImages(sessionId, urls) {
791
806
  return { dir, paths }
792
807
  }
793
808
 
809
+ // Live chat turns, keyed by sessionId → their spawned `claude` child. Lets an
810
+ // `ai_chat_stop` command find and kill the turn a user asked to interrupt
811
+ // (gd-303). A turn registers itself on spawn and removes itself on close.
812
+ const chatChildren = new Map()
813
+
814
+ // Kill a process AND its descendants. `claude` spawns its own children (bash,
815
+ // node hooks), so killing just the top pid would orphan them. Windows needs
816
+ // `taskkill /T`; POSIX kills the process group, falling back to a hard kill.
817
+ function killTree(child) {
818
+ const pid = child?.pid
819
+ if (!pid) return
820
+ if (process.platform === 'win32') {
821
+ try { spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }) } catch { /* best-effort */ }
822
+ } else {
823
+ try { process.kill(-pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } }
824
+ }
825
+ }
826
+
794
827
  // Run one turn of an interactive chat session: `claude -p` (resuming the prior
795
828
  // Claude session when known) with the user's message on stdin, streaming the
796
829
  // reply back to the session console. Long-running + fire-and-forget like ai_run.
@@ -822,6 +855,9 @@ async function runAiChat(cfg, cmd, repoPath) {
822
855
  '-p',
823
856
  '--output-format', 'stream-json',
824
857
  '--verbose',
858
+ // Stream the reply token-by-token so the console can show it being written
859
+ // live, not only once each block is complete (gd-302).
860
+ '--include-partial-messages',
825
861
  '--permission-mode', 'acceptEdits',
826
862
  '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
827
863
  // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
@@ -837,16 +873,35 @@ async function runAiChat(cfg, cmd, repoPath) {
837
873
  let pending = []
838
874
  let capturedSession = null // Claude's session id, sent (idempotently) for --resume
839
875
  let flushing = false
876
+ let liveText = '' // in-progress text of the current assistant block (live preview)
877
+ let sentLive = '' // last streamingText we posted — so we only push on change
878
+ let lastPostAt = 0 // Date.now() of the last post, for the keep-alive heartbeat
840
879
  const flush = async () => {
841
- if (flushing || pending.length === 0) return
880
+ if (flushing) return
881
+ const hasEvents = pending.length > 0
882
+ const liveChanged = liveText !== sentLive
883
+ // Heartbeat: with nothing new for a while, still post (bumps lastActivityAt)
884
+ // so the console shows honest "still working" and can spot a real stall.
885
+ const heartbeat = !hasEvents && !liveChanged && Date.now() - lastPostAt > 2500
886
+ if (!hasEvents && !liveChanged && !heartbeat) return
842
887
  flushing = true
843
888
  const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
844
- await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined)
889
+ const streamingText = liveChanged ? liveText : undefined
890
+ sentLive = liveText
891
+ await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined, streamingText)
892
+ lastPostAt = Date.now()
845
893
  flushing = false
846
894
  }
847
- const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
895
+ const push = (kind, text) => {
896
+ if (text == null || String(text) === '') return
897
+ // A completed text block becomes a real event — drop its live preview so the
898
+ // finalised bubble and the cleared preview swap in on the same flush.
899
+ if (kind === 'TEXT') liveText = ''
900
+ pending.push({ kind, text: String(text) })
901
+ }
848
902
  const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
849
- const timer = setInterval(flush, 800)
903
+ const onDelta = (chunk) => { liveText += chunk }
904
+ const timer = setInterval(flush, 500)
850
905
 
851
906
  // Fetch any attached images locally and fold their paths into the prompt so
852
907
  // Claude reads them with its Read tool (URLs on stdin don't render).
@@ -875,6 +930,9 @@ async function runAiChat(cfg, cmd, repoPath) {
875
930
  return
876
931
  }
877
932
 
933
+ // Track this turn so an `ai_chat_stop` command can find and kill it (gd-303).
934
+ chatChildren.set(sessionId, child)
935
+
878
936
  if (child.stdin) {
879
937
  child.stdin.on('error', () => {})
880
938
  try { child.stdin.write(fullPrompt); child.stdin.end() }
@@ -888,28 +946,61 @@ async function runAiChat(cfg, cmd, repoPath) {
888
946
  while ((nl = buf.indexOf('\n')) >= 0) {
889
947
  const line = buf.slice(0, nl).trim()
890
948
  buf = buf.slice(nl + 1)
891
- if (line) parseStreamLine(line, push, onInit)
949
+ if (line) parseStreamLine(line, push, onInit, onDelta)
892
950
  }
893
951
  })
894
952
  child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
895
953
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
896
954
  child.on('close', async (code) => {
897
955
  clearInterval(timer)
956
+ chatChildren.delete(sessionId)
898
957
  if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
899
- if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
958
+ if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta)
959
+ liveText = '' // turn is over — drop any lingering live preview
900
960
  await flush()
961
+ // User-initiated stop (gd-303): land on idle with a friendly note, not an
962
+ // "exited with code N" error — the kill's non-zero code is expected here.
963
+ if (child.__stopped) {
964
+ await postSessionEvents(
965
+ cfg, sessionId,
966
+ [{ role: 'SYSTEM', text: '⏹ Спряно. Напиши още нещо, за да продължим разговора.' }],
967
+ 'idle', capturedSession || undefined, '',
968
+ )
969
+ reportCommandResult(cfg, cmd.id, 'done', 'stopped by user')
970
+ log(`⏹ ai_chat ${sessionId} спряно от потребителя`)
971
+ return
972
+ }
901
973
  const ok = code === 0
902
974
  await postSessionEvents(
903
975
  cfg, sessionId,
904
976
  ok ? [] : [{ role: 'SYSTEM', text: `✗ Ходът приключи с код ${code}.` }],
905
977
  ok ? 'idle' : 'error',
906
978
  capturedSession || undefined,
979
+ '', // clear the live preview on the terminal post
907
980
  )
908
981
  reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
909
982
  log(`■ ai_chat ${sessionId} приключи (code ${code})`)
910
983
  })
911
984
  }
912
985
 
986
+ // ai_chat_stop — interrupt a running chat turn the user asked to stop (gd-303).
987
+ // Kills the tracked child (its `close` handler then posts the "⏹ Спряно" note and
988
+ // flips the session to idle). If nothing is running, just idles the session so
989
+ // the console unlocks.
990
+ function stopAiChat(cfg, cmd) {
991
+ const sessionId = cmd.payload?.sessionId
992
+ const child = sessionId ? chatChildren.get(sessionId) : null
993
+ if (!child) {
994
+ if (sessionId) postSessionEvents(cfg, sessionId, [], 'idle', undefined, '')
995
+ reportCommandResult(cfg, cmd.id, 'done', 'no active turn')
996
+ return
997
+ }
998
+ child.__stopped = true
999
+ killTree(child)
1000
+ log(`⏹ ai_chat_stop ${sessionId} — kill signalled`)
1001
+ reportCommandResult(cfg, cmd.id, 'done', 'stop signalled')
1002
+ }
1003
+
913
1004
  // deploy — run the project's deploy script (scripts/deploy.sh) in the repo.
914
1005
  // Long-running (git pull + npm build + service restart), so we spawn it detached
915
1006
  // from the poll loop and report the result on exit. The agent is its OWN process
@@ -954,6 +1045,12 @@ async function executeCommand(cfg, cmd, repoPath) {
954
1045
  runAiChat(cfg, cmd, repoPath)
955
1046
  return
956
1047
  }
1048
+ // ai_chat_stop — interrupt the running turn for a session (gd-303). Handled
1049
+ // inline (it just signals a kill) and reports its own result.
1050
+ if (cmd.type === 'ai_chat_stop') {
1051
+ stopAiChat(cfg, cmd)
1052
+ return
1053
+ }
957
1054
  // deploy — run the repo's deploy script. Long-running (pull + build + service
958
1055
  // restart), so it's launched in the background and reports its own result on
959
1056
  // exit, same as ai_run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
5
  "type": "module",
6
6
  "bin": {