gitdone-agent 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +679 -75
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -13,6 +13,7 @@ import { execSync, execFileSync, spawn } from 'node:child_process'
13
13
  import {
14
14
  existsSync, writeFileSync, readFileSync, unlinkSync, renameSync,
15
15
  mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync, statSync,
16
+ openSync, writeSync, fsyncSync, closeSync, readSync,
16
17
  } from 'node:fs'
17
18
  import { resolve, join } from 'node:path'
18
19
  import { homedir, hostname, tmpdir } from 'node:os'
@@ -27,10 +28,12 @@ import { randomUUID, createHash } from 'node:crypto'
27
28
  // Reported to the server on every sync so the web UI can flag outdated agents.
28
29
  // Keep in lockstep with packages/agent/package.json "version" AND
29
30
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
30
- const AGENT_VERSION = '0.7.6'
31
+ const AGENT_VERSION = '0.8.0'
31
32
 
32
33
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
34
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
35
+ // Last-known-good copy of config.json, refreshed once per successful start.
36
+ const CONFIG_BAK_PATH = CONFIG_PATH + '.bak'
34
37
  const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
35
38
  const LOG_PATH = join(AGENT_DIR, 'agent.log')
36
39
 
@@ -80,20 +83,98 @@ function survive(kind, err) {
80
83
  process.on('uncaughtException', (err) => survive('uncaught exception', err))
81
84
  process.on('unhandledRejection', (err) => survive('unhandled rejection', err))
82
85
 
83
- function readConfig() {
84
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
86
+ function parseConfigFile(path) {
87
+ try {
88
+ // Strip a UTF-8 BOM: we never write one, but a config repaired by hand in an
89
+ // editor that adds one would otherwise be unparsable — and "I fixed the file
90
+ // and it still says no config" is the worst possible dead end (gd-478).
91
+ const cfg = JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''))
92
+ return cfg && typeof cfg === 'object' && cfg.key ? cfg : null
93
+ } catch { return null }
94
+ }
95
+
96
+ // Rebuild the bare minimum from ai-mcp-config.json — last resort when both
97
+ // config.json and its backup are gone. It is written from cfg.key/cfg.machineId
98
+ // on every AI session, so it carries the two values that cannot be regenerated
99
+ // locally (a new machineId would show up as a SECOND machine on the server, and
100
+ // there is no way to invent the API key). Roots are lost, but an agent that is
101
+ // online with no roots can be fixed from the web UI; an offline one cannot.
102
+ function recoverConfigFromAiMcp() {
103
+ try {
104
+ const mcp = JSON.parse(readFileSync(join(AGENT_DIR, 'ai-mcp-config.json'), 'utf8'))
105
+ const server = mcp?.mcpServers?.gitdone
106
+ const key = server?.headers?.Authorization?.replace(/^Bearer\s+/, '')
107
+ const machineId = server?.headers?.['X-Gitdone-Machine-Id']
108
+ if (!key || !machineId || !server.url) return null
109
+ return {
110
+ key,
111
+ url: server.url.replace(/\/api\/mcp\/?$/, ''),
112
+ interval: 30,
113
+ machineId,
114
+ hostname: hostname(),
115
+ roots: [],
116
+ }
117
+ } catch { return null }
85
118
  }
86
119
 
120
+ // True when the running config had to be rebuilt — surfaced by --doctor.
121
+ let configWasRecovered = null
122
+
123
+ // An unclean shutdown (power loss, BSOD) can leave config.json committed by the
124
+ // filesystem as a NUL-filled file of the right length: intact on disk, garbage
125
+ // to JSON.parse. Before gd-478 that bricked the agent for good — every autostart
126
+ // exited with "no config found" and the supervisor just respawned it every 10s,
127
+ // so the machine stayed offline until somebody hand-edited the file. A user
128
+ // without filesystem access had no way out at all. So never trust the primary
129
+ // copy blindly: fall back to the last-known-good backup, then to the AI MCP
130
+ // config, and put whichever survived back in place.
131
+ function readConfig() {
132
+ const primary = parseConfigFile(CONFIG_PATH)
133
+ if (primary) return primary
134
+ // Nothing to recover from on a fresh machine (install path) — stay quiet.
135
+ if (!existsSync(CONFIG_PATH) && !existsSync(CONFIG_BAK_PATH)) return null
136
+
137
+ for (const [source, cfg] of [
138
+ ['config.json.bak', parseConfigFile(CONFIG_BAK_PATH)],
139
+ ['ai-mcp-config.json', recoverConfigFromAiMcp()],
140
+ ]) {
141
+ if (!cfg) continue
142
+ configWasRecovered = source
143
+ log(`✗ config.json е повреден (най-вероятно от неочаквано изключване) — възстановен от ${source}`)
144
+ try { writeConfig(cfg) } catch (err) { log(`✗ записът на възстановения config не успя: ${err.message}`) }
145
+ return cfg
146
+ }
147
+ log('✗ config.json е повреден и няма от какво да се възстанови — пусни gitdone-agent --key=... --root=... --install')
148
+ return null
149
+ }
150
+
151
+ // Atomic AND durable: write to a temp file, force it to the platter, and only
152
+ // then rename over the real one. The fsync is the part that matters — without
153
+ // it the rename can be committed while the data is still in the page cache, and
154
+ // a power cut in that window is exactly what produced the NUL-filled config in
155
+ // gd-478. Losing the rename is harmless (the old config survives); losing the
156
+ // data is not.
87
157
  function writeConfig(cfg) {
88
158
  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
159
  const tmp = CONFIG_PATH + '.tmp'
93
- writeFileSync(tmp, JSON.stringify(cfg, null, 2), 'utf8')
160
+ const fd = openSync(tmp, 'w')
161
+ try {
162
+ writeSync(fd, JSON.stringify(cfg, null, 2), null, 'utf8')
163
+ fsyncSync(fd)
164
+ } finally {
165
+ closeSync(fd)
166
+ }
94
167
  renameSync(tmp, CONFIG_PATH)
95
168
  }
96
169
 
170
+ // Refresh the last-known-good copy. Called once per successful start, so the
171
+ // backup is always a config that actually booted the agent — never a half-baked
172
+ // one. Best-effort: a failed backup must not stop the agent from running.
173
+ function backupConfig(cfg) {
174
+ try { writeFileSync(CONFIG_BAK_PATH, JSON.stringify(cfg, null, 2), 'utf8') }
175
+ catch (err) { log(`✗ backup на config.json не успя: ${err.message}`) }
176
+ }
177
+
97
178
  // ─── Arg parsing ─────────────────────────────────────────────────────────────
98
179
 
