gitdone-agent 0.8.3 → 0.8.5
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 +149 -46
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { randomUUID, createHash } from 'node:crypto'
|
|
|
29
29
|
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
30
30
|
// Keep in lockstep with packages/agent/package.json "version" AND
|
|
31
31
|
// src/lib/agentVersion.ts LATEST_AGENT_VERSION.
|
|
32
|
-
const AGENT_VERSION = '0.8.
|
|
32
|
+
const AGENT_VERSION = '0.8.5'
|
|
33
33
|
|
|
34
34
|
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
35
35
|
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
@@ -999,17 +999,64 @@ function scanRepos(roots, maxDepth = 5) {
|
|
|
999
999
|
|
|
1000
1000
|
// ─── Server sync + commands ─────────────────────────────────────────────────────
|
|
1001
1001
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1002
|
+
// Keep the deadline below the server-side session stall window. Without a
|
|
1003
|
+
// deadline, a proxy/network connection that never completes can block the
|
|
1004
|
+
// event-posting loop forever, so no heartbeat reaches the server and the
|
|
1005
|
+
// watchdog incorrectly declares a live AI turn stalled.
|
|
1006
|
+
const API_REQUEST_TIMEOUT_MS = 10_000
|
|
1007
|
+
|
|
1008
|
+
async function apiOnce(cfg, path, body) {
|
|
1009
|
+
const controller = new AbortController()
|
|
1010
|
+
const timer = setTimeout(() => controller.abort(), API_REQUEST_TIMEOUT_MS)
|
|
1011
|
+
try {
|
|
1012
|
+
const res = await fetch(`${cfg.url}${path}`, {
|
|
1013
|
+
method: 'POST',
|
|
1014
|
+
headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
|
|
1015
|
+
body: JSON.stringify(body),
|
|
1016
|
+
signal: controller.signal,
|
|
1017
|
+
})
|
|
1018
|
+
if (!res.ok) {
|
|
1019
|
+
const text = await res.text().catch(() => '')
|
|
1020
|
+
const err = new Error(`HTTP ${res.status} ${path}: ${text}`)
|
|
1021
|
+
err.status = res.status
|
|
1022
|
+
throw err
|
|
1023
|
+
}
|
|
1024
|
+
return res.json().catch(() => ({}))
|
|
1025
|
+
} finally {
|
|
1026
|
+
clearTimeout(timer)
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// gd-514: statuses where the app server demonstrably never processed the body,
|
|
1031
|
+
// so re-sending it can't double-apply anything. 502/503/504 is Caddy answering
|
|
1032
|
+
// while `systemctl restart gitdone` is mid-flight — i.e. EVERY deploy. A thrown
|
|
1033
|
+
// fetch (no status) is a connection that never completed, same story. 4xx and
|
|
1034
|
+
// 500 are NOT retried: they won't get better, and a 500 may have half-applied.
|
|
1035
|
+
function isRetriableApiError(err) {
|
|
1036
|
+
return err?.status === undefined || [408, 429, 502, 503, 504].includes(err.status)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* POST to the app, retrying the failures that are purely "the server wasn't
|
|
1041
|
+
* there". Console events used to be strictly best-effort, which meant a single
|
|
1042
|
+
* 502 during a deploy could swallow the terminal "turn finished" post and leave
|
|
1043
|
+
* the session RUNNING forever, showing a half-written reply that never moved
|
|
1044
|
+
* again (gd-514).
|
|
1045
|
+
*/
|
|
1046
|
+
async function api(cfg, path, body, attempts = 4) {
|
|
1047
|
+
let lastErr
|
|
1048
|
+
for (let i = 0; i < attempts; i++) {
|
|
1049
|
+
try {
|
|
1050
|
+
return await apiOnce(cfg, path, body)
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
lastErr = err
|
|
1053
|
+
if (i === attempts - 1 || !isRetriableApiError(err)) break
|
|
1054
|
+
// 1s, 2s, 4s — covers the restart window of a deploy without stalling the
|
|
1055
|
+
// console noticeably when the server is merely slow.
|
|
1056
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** i))
|
|
1057
|
+
}
|
|
1011
1058
|
}
|
|
1012
|
-
|
|
1059
|
+
throw lastErr
|
|
1013
1060
|
}
|
|
1014
1061
|
|
|
1015
1062
|
async function reportCommandResult(cfg, id, status, result) {
|
|
@@ -1021,20 +1068,28 @@ async function reportCommandResult(cfg, id, status, result) {
|
|
|
1021
1068
|
// how many tokens the run cost. `streamingText` / `activityText` (gd-419) carry
|
|
1022
1069
|
// the live "being typed" preview and the latest thinking snippet — explicit ''
|
|
1023
1070
|
// clears them; `undefined` leaves them untouched.
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1071
|
+
// Same contract as postSessionEvents: true when it landed, false when the
|
|
1072
|
+
// caller must keep the batch and retry it (gd-514).
|
|
1073
|
+
async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText, extra, attempts) {
|
|
1074
|
+
try {
|
|
1075
|
+
await api(cfg, '/api/v1/agent/ai-run/events', {
|
|
1076
|
+
runId,
|
|
1077
|
+
events,
|
|
1078
|
+
...(status ? { status } : {}),
|
|
1079
|
+
...(result !== undefined ? { result } : {}),
|
|
1080
|
+
...(usage ? { usage } : {}),
|
|
1081
|
+
...(streamingText !== undefined ? { streamingText } : {}),
|
|
1082
|
+
...(activityText !== undefined ? { activityText } : {}),
|
|
1083
|
+
// gd-466: claude's own session id (for --resume) + precise stop cause.
|
|
1084
|
+
...(extra?.claudeSessionId ? { claudeSessionId: extra.claudeSessionId } : {}),
|
|
1085
|
+
...(extra?.stopReason ? { stopReason: extra.stopReason } : {}),
|
|
1086
|
+
...(typeof extra?.resetAt === 'number' ? { resetAt: extra.resetAt } : {}),
|
|
1087
|
+
}, attempts)
|
|
1088
|
+
return true
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
log(`✗ ai-run events post failed: ${e.message}`)
|
|
1091
|
+
return false
|
|
1092
|
+
}
|
|
1038
1093
|
}
|
|
1039
1094
|
|
|
1040
1095
|
// gd-466: parse a hard usage-limit stop out of claude's headless output. On the
|
|
@@ -1058,17 +1113,26 @@ function detectUsageLimit(text) {
|
|
|
1058
1113
|
|
|
1059
1114
|
// Post transcript lines (and optional turn status / Claude session id) for an
|
|
1060
1115
|
// interactive AiSession chat turn.
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1116
|
+
// Returns true when the post landed. A false is the caller's cue to put the
|
|
1117
|
+
// batch back and re-send it next tick, instead of dropping it on the floor
|
|
1118
|
+
// (gd-514) — that is how whole chunks of a reply used to go missing.
|
|
1119
|
+
async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText, activityText, attempts) {
|
|
1120
|
+
try {
|
|
1121
|
+
await api(cfg, '/api/v1/agent/ai-session/events', {
|
|
1122
|
+
sessionId,
|
|
1123
|
+
events,
|
|
1124
|
+
...(status ? { status } : {}),
|
|
1125
|
+
...(claudeSessionId ? { claudeSessionId } : {}),
|
|
1126
|
+
// Explicit '' clears the live preview; `undefined` leaves it untouched.
|
|
1127
|
+
...(streamingText !== undefined ? { streamingText } : {}),
|
|
1128
|
+
// Same semantics for the live thinking snippet (gd-419).
|
|
1129
|
+
...(activityText !== undefined ? { activityText } : {}),
|
|
1130
|
+
}, attempts)
|
|
1131
|
+
return true
|
|
1132
|
+
} catch (e) {
|
|
1133
|
+
log(`✗ ai-session events post failed: ${e.message}`)
|
|
1134
|
+
return false
|
|
1135
|
+
}
|
|
1072
1136
|
}
|
|
1073
1137
|
|
|
1074
1138
|
// Turn one stream-json line from `claude -p` into console events. Phase 1 shows
|
|
@@ -1645,7 +1709,9 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1645
1709
|
const activityChanged = liveActivity !== sentActivity
|
|
1646
1710
|
if (pending.length === 0 && !liveChanged && !activityChanged) return
|
|
1647
1711
|
flushing = true
|
|
1648
|
-
const
|
|
1712
|
+
const taken = pending; pending = []
|
|
1713
|
+
const prevSentLive = sentLive
|
|
1714
|
+
const prevSentActivity = sentActivity
|
|
1649
1715
|
const streamingText = liveChanged ? liveText : undefined
|
|
1650
1716
|
const activityText = activityChanged ? liveActivity : undefined
|
|
1651
1717
|
sentLive = liveText
|
|
@@ -1654,7 +1720,16 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1654
1720
|
// resume survives even if the agent dies before the clean close handler.
|
|
1655
1721
|
const extra = (!sentSession && capturedSession) ? { claudeSessionId: capturedSession } : undefined
|
|
1656
1722
|
if (extra) sentSession = true
|
|
1657
|
-
await postRunEvents(cfg, runId,
|
|
1723
|
+
const ok = await postRunEvents(cfg, runId, taken, undefined, undefined, undefined, streamingText, activityText, extra)
|
|
1724
|
+
if (!ok) {
|
|
1725
|
+
// Keep everything for the next tick rather than losing console lines to
|
|
1726
|
+
// one bad request (gd-514) — including the session id, so a resume still
|
|
1727
|
+
// gets it.
|
|
1728
|
+
pending = taken.concat(pending)
|
|
1729
|
+
sentLive = prevSentLive
|
|
1730
|
+
sentActivity = prevSentActivity
|
|
1731
|
+
if (extra) sentSession = false
|
|
1732
|
+
}
|
|
1658
1733
|
flushing = false
|
|
1659
1734
|
}
|
|
1660
1735
|
const push = (kind, text) => {
|
|
@@ -1757,7 +1832,9 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1757
1832
|
: (ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.`),
|
|
1758
1833
|
}]
|
|
1759
1834
|
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)}` : ''}` })
|
|
1760
|
-
|
|
1835
|
+
// Terminal post — retried hard (gd-514): losing it leaves the run stuck on
|
|
1836
|
+
// RUNNING, and with it the token-limit park/resume the server keys off.
|
|
1837
|
+
const landed = await postRunEvents(
|
|
1761
1838
|
cfg, runId,
|
|
1762
1839
|
events,
|
|
1763
1840
|
// A limit stop is not a clean finish — report it as 'error' so the run
|
|
@@ -1768,7 +1845,9 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1768
1845
|
undefined,
|
|
1769
1846
|
undefined,
|
|
1770
1847
|
extra,
|
|
1848
|
+
7,
|
|
1771
1849
|
)
|
|
1850
|
+
if (!landed) log(`✗ ai_run ${runId}: терминалният пост не мина — сървърният watchdog поема`)
|
|
1772
1851
|
reportCommandResult(cfg, cmd.id, ok && !limit.limited ? 'done' : 'error', limit.limited ? 'usage_limit' : `exit ${code}`)
|
|
1773
1852
|
log(`■ ai_run ${runId} приключи (code ${code}${limit.limited ? ', usage_limit' : ''})`)
|
|
1774
1853
|
})
|
|
@@ -1863,12 +1942,26 @@ async function flushChatTurn(cfg, entry) {
|
|
|
1863
1942
|
const heartbeat = !hasEvents && !liveChanged && !activityChanged && Date.now() - t.lastPostAt > 2500
|
|
1864
1943
|
if (!hasEvents && !liveChanged && !activityChanged && !heartbeat) return
|
|
1865
1944
|
t.flushing = true
|
|
1866
|
-
const
|
|
1945
|
+
const taken = t.pending; t.pending = []
|
|
1946
|
+
const prevSentLive = t.sentLive
|
|
1947
|
+
const prevSentActivity = t.sentActivity
|
|
1867
1948
|
const streamingText = liveChanged ? t.liveText : undefined
|
|
1868
1949
|
const activityText = activityChanged ? t.liveActivity : undefined
|
|
1869
1950
|
t.sentLive = t.liveText
|
|
1870
1951
|
t.sentActivity = t.liveActivity
|
|
1871
|
-
await postSessionEvents(
|
|
1952
|
+
const ok = await postSessionEvents(
|
|
1953
|
+
cfg, entry.sessionId,
|
|
1954
|
+
taken.map((e) => ({ role: chatRoleFor(e.kind), text: e.text })),
|
|
1955
|
+
'running', entry.capturedSession || undefined, streamingText, activityText,
|
|
1956
|
+
)
|
|
1957
|
+
if (!ok) {
|
|
1958
|
+
// Put it all back so the next tick re-sends it (gd-514). Dropping the batch
|
|
1959
|
+
// is what used to tear holes in the transcript; rewinding sentLive makes the
|
|
1960
|
+
// next post carry the current live text again (it's a replace, not a delta).
|
|
1961
|
+
t.pending = taken.concat(t.pending)
|
|
1962
|
+
t.sentLive = prevSentLive
|
|
1963
|
+
t.sentActivity = prevSentActivity
|
|
1964
|
+
}
|
|
1872
1965
|
t.lastPostAt = Date.now()
|
|
1873
1966
|
t.flushing = false
|
|
1874
1967
|
}
|
|
@@ -1889,21 +1982,31 @@ async function finishChatTurn(cfg, entry, outcome) {
|
|
|
1889
1982
|
for (let i = 0; i < 20 && t.flushing; i++) await new Promise((r) => setTimeout(r, 100))
|
|
1890
1983
|
const batch = t.pending.map((e) => ({ role: chatRoleFor(e.kind), text: e.text }))
|
|
1891
1984
|
t.pending = []
|
|
1985
|
+
// The one post that MUST land: it carries the last of the reply and the
|
|
1986
|
+
// terminal status. Lose it and the console shows "АИ пише…" over a
|
|
1987
|
+
// half-written answer forever (gd-514) — so it gets ~1 minute of retries,
|
|
1988
|
+
// enough to outlast an app restart. The server's stall watchdog is the net if
|
|
1989
|
+
// even that fails.
|
|
1990
|
+
const terminal = async (status, extra) => {
|
|
1991
|
+
const ok = await postSessionEvents(
|
|
1992
|
+
cfg, entry.sessionId, extra ? batch.concat(extra) : batch, status,
|
|
1993
|
+
entry.capturedSession || undefined, '', '', 7,
|
|
1994
|
+
)
|
|
1995
|
+
if (!ok) log(`✗ ai_chat ${entry.sessionId}: терминалният пост не мина — сървърът ще помири сесията`)
|
|
1996
|
+
}
|
|
1892
1997
|
if (outcome.stopped) {
|
|
1893
|
-
|
|
1894
|
-
await postSessionEvents(cfg, entry.sessionId, batch, 'idle', entry.capturedSession || undefined, '', '')
|
|
1998
|
+
await terminal('idle', { role: 'SYSTEM', text: '⏹ Спряно. Напиши още нещо, за да продължим разговора.' })
|
|
1895
1999
|
reportCommandResult(cfg, t.cmdId, 'done', 'stopped by user')
|
|
1896
2000
|
log(`⏹ ai_chat ${entry.sessionId} спряно от потребителя`)
|
|
1897
2001
|
return
|
|
1898
2002
|
}
|
|
1899
2003
|
if (!outcome.ok) {
|
|
1900
|
-
|
|
1901
|
-
await postSessionEvents(cfg, entry.sessionId, batch, 'error', entry.capturedSession || undefined, '', '')
|
|
2004
|
+
await terminal('error', { role: 'SYSTEM', text: `✗ Ходът приключи с код ${outcome.code}.` })
|
|
1902
2005
|
reportCommandResult(cfg, t.cmdId, 'error', `exit ${outcome.code}`)
|
|
1903
2006
|
log(`✗ ai_chat ход в ${entry.sessionId} падна (code ${outcome.code})`)
|
|
1904
2007
|
return
|
|
1905
2008
|
}
|
|
1906
|
-
await
|
|
2009
|
+
await terminal('idle')
|
|
1907
2010
|
reportCommandResult(cfg, t.cmdId, 'done', 'ok')
|
|
1908
2011
|
log(`✓ ai_chat ход в ${entry.sessionId} приключи (процесът остава жив)`)
|
|
1909
2012
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitdone-agent",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Local git agent for gitdone
|
|
3
|
+
"version": "0.8.5",
|
|
4
|
+
"description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"gitdone-agent": "index.js"
|