99
180
  function parseArgs() {
@@ -148,42 +229,65 @@ function getNodePath() {
148
229
  return process.execPath
149
230
  }
150
231
 
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
232
+ // Locate an agentic CLI on this machine. Prefer a real executable (the native
233
+ // installer's .exe) so spawn works WITHOUT a shell — that lets us pass a
153
234
  // 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() {
235
+ // npm shim (.cmd) needs shell:true, where we collapse the prompt to one line to
236
+ // survive cmd.exe parsing. Returns `found:false` when nothing was located, so
237
+ // callers can show a clear message instead of spawning the bare name and letting
238
+ // cmd.exe emit "'' is not recognized…".
239
+ //
240
+ // `wellKnown` lists install locations to probe directly: the agent is
241
+ // auto-started at login, so its PATH is frozen at that moment — a CLI installed
242
+ // (or a PATH entry added) afterwards is invisible to `where`/`which`. Probing
243
+ // makes a freshly-installed CLI work without a Windows re-login.
244
+ function findCli(bin, wellKnown) {
159
245
  const tryCmd = (c) => {
160
246
  try {
161
247
  const out = execSync(c, { encoding: 'utf8' }).trim()
162
248
  return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
163
249
  } catch { return [] }
164
250
  }
165
- const cands = [...tryCmd('where claude'), ...tryCmd('which claude')]
251
+ const cands = [...tryCmd(`where ${bin}`), ...tryCmd(`which ${bin}`)]
252
+ for (const p of wellKnown) {
253
+ if (existsSync(p) && !cands.includes(p)) cands.push(p)
254
+ }
166
255
 
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.
256
+ const exe = cands.find((p) => /\.exe$/i.test(p))
257
+ if (exe) return { path: exe, shell: false, found: true }
258
+ if (cands.length) return { path: cands[0], shell: true, found: true }
259
+ return { path: bin, shell: true, found: false }
260
+ }
261
+
262
+ // Locate the Claude Code CLI.
263
+ function findClaude() {
171
264
  const home = homedir()
172
265
  const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
173
- for (const p of [
266
+ return findCli('claude', [
174
267
  join(home, '.local', 'bin', 'claude.exe'), // Windows native installer
175
268
  join(appData, 'npm', 'claude.cmd'), // Windows npm global shim
176
269
  join(home, '.local', 'bin', 'claude'), // macOS/Linux native installer
177
270
  '/usr/local/bin/claude', // Homebrew (Intel) / manual install
178
271
  '/opt/homebrew/bin/claude', // Homebrew (Apple silicon)
179
- ]) {
180
- if (existsSync(p) && !cands.includes(p)) cands.push(p)
181
- }
272
+ ])
273
+ }
182
274
 
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 }
275
+ // Locate the OpenAI Codex CLI — the ChatGPT-side engine (gd-491). Same rules as
276
+ // findClaude; the Codex binary ships as a Rust executable (native installer /
277
+ // cargo / Homebrew) or as the `@openai/codex` npm global.
278
+ function findCodex() {
279
+ const home = homedir()
280
+ const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
281
+ return findCli('codex', [
282
+ join(home, '.codex', 'bin', 'codex.exe'), // Windows native installer
283
+ join(home, '.local', 'bin', 'codex.exe'),
284
+ join(appData, 'npm', 'codex.cmd'), // Windows npm global shim
285
+ join(home, '.codex', 'bin', 'codex'), // macOS/Linux native installer
286
+ join(home, '.local', 'bin', 'codex'),
287
+ join(home, '.cargo', 'bin', 'codex'), // cargo install
288
+ '/usr/local/bin/codex', // Homebrew (Intel) / manual install
289
+ '/opt/homebrew/bin/codex', // Homebrew (Apple silicon)
290
+ ])
187
291
  }
188
292
 
189
293
  // ─── Single instance (gd-407) ─────────────────────────────────────────────────
@@ -458,11 +562,18 @@ function runDoctor() {
458
562
  let hb = '(none yet)'
459
563
  try { hb = `(последен: преди ${Math.round((Date.now() - statSync(HEARTBEAT_PATH).mtimeMs) / 1000)}s)` } catch { /* no beat yet */ }
460
564
  console.log(` heartbeat : ${HEARTBEAT_PATH} ${hb}`)
461
- console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
565
+ // cfg comes from readConfig() above, so a corrupt config.json has already been
566
+ // repaired by the time we print this — say so rather than a bare "(ok)".
567
+ const cfgState = configWasRecovered ? `(ВЪЗСТАНОВЕН от ${configWasRecovered} — беше повреден)`
568
+ : cfg ? '(ok)' : existsSync(CONFIG_PATH) ? '(ПОВРЕДЕН)' : '(MISSING)'
569
+ console.log(` config : ${CONFIG_PATH} ${cfgState}`)
570
+ console.log(` config backup : ${CONFIG_BAK_PATH} ${parseConfigFile(CONFIG_BAK_PATH) ? '(ok)' : '(няма — прави се при първия успешен старт)'}`)
462
571
  console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
463
572
  console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
464
573
  const claude = findClaude()
465
574
  console.log(` claude cli : ${claude.found ? claude.path : 'NOT FOUND — инсталирай от claude.ai/code и рестартирай агента'}`)
575
+ const codex = findCodex()
576
+ console.log(` codex cli : ${codex.found ? codex.path : 'NOT FOUND — нужен само за проекти с АИ = ChatGPT (npm i -g @openai/codex + codex login)'}`)
466
577
  if (cfg) {
467
578
  console.log(` machineId : ${cfg.machineId}`)
468
579
  console.log(` server : ${cfg.url}`)
@@ -1058,6 +1169,162 @@ function parseStreamLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
1058
1169
  }
1059
1170
  }
1060
1171
 
1172
+ // ─── Codex (ChatGPT) event stream (gd-491) ───────────────────────────────────
1173
+ // `codex exec --json` emits JSON Lines with a different vocabulary than Claude's
1174
+ // stream-json, so it gets its own parser behind the SAME callback contract
1175
+ // (push / onInit / onDelta / onMeta / onTurnEnd) — everything downstream (the
1176
+ // console flushers, the run/session posters) then works unchanged.
1177
+ //
1178
+ // Events: thread.started (thread_id — the id we resume with), turn.started,
1179
+ // turn.completed (usage), turn.failed, item.started|updated|completed (the work
1180
+ // itself), error. Item types: agent_message, reasoning, command_execution,
1181
+ // file_change, mcp_tool_call, web_search, todo_list.
1182
+ //
1183
+ // Field access is deliberately forgiving (several spellings per field): the
1184
+ // Codex CLI is explicitly experimental and has renamed stream fields between
1185
+ // releases, and an unknown shape must degrade to "shows less in the console",
1186
+ // never to a crashed turn.
1187
+
1188
+ // Compact one item into the console's TOOL_CALL one-liner.
1189
+ function codexItemText(item) {
1190
+ const type = item?.type
1191
+ if (type === 'command_execution') {
1192
+ const cmd = item.command ?? item.cmd ?? ''
1193
+ const arr = Array.isArray(cmd) ? cmd.join(' ') : String(cmd)
1194
+ return arr ? `Bash ${arr.slice(0, 400)}` : 'Bash'
1195
+ }
1196
+ if (type === 'file_change') {
1197
+ const changes = item.changes ?? item.files ?? []
1198
+ const names = (Array.isArray(changes) ? changes : [])
1199
+ .map((c) => (typeof c === 'string' ? c : c?.path ?? c?.file ?? ''))
1200
+ .filter(Boolean)
1201
+ return names.length ? `Edit ${names.slice(0, 8).join(', ').slice(0, 400)}` : 'Edit'
1202
+ }
1203
+ if (type === 'mcp_tool_call') {
1204
+ const server = item.server ?? item.server_name ?? ''
1205
+ const tool = item.tool ?? item.tool_name ?? item.name ?? ''
1206
+ const args = item.arguments ?? item.args ?? item.input
1207
+ const argStr = args ? ` ${(typeof args === 'string' ? args : JSON.stringify(args)).slice(0, 300)}` : ''
1208
+ return `${[server, tool].filter(Boolean).join('__') || 'mcp'}${argStr}`
1209
+ }
1210
+ if (type === 'web_search') {
1211
+ const q = item.query ?? item.q ?? ''
1212
+ return `WebSearch${q ? ` ${String(q).slice(0, 300)}` : ''}`
1213
+ }
1214
+ if (type === 'todo_list') {
1215
+ const items = item.items ?? item.todos ?? []
1216
+ const names = (Array.isArray(items) ? items : [])
1217
+ .map((t) => (typeof t === 'string' ? t : t?.text ?? t?.title ?? ''))
1218
+ .filter(Boolean)
1219
+ return names.length ? `TodoWrite\n${names.map((n) => `- ${n}`).join('\n').slice(0, 800)}` : 'TodoWrite'
1220
+ }
1221
+ return null
1222
+ }
1223
+
1224
+ // Raw output of a finished tool item — the Codex counterpart of the PostToolUse
1225
+ // hook we register for Claude (Codex has no hook mechanism, but it puts the
1226
+ // output right in the item, which is better).
1227
+ function codexItemResult(item) {
1228
+ if (item?.type === 'command_execution') {
1229
+ const out = item.aggregated_output ?? item.output ?? item.stdout ?? ''
1230
+ const code = item.exit_code ?? item.exitCode
1231
+ const text = String(out || '').slice(0, 2000)
1232
+ if (!text && code == null) return null
1233
+ return `${text}${code != null ? `\n(exit ${code})` : ''}`.trim()
1234
+ }
1235
+ // A failed gitdone MCP call is worth seeing: it's how the AI drives the task,
1236
+ // so silence here would leave the board unmoved with no visible reason.
1237
+ if (item?.type === 'mcp_tool_call' && item.error) {
1238
+ const msg = typeof item.error === 'string' ? item.error : item.error.message ?? JSON.stringify(item.error)
1239
+ return `✗ ${String(msg).slice(0, 500)}`
1240
+ }
1241
+ return null
1242
+ }
1243
+
1244
+ function parseCodexLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
1245
+ let ev
1246
+ try { ev = JSON.parse(line) } catch { return }
1247
+ // Valid JSON that isn't an object (`null`, `[]`, a bare number) must not throw:
1248
+ // this runs inside the child's stdout handler, where an exception is uncaught
1249
+ // and would take down the whole agent process.
1250
+ if (!ev || typeof ev !== 'object') return
1251
+ const type = ev.type ?? ev.event ?? ''
1252
+
1253
+ if (type === 'thread.started') {
1254
+ const id = ev.thread_id ?? ev.threadId ?? ev.id
1255
+ push('SYSTEM', 'Сесия стартирана (Codex).')
1256
+ // The thread id is what `codex exec resume <id>` continues — the Codex
1257
+ // equivalent of claude's session id, stored in the same column.
1258
+ if (id && typeof onInit === 'function') onInit(id)
1259
+ return
1260
+ }
1261
+
1262
+ if (type === 'item.started' || type === 'item.updated' || type === 'item.completed') {
1263
+ const item = ev.item ?? ev
1264
+ const itemType = item?.type
1265
+
1266
+ // The model's own prose. Only the completed item is final; the in-flight
1267
+ // updates drive the live "being typed" preview, like Claude's text deltas.
1268
+ if (itemType === 'agent_message') {
1269
+ const text = item.text ?? item.message ?? item.content ?? ''
1270
+ if (type === 'item.completed') {
1271
+ if (String(text).trim()) push('TEXT', String(text).trim())
1272
+ } else if (typeof onDelta === 'function' && text) {
1273
+ // Codex sends the whole message so far (not a delta) — replace rather
1274
+ // than append, or the preview would repeat itself as it grows.
1275
+ onDelta(String(text), 'text-replace')
1276
+ }
1277
+ return
1278
+ }
1279
+
1280
+ // Reasoning → the gray „какво прави АИ-то" line.
1281
+ if (itemType === 'reasoning') {
1282
+ const text = item.text ?? item.summary ?? item.content ?? ''
1283
+ if (typeof onDelta === 'function' && text) {
1284
+ onDelta('', 'thinking-start')
1285
+ onDelta(String(text), 'thinking')
1286
+ }
1287
+ return
1288
+ }
1289
+
1290
+ // Tools: announce on start, show raw output on completion.
1291
+ if (type === 'item.started') {
1292
+ const text = codexItemText(item)
1293
+ if (text) push('TOOL_CALL', text)
1294
+ } else if (type === 'item.completed') {
1295
+ const result = codexItemResult(item)
1296
+ if (result) push('TOOL_RESULT', result)
1297
+ }
1298
+ return
1299
+ }
1300
+
1301
+ if (type === 'turn.completed') {
1302
+ const u = ev.usage ?? ev.turn?.usage
1303
+ if (u && typeof onMeta === 'function') {
1304
+ onMeta({
1305
+ usage: {
1306
+ inputTokens: u.input_tokens ?? u.inputTokens ?? 0,
1307
+ outputTokens: u.output_tokens ?? u.outputTokens ?? 0,
1308
+ cacheReadTokens: u.cached_input_tokens ?? u.cachedInputTokens ?? 0,
1309
+ cacheCreateTokens: 0,
1310
+ },
1311
+ })
1312
+ }
1313
+ if (typeof onTurnEnd === 'function') onTurnEnd('success')
1314
+ return
1315
+ }
1316
+
1317
+ if (type === 'turn.failed' || type === 'error') {
1318
+ const err = ev.error ?? ev.message ?? ev
1319
+ const text = typeof err === 'string' ? err : err?.message ?? JSON.stringify(err).slice(0, 500)
1320
+ push('SYSTEM', `✗ ${text}`)
1321
+ // Feed the failure text to the usage-limit detector, same as Claude's
1322
+ // result text — a ChatGPT quota stop parks the task the same way.
1323
+ if (typeof onMeta === 'function') onMeta({ subtype: 'error', resultText: text })
1324
+ if (typeof onTurnEnd === 'function') onTurnEnd('error')
1325
+ }
1326
+ }
1327
+
1061
1328
  // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
1062
1329
  // (Bash stdout, edit results, …) to the run's console, plus the settings file
1063
1330
  // that registers it. The hook reads GITDONE_* from its env (set per run on the
@@ -1196,64 +1463,155 @@ function aiModelArg(raw) {
1196
1463
  return /^[A-Za-z0-9._-]+$/.test(m) ? m : null
1197
1464
  }
1198
1465
 
1199
- // Run a headless Claude Code session for an `ai_run` command and stream its
1200
- // output back. Long-running and fire-and-forget: it wires up async handlers and
1201
- // returns immediately so the agent's snapshot loop is never blocked.
1466
+ // Which CLI a command asks for (gd-491). Anything unknown including the
1467
+ // missing field sent by a gitDone older than gd-491 means Claude, which is
1468
+ // what every run was before this existed.
1469
+ function aiProviderArg(raw) {
1470
+ return raw === 'codex' ? 'codex' : 'claude'
1471
+ }
1472
+
1473
+ function cliNotFoundMessage(provider, hostname) {
1474
+ return provider === 'codex'
1475
+ ? `Codex CLI (ChatGPT) не е намерен на този компютър (${hostname}). Инсталирай го (npm i -g @openai/codex), влез с ChatGPT акаунт през „codex login", увери се, че „codex" е в PATH, после рестартирай агента.`
1476
+ : `Claude Code CLI не е намерен на този компютър (${hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1477
+ }
1478
+
1479
+ // Argument list for one `codex exec` run (gd-491).
1480
+ //
1481
+ // Notes on the choices here:
1482
+ // • `-` makes Codex read the prompt from STDIN — same reason as Claude's `-p`
1483
+ // on stdin: a multi-line Cyrillic prompt survives the npm .cmd shim intact.
1484
+ // • `--json` is the JSONL event stream parseCodexLine consumes.
1485
+ // • `--sandbox workspace-write` matches Claude's `--permission-mode acceptEdits`:
1486
+ // the AI may edit the repo it was pointed at, but not roam the machine.
1487
+ // • `--skip-git-repo-check` — the repo IS a git repo, but a fresh clone/worktree
1488
+ // without a commit would otherwise abort the run.
1489
+ // • MCP: Codex has no `--mcp-config`; the documented route is config overrides,
1490
+ // so we inject the gitdone HTTP MCP server with `-c` dotted keys. The bearer
1491
+ // token is passed by ENV VAR NAME (bearer_token_env_var), so the agent's key
1492
+ // never lands in the process command line where other users could read it.
1493
+ //
1494
+ // The commit policy is NOT here: Codex has no tool-level deny list to mirror
1495
+ // Claude's --disallowedTools, so it rides along in the prompt (codexPrompt).
1496
+ function codexExecArgs(cfg, opts) {
1497
+ const { model, resumeId } = opts
1498
+ const flags = [
1499
+ '--json',
1500
+ '--skip-git-repo-check',
1501
+ // Why the alarming flag: in `codex exec` there is NO approval handler (stdin
1502
+ // carries the prompt, not answers), so EVERY MCP tool call is auto-denied
1503
+ // with "user cancelled MCP tool call" — verified here against codex 0.146,
1504
+ // and tracked upstream as openai/codex#24135 and #16685. Neither
1505
+ // `-a never`, `-c approval_policy="never"` nor a sandbox mode changes it;
1506
+ // this flag is the only thing that lets an MCP call through. Without it the
1507
+ // gitdone MCP is dead weight and a ChatGPT run cannot register its agents or
1508
+ // move its own task — the entire point of dispatching it.
1509
+ //
1510
+ // It is not a step down from how this agent already runs Claude: that path
1511
+ // uses `--permission-mode acceptEdits` with Bash allowed and no OS sandbox,
1512
+ // so both engines have the same reach on the machine. Revisit once the
1513
+ // upstream issue lands — the sandbox is worth having back.
1514
+ //
1515
+ // (`--sandbox` is deliberately NOT used: it exists only on plain `exec`, and
1516
+ // `codex exec resume` — a subcommand with its own smaller flag set — rejects
1517
+ // it, which killed every resumed turn with "unexpected argument".)
1518
+ '--dangerously-bypass-approvals-and-sandbox',
1519
+ ...(model ? ['--model', model] : []),
1520
+ '-c', `mcp_servers.gitdone.url="${cfg.url}/api/mcp"`,
1521
+ '-c', 'mcp_servers.gitdone.bearer_token_env_var="GITDONE_KEY"',
1522
+ // Tags the AI agents this run registers with THIS computer (gd-308).
1523
+ '-c', `mcp_servers.gitdone.http_headers.X-Gitdone-Machine-Id="${cfg.machineId}"`,
1524
+ ]
1525
+ // `-` = read the prompt from stdin, and it must be the LAST positional:
1526
+ // codex exec [OPTIONS] [PROMPT]
1527
+ // codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]
1528
+ // Continuing a thread is how a console session keeps its context across turns,
1529
+ // and how a token-limit park resumes (gd-466 semantics, Codex spelling).
1530
+ return resumeId
1531
+ ? ['exec', 'resume', ...flags, resumeId, '-']
1532
+ : ['exec', ...flags, '-']
1533
+ }
1534
+
1535
+ // Claude gets the repo's commit policy enforced by `--disallowedTools`; Codex
1536
+ // has no equivalent deny list, so the same policy is stated in the prompt. Weaker
1537
+ // (an instruction, not a hard block) — hence it's spelled out unmistakably.
1538
+ function codexPrompt(prompt, allowCommit) {
1539
+ if (allowCommit) return prompt
1540
+ return `${prompt}\n\n[ВАЖНО: в това repo НЕ ти е позволено да правиш git commit или git push. Остави промените некомитнати — човекът ги преглежда и комитва сам.]`
1541
+ }
1542
+
1543
+ // Run a headless AI session for an `ai_run` command and stream its output back.
1544
+ // Long-running and fire-and-forget: it wires up async handlers and returns
1545
+ // immediately so the agent's snapshot loop is never blocked. The engine is
1546
+ // whichever CLI the task's project picked — Claude Code or Codex (gd-491); the
1547
+ // streaming/exit machinery below is shared, only the spawn + parser differ.
1202
1548
  function runAiCommand(cfg, cmd, repoPath) {
1203
1549
  const runId = cmd.payload?.runId
1204
1550
  const prompt = cmd.payload?.prompt ?? ''
1205
1551
  const allowCommit = cmd.payload?.allowCommit === true
1206
1552
  const model = aiModelArg(cmd.payload?.model)
1207
- // gd-466: a token-reset resume asks us to continue claude's own conversation.
1553
+ const provider = aiProviderArg(cmd.payload?.provider)
1554
+ const isCodex = provider === 'codex'
1555
+ // gd-466: a token-reset resume asks us to continue the CLI's own conversation.
1208
1556
  const resumeId = cmd.payload?.resume || null
1209
1557
  if (!runId || !prompt) {
1210
1558
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
1211
1559
  return
1212
1560
  }
1213
1561
 
1214
- const { path: claudePath, shell, found } = findClaude()
1562
+ const { path: cliPath, shell, found } = isCodex ? findCodex() : findClaude()
1215
1563
  if (!found) {
1216
- const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1217
- log(`✗ ai_run ${runId}: claude not found`)
1218
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', 'claude not found')
1219
- reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
1564
+ const msg = cliNotFoundMessage(provider, cfg.hostname)
1565
+ log(`✗ ai_run ${runId}: ${provider} not found`)
1566
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', `${provider} not found`)
1567
+ reportCommandResult(cfg, cmd.id, 'error', `${provider} not found`)
1220
1568
  return
1221
1569
  }
1222
1570
 
1571
+ // Claude-only: the PostToolUse hook + MCP config files. Codex reports tool
1572
+ // output inline in its own stream and takes MCP via `-c` overrides.
1223
1573
  let settingsPath
1224
- try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
1225
1574
  let mcpConfigPath
1226
- try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1575
+ if (!isCodex) {
1576
+ try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
1577
+ try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1578
+ }
1227
1579
 
1228
1580
  // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
1229
1581
  // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
1230
1582
  // mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
1231
1583
  // it), so Claude received no prompt and fell back to an empty stdin
1232
1584
  // ("no stdin data received…"), then ran blind off whatever was in the repo.
1233
- // Piping it to stdin is robust for both the native exe and the shim.
1234
- const args = [
1235
- '-p',
1236
- '--output-format', 'stream-json',
1237
- '--verbose',
1238
- // Stream text token-by-token + thinking deltas so the terminal shows the
1239
- // reply being written live, ред по ред, not in whole-block batches (gd-419).
1240
- '--include-partial-messages',
1241
- // gd-466: resume claude's own prior conversation for this task when we're
1242
- // continuing after a token reset it keeps its context/todo. A stale/missing
1243
- // session errors out fast; the watchdog then re-dispatches fresh (no resume).
1244
- ...(resumeId ? ['--resume', resumeId] : []),
1245
- // Model chosen in gitDone (project default / task); omitted machine default (gd-354).
1246
- ...(model ? ['--model', model] : []),
1247
- '--permission-mode', 'acceptEdits',
1248
- '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1249
- // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1250
- ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1251
- ...(settingsPath ? ['--settings', settingsPath] : []),
1252
- // Our own gitdone MCP, tagged with this machine (gd-308).
1253
- ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1254
- ]
1585
+ // Piping it to stdin is robust for both the native exe and the shim; `codex
1586
+ // exec -` reads its prompt from stdin the same way.
1587
+ const args = isCodex
1588
+ ? codexExecArgs(cfg, { model, resumeId })
1589
+ : [
1590
+ '-p',
1591
+ '--output-format', 'stream-json',
1592
+ '--verbose',
1593
+ // Stream text token-by-token + thinking deltas so the terminal shows the
1594
+ // reply being written live, ред по ред, not in whole-block batches (gd-419).
1595
+ '--include-partial-messages',
1596
+ // gd-466: resume claude's own prior conversation for this task when we're
1597
+ // continuing after a token reset it keeps its context/todo. A stale/missing
1598
+ // session errors out fast; the watchdog then re-dispatches fresh (no resume).
1599
+ ...(resumeId ? ['--resume', resumeId] : []),
1600
+ // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1601
+ ...(model ? ['--model', model] : []),
1602
+ '--permission-mode', 'acceptEdits',
1603
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1604
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1605
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1606
+ ...(settingsPath ? ['--settings', settingsPath] : []),
1607
+ // Our own gitdone MCP, tagged with this machine (gd-308).
1608
+ ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1609
+ ]
1610
+
1611
+ const parseLine = isCodex ? parseCodexLine : parseStreamLine
1255
1612
 
1256
1613
  // The PostToolUse hook reads these from its env to post raw tool output.
1614
+ // GITDONE_KEY doubles as the bearer token for Codex's MCP client (gd-491).
1257
1615
  const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
1258
1616
 
1259
1617
  // Batch events on a timer so we don't hammer the server per token/line.
@@ -1293,6 +1651,8 @@ function runAiCommand(cfg, cmd, repoPath) {
1293
1651
  if (type === 'thinking-start') { liveActivity = ''; return }
1294
1652
  // Keep only the tail — the freshest thought is what the gray line shows.
1295
1653
  if (type === 'thinking') { liveActivity = (liveActivity + chunk).slice(-4000); return }
1654
+ // Codex resends the whole message-so-far rather than a delta (gd-491).
1655
+ if (type === 'text-replace') { liveText = chunk; return }
1296
1656
  liveText += chunk
1297
1657
  }
1298
1658
  const timer = setInterval(flush, 500)
@@ -1317,12 +1677,12 @@ function runAiCommand(cfg, cmd, repoPath) {
1317
1677
  const onInit = (sid) => { if (sid) capturedSession = sid }
1318
1678
  let stderrBuf = ''
1319
1679
 
1320
- log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
1321
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
1680
+ log(`▶ ai_run ${runId} @ ${repoPath} via ${cliPath} (${provider})`)
1681
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на ${isCodex ? 'Codex (ChatGPT)' : 'Claude Code'} в ${repoPath}…` }], 'running')
1322
1682
 
1323
1683
  let child
1324
1684
  try {
1325
- child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1685
+ child = spawn(cliPath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1326
1686
  } catch (err) {
1327
1687
  clearInterval(timer)
1328
1688
  postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
@@ -1334,7 +1694,7 @@ function runAiCommand(cfg, cmd, repoPath) {
1334
1694
  // Swallow EPIPE in case the process exits before we finish writing.
1335
1695
  if (child.stdin) {
1336
1696
  child.stdin.on('error', () => {})
1337
- try { child.stdin.write(prompt); child.stdin.end() }
1697
+ try { child.stdin.write(isCodex ? codexPrompt(prompt, allowCommit) : prompt); child.stdin.end() }
1338
1698
  catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
1339
1699
  }
1340
1700
 
@@ -1345,14 +1705,14 @@ function runAiCommand(cfg, cmd, repoPath) {
1345
1705
  while ((nl = buf.indexOf('\n')) >= 0) {
1346
1706
  const line = buf.slice(0, nl).trim()
1347
1707
  buf = buf.slice(nl + 1)
1348
- if (line) parseStreamLine(line, push, onInit, onDelta, onMeta)
1708
+ if (line) parseLine(line, push, onInit, onDelta, onMeta)
1349
1709
  }
1350
1710
  })
1351
1711
  child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) { stderrBuf = (stderrBuf + '\n' + s).slice(-8000); push('SYSTEM', s) } })
1352
1712
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1353
1713
  child.on('close', async (code) => {
1354
1714
  clearInterval(timer)
1355
- if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta, onMeta)
1715
+ if (buf.trim()) parseLine(buf.trim(), push, onInit, onDelta, onMeta)
1356
1716
  liveText = '' // run is over — drop any lingering live preview / thought
1357
1717
  liveActivity = ''
1358
1718
  await flush()
@@ -1421,6 +1781,17 @@ async function downloadSessionImages(sessionId, urls) {
1421
1781
  return { dir, paths }
1422
1782
  }
1423
1783
 
1784
+ // Point the AI at the images the user attached: neither CLI renders an image URL
1785
+ // off stdin, so they're downloaded locally and referenced by path for its Read
1786
+ // tool. Returns the prompt unchanged when nothing was attached (or every
1787
+ // download failed), so an image-only turn still says something.
1788
+ function withImageNote(prompt, paths) {
1789
+ if (!paths || paths.length === 0) return prompt
1790
+ const list = paths.map((p) => `- ${p}`).join('\n')
1791
+ const note = `Потребителят прикачи ${paths.length} изображени${paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
1792
+ return prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1793
+ }
1794
+
1424
1795
  // ─── Persistent chat processes (gd-421) ───────────────────────────────────────
1425
1796
  // One long-lived `claude` process per chat session, keyed by sessionId.
1426
1797
  // Spawning a fresh CLI per turn (`claude -p --resume`) cost 5–15s of process
@@ -1624,6 +1995,105 @@ function killTree(child) {
1624
1995
  }
1625
1996
  }
1626
1997
 
1998
+ // Run one turn of a Codex (ChatGPT) chat session (gd-491).
1999
+ //
2000
+ // Unlike Claude, `codex exec` has no persistent stdin protocol — it runs ONE
2001
+ // turn and exits — so the gd-421 process pool doesn't apply: every turn spawns
2002
+ // its own process, continuing the conversation with `codex exec resume <thread>`
2003
+ // (the thread id we captured from the first turn). It still registers in
2004
+ // chatProcs under the same entry shape, so ai_chat_stop and the stuck-turn
2005
+ // sweeper keep working untouched.
2006
+ function runCodexChatTurn(cfg, cmd, repoPath, opts) {
2007
+ const { sessionId, prompt, resumeId, model, allowCommit } = opts
2008
+
2009
+ const { path: codexPath, shell, found } = findCodex()
2010
+ if (!found) {
2011
+ const msg = cliNotFoundMessage('codex', cfg.hostname)
2012
+ log(`✗ ai_chat ${sessionId}: codex not found`)
2013
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
2014
+ reportCommandResult(cfg, cmd.id, 'error', 'codex not found')
2015
+ return
2016
+ }
2017
+
2018
+ const args = codexExecArgs(cfg, { model, resumeId })
2019
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
2020
+
2021
+ let child
2022
+ try {
2023
+ child = spawn(codexPath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
2024
+ } catch (err) {
2025
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
2026
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
2027
+ return
2028
+ }
2029
+
2030
+ const entry = {
2031
+ sessionId, child, busy: true, stopped: false, lastUsedAt: Date.now(),
2032
+ allowCommit, capturedSession: resumeId || null, turn: null,
2033
+ }
2034
+ chatProcs.set(sessionId, entry)
2035
+ ensureChatSweep()
2036
+
2037
+ const push = (kind, text) => {
2038
+ const t = entry.turn
2039
+ if (!t || text == null || String(text) === '') return
2040
+ if (kind === 'TEXT') t.liveText = ''
2041
+ t.liveActivity = ''
2042
+ t.pending.push({ kind, text: String(text) })
2043
+ }
2044
+ const onInit = (sid) => { if (sid && !entry.capturedSession) entry.capturedSession = sid }
2045
+ const onDelta = (chunk, type) => {
2046
+ const t = entry.turn
2047
+ if (!t) return
2048
+ if (type === 'thinking-start') { t.liveActivity = ''; return }
2049
+ if (type === 'thinking') { t.liveActivity = (t.liveActivity + chunk).slice(-4000); return }
2050
+ // Codex resends the whole message-so-far, not a delta — replace, or the
2051
+ // preview would repeat itself as it grows.
2052
+ if (type === 'text-replace') { t.liveText = chunk; return }
2053
+ t.liveText += chunk
2054
+ }
2055
+ const onTurnEnd = () => { finishChatTurn(cfg, entry, { ok: true }) }
2056
+
2057
+ // Open the turn BEFORE writing the prompt so the earliest output is captured.
2058
+ entry.turn = {
2059
+ cmdId: cmd.id, imgDir: opts.imgDir, startedAt: Date.now(), timer: null, flushing: false,
2060
+ pending: [], liveText: '', sentLive: '', liveActivity: '', sentActivity: '', lastPostAt: 0,
2061
+ }
2062
+ entry.turn.timer = setInterval(() => flushChatTurn(cfg, entry), 250)
2063
+
2064
+ if (child.stdin) {
2065
+ child.stdin.on('error', () => {})
2066
+ try { child.stdin.write(codexPrompt(prompt, allowCommit)); child.stdin.end() }
2067
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
2068
+ }
2069
+
2070
+ let buf = ''
2071
+ child.stdout.on('data', (d) => {
2072
+ buf += d.toString()
2073
+ let nl
2074
+ while ((nl = buf.indexOf('\n')) >= 0) {
2075
+ const line = buf.slice(0, nl).trim()
2076
+ buf = buf.slice(nl + 1)
2077
+ if (line) parseCodexLine(line, push, onInit, onDelta, undefined, onTurnEnd)
2078
+ }
2079
+ })
2080
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
2081
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
2082
+ child.on('close', async (code) => {
2083
+ if (chatProcs.get(sessionId) === entry) chatProcs.delete(sessionId)
2084
+ if (buf.trim()) parseCodexLine(buf.trim(), push, onInit, onDelta, undefined, onTurnEnd)
2085
+ // `turn.completed` normally ends the turn just before the process exits, in
2086
+ // which case entry.turn is already null and finishChatTurn no-ops. This is
2087
+ // the fallback for a process that died without one.
2088
+ if (entry.turn) {
2089
+ await finishChatTurn(cfg, entry, entry.stopped ? { stopped: true } : code === 0 ? { ok: true } : { ok: false, code })
2090
+ }
2091
+ log(`■ ai_chat codex ${sessionId} приключи (code ${code})`)
2092
+ })
2093
+
2094
+ log(`▶ ai_chat codex ход ${sessionId} @ ${repoPath} (resume=${resumeId ? 'yes' : 'no'}) via ${codexPath}`)
2095
+ }
2096
+
1627
2097
  // Run one turn of an interactive chat session (gd-421): reuse the session's
1628
2098
  // persistent claude process when it's alive — the message is one stream-json
1629
2099
  // line on its stdin and the reply starts within seconds. Only the FIRST turn
@@ -1636,6 +2106,7 @@ async function runAiChat(cfg, cmd, repoPath) {
1636
2106
  const claudeSessionId = cmd.payload?.claudeSessionId || null
1637
2107
  const allowCommit = cmd.payload?.allowCommit === true
1638
2108
  const model = aiModelArg(cmd.payload?.model)
2109
+ const provider = aiProviderArg(cmd.payload?.provider)
1639
2110
  // A turn may be text-only, image-only, or both — but needs at least one.
1640
2111
  if (!sessionId || (!prompt && images.length === 0)) {
1641
2112
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
@@ -1658,10 +2129,26 @@ async function runAiChat(cfg, cmd, repoPath) {
1658
2129
  return
1659
2130
  }
1660
2131
 
2132
+ // Codex (gd-491): one process per turn, prompt on stdin at spawn — so the
2133
+ // images have to be on disk BEFORE we start it, unlike the Claude path where
2134
+ // the message is written into an already-running process.
2135
+ if (provider === 'codex') {
2136
+ const dl = images.length > 0 ? await downloadSessionImages(sessionId, images) : null
2137
+ runCodexChatTurn(cfg, cmd, repoPath, {
2138
+ sessionId,
2139
+ prompt: withImageNote(prompt, dl?.paths ?? []),
2140
+ imgDir: dl?.dir ?? null,
2141
+ resumeId: claudeSessionId,
2142
+ model,
2143
+ allowCommit,
2144
+ })
2145
+ return
2146
+ }
2147
+
1661
2148
  if (!entry) {
1662
2149
  const { path: claudePath, shell, found } = findClaude()
1663
2150
  if (!found) {
1664
- const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
2151
+ const msg = cliNotFoundMessage('claude', cfg.hostname)
1665
2152
  log(`✗ ai_chat ${sessionId}: claude not found`)
1666
2153
  postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
1667
2154
  reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
@@ -1682,17 +2169,13 @@ async function runAiChat(cfg, cmd, repoPath) {
1682
2169
  }
1683
2170
 
1684
2171
  // Fetch any attached images locally and fold their paths into the prompt so
1685
- // Claude reads them with its Read tool (URLs on stdin don't render).
2172
+ // the AI reads them with its Read tool (URLs on stdin don't render).
1686
2173
  let imgDir = null
1687
2174
  let fullPrompt = prompt
1688
2175
  if (images.length > 0) {
1689
2176
  const dl = await downloadSessionImages(sessionId, images)
1690
2177
  imgDir = dl.dir
1691
- if (dl.paths.length > 0) {
1692
- const list = dl.paths.map((p) => `- ${p}`).join('\n')
1693
- const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
1694
- fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1695
- }
2178
+ fullPrompt = withImageNote(prompt, dl.paths)
1696
2179
  }
1697
2180
 
1698
2181
  // Open the turn BEFORE writing the message, so even the earliest output
@@ -1904,6 +2387,117 @@ async function readClaudeUsage() {
1904
2387
  }
1905
2388
  }
1906
2389
 
2390
+ // ─── Codex (ChatGPT) plan usage (gd-492) ───────────────────────────────────
2391
+ // Codex knows its own rate limits but does NOT put them in the `codex exec
2392
+ // --json` stdout stream (upstream openai/codex#14728). It DOES write them to the
2393
+ // session rollout it keeps on disk:
2394
+ // ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<thread>.jsonl
2395
+ // where `token_count` lines carry:
2396
+ // payload.rate_limits = { primary: { used_percent, window_minutes, resets_at },
2397
+ // secondary: …|null, plan_type, credits, … }
2398
+ // So we read the newest rollout and take its LAST rate_limits record.
2399
+ //
2400
+ // Unlike the Claude numbers — fetched live from Anthropic on every tick — these
2401
+ // are only as fresh as the user's last Codex run, which is why we report the
2402
+ // rollout line's OWN timestamp as observedAt instead of "now". The console can
2403
+ // then say how old they are rather than implying they're live.
2404
+
2405
+ // Newest entry in a dir, by name (the sessions tree is zero-padded YYYY/MM/DD,
2406
+ // so the lexicographic max IS the newest) or by mtime for the files themselves.
2407
+ function newestChild(dir, { dirs }) {
2408
+ let entries
2409
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return [] }
2410
+ const wanted = entries.filter((e) => (dirs ? e.isDirectory() : e.isFile() && e.name.endsWith('.jsonl')))
2411
+ if (dirs) return wanted.map((e) => e.name).sort().reverse().map((n) => join(dir, n))
2412
+ return wanted
2413
+ .map((e) => {
2414
+ const p = join(dir, e.name)
2415
+ try { return { p, t: statSync(p).mtimeMs } } catch { return { p, t: 0 } }
2416
+ })
2417
+ .sort((a, b) => b.t - a.t)
2418
+ .map((x) => x.p)
2419
+ }
2420
+
2421
+ // Rollout files hold the whole transcript, so read only the tail — the newest
2422
+ // token_count is at the end, and a long session can be megabytes.
2423
+ const CODEX_TAIL_BYTES = 256 * 1024
2424
+ function readTail(file, bytes) {
2425
+ const size = statSync(file).size
2426
+ const start = Math.max(0, size - bytes)
2427
+ const len = size - start
2428
+ if (len <= 0) return ''
2429
+ const buf = Buffer.alloc(len)
2430
+ const fd = openSync(file, 'r')
2431
+ try { readSync(fd, buf, 0, len, start) } finally { closeSync(fd) }
2432
+ const text = buf.toString('utf8')
2433
+ // A tail read almost certainly starts mid-line — drop that partial first line
2434
+ // so JSON.parse below isn't fed a fragment.
2435
+ return start > 0 ? text.slice(text.indexOf('\n') + 1) : text
2436
+ }
2437
+
2438
+ // One window of a Codex rate limit → the shape the server stores.
2439
+ function codexWindow(w) {
2440
+ if (!w || typeof w !== 'object') return null
2441
+ const pct = Number(w.used_percent)
2442
+ if (!Number.isFinite(pct)) return null
2443
+ // resets_at is epoch SECONDS here (Claude's endpoint returns an ISO string).
2444
+ const resets = Number(w.resets_at)
2445
+ return {
2446
+ pct: Math.round(pct),
2447
+ windowMin: Number.isFinite(Number(w.window_minutes)) ? Math.round(Number(w.window_minutes)) : null,
2448
+ resetsAt: Number.isFinite(resets) && resets > 0 ? new Date(resets * 1000).toISOString() : null,
2449
+ }
2450
+ }
2451
+
2452
+ let codexUsageCache = { at: 0, data: null }
2453
+ function readCodexUsage() {
2454
+ const now = Date.now()
2455
+ if (codexUsageCache.data && now - codexUsageCache.at < 60_000) return codexUsageCache.data
2456
+ try {
2457
+ const root = join(homedir(), '.codex', 'sessions')
2458
+ if (!existsSync(root)) return null
2459
+ // Descend newest year → month → day. Check a few of the newest day folders,
2460
+ // since the very newest could be empty (a crashed/cleaned run).
2461
+ let dayDirs = []
2462
+ for (const y of newestChild(root, { dirs: true }).slice(0, 2)) {
2463
+ for (const m of newestChild(y, { dirs: true }).slice(0, 2)) {
2464
+ dayDirs.push(...newestChild(m, { dirs: true }).slice(0, 3))
2465
+ if (dayDirs.length >= 3) break
2466
+ }
2467
+ if (dayDirs.length >= 3) break
2468
+ }
2469
+ for (const day of dayDirs.slice(0, 3)) {
2470
+ for (const file of newestChild(day, { dirs: false }).slice(0, 3)) {
2471
+ const lines = readTail(file, CODEX_TAIL_BYTES).split('\n')
2472
+ // Walk backwards: the freshest rate_limits wins.
2473
+ for (let i = lines.length - 1; i >= 0; i--) {
2474
+ const line = lines[i].trim()
2475
+ if (!line || !line.includes('rate_limits')) continue
2476
+ let ev
2477
+ try { ev = JSON.parse(line) } catch { continue }
2478
+ const rl = ev?.payload?.rate_limits
2479
+ if (!rl || typeof rl !== 'object') continue
2480
+ const primary = codexWindow(rl.primary)
2481
+ const secondary = codexWindow(rl.secondary)
2482
+ if (!primary && !secondary) continue
2483
+ const data = {
2484
+ primary,
2485
+ secondary,
2486
+ planType: typeof rl.plan_type === 'string' ? rl.plan_type : null,
2487
+ // When these numbers were actually true — the run that produced them.
2488
+ observedAt: typeof ev.timestamp === 'string' ? ev.timestamp : new Date(statSync(file).mtimeMs).toISOString(),
2489
+ }
2490
+ codexUsageCache = { at: now, data }
2491
+ return data
2492
+ }
2493
+ }
2494
+ }
2495
+ return null
2496
+ } catch {
2497
+ return null // no codex / unreadable rollout — stay quiet, same as Claude's
2498
+ }
2499
+ }
2500
+
1907
2501
  // The discovered repo list barely ever changes, but re-upserting every repo's
1908
2502
  // row on the server each tick is pure idle write load at scale. So we ship the
1909
2503
  // full `repos` list only when the discovered set actually changed, or every
@@ -1917,6 +2511,9 @@ let syncFullCountdown = 0
1917
2511
 
1918
2512
  async function sync(cfg, discovered) {
1919
2513
  const usage = await readClaudeUsage()
2514
+ // gd-492: the ChatGPT side's own limits, so a Codex console shows ITS numbers
2515
+ // instead of Claude's. Both ride along — one machine can run both engines.
2516
+ const codexUsage = readCodexUsage()
1920
2517
  const sig = createHash('sha1').update(JSON.stringify(discovered)).digest('hex')
1921
2518
  const full = sig !== syncRepoSig || syncFullCountdown <= 0
1922
2519
  const data = await api(cfg, '/api/v1/agent/sync', {
@@ -1926,6 +2523,7 @@ async function sync(cfg, discovered) {
1926
2523
  roots: cfg.roots,
1927
2524
  repos: full ? discovered : [],
1928
2525
  ...(usage ? { usage } : {}),
2526
+ ...(codexUsage ? { codexUsage } : {}),
1929
2527
  })
1930
2528
  if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
1931
2529
  else syncFullCountdown--
@@ -2094,6 +2692,9 @@ async function runLoop(cfg) {
2094
2692
  // start so in-place-updated agents get the watchdog without a re-install.
2095
2693
  startHeartbeat()
2096
2694
  ensureWatchdog()
2695
+ // This config just booted an agent, so it is known-good — snapshot it for
2696
+ // readConfig() to fall back on (gd-478).
2697
+ backupConfig(cfg)
2097
2698
  log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
2098
2699
  log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
2099
2700
 
@@ -2169,7 +2770,10 @@ async function main() {
2169
2770
  // No args → running mode (this is how autostart launches us): read config.
2170
2771
  const cfg = readConfig()
2171
2772
  if (!cfg || !cfg.key) {
2172
- console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
2773
+ // Through agent.log, not just stderr the autostart runs hidden, so the
2774
+ // crash log is the only other place this would land and nobody reads it
2775
+ // until someone already suspects the agent (gd-478).
2776
+ log(`✗ няма използваем config в ${CONFIG_PATH} — пусни gitdone-agent --key=... --root=... --install`)
2173
2777
  process.exit(1)
2174
2778
  }
2175
2779
  await runLoop(cfg)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
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": {