gitdone-agent 0.7.7 → 0.8.1

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 +662 -68
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -10,10 +10,11 @@
10
10
  // from ~/.gitdone-agent/config.json, so the autostart entry needs no args.
11
11
 
12
12
  import { execSync, execFileSync, spawn } from 'node:child_process'
13
+ import readline from 'node:readline'
13
14
  import {
14
15
  existsSync, writeFileSync, readFileSync, unlinkSync, renameSync,
15
16
  mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync, statSync,
16
- openSync, writeSync, fsyncSync, closeSync,
17
+ openSync, writeSync, fsyncSync, closeSync, readSync,
17
18
  } from 'node:fs'
18
19
  import { resolve, join } from 'node:path'
19
20
  import { homedir, hostname, tmpdir } from 'node:os'
@@ -28,7 +29,7 @@ import { randomUUID, createHash } from 'node:crypto'
28
29
  // Reported to the server on every sync so the web UI can flag outdated agents.
29
30
  // Keep in lockstep with packages/agent/package.json "version" AND
30
31
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
31
- const AGENT_VERSION = '0.7.7'
32
+ const AGENT_VERSION = '0.8.1'
32
33
 
33
34
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
34
35
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -229,42 +230,65 @@ function getNodePath() {
229
230
  return process.execPath
230
231
  }
231
232
 
232
- // Locate the Claude Code CLI. Prefer a real executable (claude.exe from the
233
- // native installer) so spawn works WITHOUT a shell — that lets us pass a
233
+ // Locate an agentic CLI on this machine. Prefer a real executable (the native
234
+ // installer's .exe) so spawn works WITHOUT a shell — that lets us pass a
234
235
  // multi-line, non-ASCII prompt as a single argv element with no escaping. An
235
- // npm shim (claude.cmd) needs shell:true, where we collapse the prompt to one
236
- // line to survive cmd.exe parsing. Returns `found:false` when nothing was
237
- // located, so callers can show a clear message instead of spawning bare
238
- // `claude` and letting cmd.exe emit "'claude' is not recognized…".
239
- function findClaude() {
236
+ // npm shim (.cmd) needs shell:true, where we collapse the prompt to one line to
237
+ // survive cmd.exe parsing. Returns `found:false` when nothing was located, so
238
+ // callers can show a clear message instead of spawning the bare name and letting
239
+ // cmd.exe emit "'' is not recognized…".
240
+ //
241
+ // `wellKnown` lists install locations to probe directly: the agent is
242
+ // auto-started at login, so its PATH is frozen at that moment — a CLI installed
243
+ // (or a PATH entry added) afterwards is invisible to `where`/`which`. Probing
244
+ // makes a freshly-installed CLI work without a Windows re-login.
245
+ function findCli(bin, wellKnown) {
240
246
  const tryCmd = (c) => {
241
247
  try {
242
248
  const out = execSync(c, { encoding: 'utf8' }).trim()
243
249
  return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
244
250
  } catch { return [] }
245
251
  }
246
- const cands = [...tryCmd('where claude'), ...tryCmd('which claude')]
252
+ const cands = [...tryCmd(`where ${bin}`), ...tryCmd(`which ${bin}`)]
253
+ for (const p of wellKnown) {
254
+ if (existsSync(p) && !cands.includes(p)) cands.push(p)
255
+ }
256
+
257
+ const exe = cands.find((p) => /\.exe$/i.test(p))
258
+ if (exe) return { path: exe, shell: false, found: true }
259
+ if (cands.length) return { path: cands[0], shell: true, found: true }
260
+ return { path: bin, shell: true, found: false }
261
+ }
247
262
 
248
- // The agent is auto-started at login, so its PATH is frozen at that moment —
249
- // a `claude` installed (or a PATH entry added) afterwards is invisible to
250
- // `where`/`which` above. Probe the well-known install locations directly so a
251
- // freshly-installed CLI works without a Windows re-login.
263
+ // Locate the Claude Code CLI.
264
+ function findClaude() {
252
265
  const home = homedir()
253
266
  const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
254
- for (const p of [
267
+ return findCli('claude', [
255
268
  join(home, '.local', 'bin', 'claude.exe'), // Windows native installer
256
269
  join(appData, 'npm', 'claude.cmd'), // Windows npm global shim
257
270
  join(home, '.local', 'bin', 'claude'), // macOS/Linux native installer
258
271
  '/usr/local/bin/claude', // Homebrew (Intel) / manual install
259
272
  '/opt/homebrew/bin/claude', // Homebrew (Apple silicon)
260
- ]) {
261
- if (existsSync(p) && !cands.includes(p)) cands.push(p)
262
- }
273
+ ])
274
+ }
263
275
 
264
- const exe = cands.find((p) => /\.exe$/i.test(p))
265
- if (exe) return { path: exe, shell: false, found: true }
266
- if (cands.length) return { path: cands[0], shell: true, found: true }
267
- return { path: 'claude', shell: true, found: false }
276
+ // Locate the OpenAI Codex CLI — the ChatGPT-side engine (gd-491). Same rules as
277
+ // findClaude; the Codex binary ships as a Rust executable (native installer /
278
+ // cargo / Homebrew) or as the `@openai/codex` npm global.
279
+ function findCodex() {
280
+ const home = homedir()
281
+ const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
282
+ return findCli('codex', [
283
+ join(home, '.codex', 'bin', 'codex.exe'), // Windows native installer
284
+ join(home, '.local', 'bin', 'codex.exe'),
285
+ join(appData, 'npm', 'codex.cmd'), // Windows npm global shim
286
+ join(home, '.codex', 'bin', 'codex'), // macOS/Linux native installer
287
+ join(home, '.local', 'bin', 'codex'),
288
+ join(home, '.cargo', 'bin', 'codex'), // cargo install
289
+ '/usr/local/bin/codex', // Homebrew (Intel) / manual install
290
+ '/opt/homebrew/bin/codex', // Homebrew (Apple silicon)
291
+ ])
268
292
  }
269
293
 
270
294
  // ─── Single instance (gd-407) ─────────────────────────────────────────────────
@@ -549,6 +573,8 @@ function runDoctor() {
549
573
  console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
550
574
  const claude = findClaude()
551
575
  console.log(` claude cli : ${claude.found ? claude.path : 'NOT FOUND — инсталирай от claude.ai/code и рестартирай агента'}`)
576
+ const codex = findCodex()
577
+ console.log(` codex cli : ${codex.found ? codex.path : 'NOT FOUND — нужен само за проекти с АИ = ChatGPT (npm i -g @openai/codex + codex login)'}`)
552
578
  if (cfg) {
553
579
  console.log(` machineId : ${cfg.machineId}`)
554
580
  console.log(` server : ${cfg.url}`)
@@ -1144,6 +1170,162 @@ function parseStreamLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
1144
1170
  }
1145
1171
  }
1146
1172
 
1173
+ // ─── Codex (ChatGPT) event stream (gd-491) ───────────────────────────────────
1174
+ // `codex exec --json` emits JSON Lines with a different vocabulary than Claude's
1175
+ // stream-json, so it gets its own parser behind the SAME callback contract
1176
+ // (push / onInit / onDelta / onMeta / onTurnEnd) — everything downstream (the
1177
+ // console flushers, the run/session posters) then works unchanged.
1178
+ //
1179
+ // Events: thread.started (thread_id — the id we resume with), turn.started,
1180
+ // turn.completed (usage), turn.failed, item.started|updated|completed (the work
1181
+ // itself), error. Item types: agent_message, reasoning, command_execution,
1182
+ // file_change, mcp_tool_call, web_search, todo_list.
1183
+ //
1184
+ // Field access is deliberately forgiving (several spellings per field): the
1185
+ // Codex CLI is explicitly experimental and has renamed stream fields between
1186
+ // releases, and an unknown shape must degrade to "shows less in the console",
1187
+ // never to a crashed turn.
1188
+
1189
+ // Compact one item into the console's TOOL_CALL one-liner.
1190
+ function codexItemText(item) {
1191
+ const type = item?.type
1192
+ if (type === 'command_execution') {
1193
+ const cmd = item.command ?? item.cmd ?? ''
1194
+ const arr = Array.isArray(cmd) ? cmd.join(' ') : String(cmd)
1195
+ return arr ? `Bash ${arr.slice(0, 400)}` : 'Bash'
1196
+ }
1197
+ if (type === 'file_change') {
1198
+ const changes = item.changes ?? item.files ?? []
1199
+ const names = (Array.isArray(changes) ? changes : [])
1200
+ .map((c) => (typeof c === 'string' ? c : c?.path ?? c?.file ?? ''))
1201
+ .filter(Boolean)
1202
+ return names.length ? `Edit ${names.slice(0, 8).join(', ').slice(0, 400)}` : 'Edit'
1203
+ }
1204
+ if (type === 'mcp_tool_call') {
1205
+ const server = item.server ?? item.server_name ?? ''
1206
+ const tool = item.tool ?? item.tool_name ?? item.name ?? ''
1207
+ const args = item.arguments ?? item.args ?? item.input
1208
+ const argStr = args ? ` ${(typeof args === 'string' ? args : JSON.stringify(args)).slice(0, 300)}` : ''
1209
+ return `${[server, tool].filter(Boolean).join('__') || 'mcp'}${argStr}`
1210
+ }
1211
+ if (type === 'web_search') {
1212
+ const q = item.query ?? item.q ?? ''
1213
+ return `WebSearch${q ? ` ${String(q).slice(0, 300)}` : ''}`
1214
+ }
1215
+ if (type === 'todo_list') {
1216
+ const items = item.items ?? item.todos ?? []
1217
+ const names = (Array.isArray(items) ? items : [])
1218
+ .map((t) => (typeof t === 'string' ? t : t?.text ?? t?.title ?? ''))
1219
+ .filter(Boolean)
1220
+ return names.length ? `TodoWrite\n${names.map((n) => `- ${n}`).join('\n').slice(0, 800)}` : 'TodoWrite'
1221
+ }
1222
+ return null
1223
+ }
1224
+
1225
+ // Raw output of a finished tool item — the Codex counterpart of the PostToolUse
1226
+ // hook we register for Claude (Codex has no hook mechanism, but it puts the
1227
+ // output right in the item, which is better).
1228
+ function codexItemResult(item) {
1229
+ if (item?.type === 'command_execution') {
1230
+ const out = item.aggregated_output ?? item.output ?? item.stdout ?? ''
1231
+ const code = item.exit_code ?? item.exitCode
1232
+ const text = String(out || '').slice(0, 2000)
1233
+ if (!text && code == null) return null
1234
+ return `${text}${code != null ? `\n(exit ${code})` : ''}`.trim()
1235
+ }
1236
+ // A failed gitdone MCP call is worth seeing: it's how the AI drives the task,
1237
+ // so silence here would leave the board unmoved with no visible reason.
1238
+ if (item?.type === 'mcp_tool_call' && item.error) {
1239
+ const msg = typeof item.error === 'string' ? item.error : item.error.message ?? JSON.stringify(item.error)
1240
+ return `✗ ${String(msg).slice(0, 500)}`
1241
+ }
1242
+ return null
1243
+ }
1244
+
1245
+ function parseCodexLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
1246
+ let ev
1247
+ try { ev = JSON.parse(line) } catch { return }
1248
+ // Valid JSON that isn't an object (`null`, `[]`, a bare number) must not throw:
1249
+ // this runs inside the child's stdout handler, where an exception is uncaught
1250
+ // and would take down the whole agent process.
1251
+ if (!ev || typeof ev !== 'object') return
1252
+ const type = ev.type ?? ev.event ?? ''
1253
+
1254
+ if (type === 'thread.started') {
1255
+ const id = ev.thread_id ?? ev.threadId ?? ev.id
1256
+ push('SYSTEM', 'Сесия стартирана (Codex).')
1257
+ // The thread id is what `codex exec resume <id>` continues — the Codex
1258
+ // equivalent of claude's session id, stored in the same column.
1259
+ if (id && typeof onInit === 'function') onInit(id)
1260
+ return
1261
+ }
1262
+
1263
+ if (type === 'item.started' || type === 'item.updated' || type === 'item.completed') {
1264
+ const item = ev.item ?? ev
1265
+ const itemType = item?.type
1266
+
1267
+ // The model's own prose. Only the completed item is final; the in-flight
1268
+ // updates drive the live "being typed" preview, like Claude's text deltas.
1269
+ if (itemType === 'agent_message') {
1270
+ const text = item.text ?? item.message ?? item.content ?? ''
1271
+ if (type === 'item.completed') {
1272
+ if (String(text).trim()) push('TEXT', String(text).trim())
1273
+ } else if (typeof onDelta === 'function' && text) {
1274
+ // Codex sends the whole message so far (not a delta) — replace rather
1275
+ // than append, or the preview would repeat itself as it grows.
1276
+ onDelta(String(text), 'text-replace')
1277
+ }
1278
+ return
1279
+ }
1280
+
1281
+ // Reasoning → the gray „какво прави АИ-то" line.
1282
+ if (itemType === 'reasoning') {
1283
+ const text = item.text ?? item.summary ?? item.content ?? ''
1284
+ if (typeof onDelta === 'function' && text) {
1285
+ onDelta('', 'thinking-start')
1286
+ onDelta(String(text), 'thinking')
1287
+ }
1288
+ return
1289
+ }
1290
+
1291
+ // Tools: announce on start, show raw output on completion.
1292
+ if (type === 'item.started') {
1293
+ const text = codexItemText(item)
1294
+ if (text) push('TOOL_CALL', text)
1295
+ } else if (type === 'item.completed') {
1296
+ const result = codexItemResult(item)
1297
+ if (result) push('TOOL_RESULT', result)
1298
+ }
1299
+ return
1300
+ }
1301
+
1302
+ if (type === 'turn.completed') {
1303
+ const u = ev.usage ?? ev.turn?.usage
1304
+ if (u && typeof onMeta === 'function') {
1305
+ onMeta({
1306
+ usage: {
1307
+ inputTokens: u.input_tokens ?? u.inputTokens ?? 0,
1308
+ outputTokens: u.output_tokens ?? u.outputTokens ?? 0,
1309
+ cacheReadTokens: u.cached_input_tokens ?? u.cachedInputTokens ?? 0,
1310
+ cacheCreateTokens: 0,
1311
+ },
1312
+ })
1313
+ }
1314
+ if (typeof onTurnEnd === 'function') onTurnEnd('success')
1315
+ return
1316
+ }
1317
+
1318
+ if (type === 'turn.failed' || type === 'error') {
1319
+ const err = ev.error ?? ev.message ?? ev
1320
+ const text = typeof err === 'string' ? err : err?.message ?? JSON.stringify(err).slice(0, 500)
1321
+ push('SYSTEM', `✗ ${text}`)
1322
+ // Feed the failure text to the usage-limit detector, same as Claude's
1323
+ // result text — a ChatGPT quota stop parks the task the same way.
1324
+ if (typeof onMeta === 'function') onMeta({ subtype: 'error', resultText: text })
1325
+ if (typeof onTurnEnd === 'function') onTurnEnd('error')
1326
+ }
1327
+ }
1328
+
1147
1329
  // Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
1148
1330
  // (Bash stdout, edit results, …) to the run's console, plus the settings file
1149
1331
  // that registers it. The hook reads GITDONE_* from its env (set per run on the
@@ -1282,64 +1464,155 @@ function aiModelArg(raw) {
1282
1464
  return /^[A-Za-z0-9._-]+$/.test(m) ? m : null
1283
1465
  }
1284
1466
 
1285
- // Run a headless Claude Code session for an `ai_run` command and stream its
1286
- // output back. Long-running and fire-and-forget: it wires up async handlers and
1287
- // returns immediately so the agent's snapshot loop is never blocked.
1467
+ // Which CLI a command asks for (gd-491). Anything unknown including the
1468
+ // missing field sent by a gitDone older than gd-491 means Claude, which is
1469
+ // what every run was before this existed.
1470
+ function aiProviderArg(raw) {
1471
+ return raw === 'codex' ? 'codex' : 'claude'
1472
+ }
1473
+
1474
+ function cliNotFoundMessage(provider, hostname) {
1475
+ return provider === 'codex'
1476
+ ? `Codex CLI (ChatGPT) не е намерен на този компютър (${hostname}). Инсталирай го (npm i -g @openai/codex), влез с ChatGPT акаунт през „codex login", увери се, че „codex" е в PATH, после рестартирай агента.`
1477
+ : `Claude Code CLI не е намерен на този компютър (${hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1478
+ }
1479
+
1480
+ // Argument list for one `codex exec` run (gd-491).
1481
+ //
1482
+ // Notes on the choices here:
1483
+ // • `-` makes Codex read the prompt from STDIN — same reason as Claude's `-p`
1484
+ // on stdin: a multi-line Cyrillic prompt survives the npm .cmd shim intact.
1485
+ // • `--json` is the JSONL event stream parseCodexLine consumes.
1486
+ // • `--sandbox workspace-write` matches Claude's `--permission-mode acceptEdits`:
1487
+ // the AI may edit the repo it was pointed at, but not roam the machine.
1488
+ // • `--skip-git-repo-check` — the repo IS a git repo, but a fresh clone/worktree
1489
+ // without a commit would otherwise abort the run.
1490
+ // • MCP: Codex has no `--mcp-config`; the documented route is config overrides,
1491
+ // so we inject the gitdone HTTP MCP server with `-c` dotted keys. The bearer
1492
+ // token is passed by ENV VAR NAME (bearer_token_env_var), so the agent's key
1493
+ // never lands in the process command line where other users could read it.
1494
+ //
1495
+ // The commit policy is NOT here: Codex has no tool-level deny list to mirror
1496
+ // Claude's --disallowedTools, so it rides along in the prompt (codexPrompt).
1497
+ function codexExecArgs(cfg, opts) {
1498
+ const { model, resumeId } = opts
1499
+ const flags = [
1500
+ '--json',
1501
+ '--skip-git-repo-check',
1502
+ // Why the alarming flag: in `codex exec` there is NO approval handler (stdin
1503
+ // carries the prompt, not answers), so EVERY MCP tool call is auto-denied
1504
+ // with "user cancelled MCP tool call" — verified here against codex 0.146,
1505
+ // and tracked upstream as openai/codex#24135 and #16685. Neither
1506
+ // `-a never`, `-c approval_policy="never"` nor a sandbox mode changes it;
1507
+ // this flag is the only thing that lets an MCP call through. Without it the
1508
+ // gitdone MCP is dead weight and a ChatGPT run cannot register its agents or
1509
+ // move its own task — the entire point of dispatching it.
1510
+ //
1511
+ // It is not a step down from how this agent already runs Claude: that path
1512
+ // uses `--permission-mode acceptEdits` with Bash allowed and no OS sandbox,
1513
+ // so both engines have the same reach on the machine. Revisit once the
1514
+ // upstream issue lands — the sandbox is worth having back.
1515
+ //
1516
+ // (`--sandbox` is deliberately NOT used: it exists only on plain `exec`, and
1517
+ // `codex exec resume` — a subcommand with its own smaller flag set — rejects
1518
+ // it, which killed every resumed turn with "unexpected argument".)
1519
+ '--dangerously-bypass-approvals-and-sandbox',
1520
+ ...(model ? ['--model', model] : []),
1521
+ '-c', `mcp_servers.gitdone.url="${cfg.url}/api/mcp"`,
1522
+ '-c', 'mcp_servers.gitdone.bearer_token_env_var="GITDONE_KEY"',
1523
+ // Tags the AI agents this run registers with THIS computer (gd-308).
1524
+ '-c', `mcp_servers.gitdone.http_headers.X-Gitdone-Machine-Id="${cfg.machineId}"`,
1525
+ ]
1526
+ // `-` = read the prompt from stdin, and it must be the LAST positional:
1527
+ // codex exec [OPTIONS] [PROMPT]
1528
+ // codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]
1529
+ // Continuing a thread is how a console session keeps its context across turns,
1530
+ // and how a token-limit park resumes (gd-466 semantics, Codex spelling).
1531
+ return resumeId
1532
+ ? ['exec', 'resume', ...flags, resumeId, '-']
1533
+ : ['exec', ...flags, '-']
1534
+ }
1535
+
1536
+ // Claude gets the repo's commit policy enforced by `--disallowedTools`; Codex
1537
+ // has no equivalent deny list, so the same policy is stated in the prompt. Weaker
1538
+ // (an instruction, not a hard block) — hence it's spelled out unmistakably.
1539
+ function codexPrompt(prompt, allowCommit) {
1540
+ if (allowCommit) return prompt
1541
+ return `${prompt}\n\n[ВАЖНО: в това repo НЕ ти е позволено да правиш git commit или git push. Остави промените некомитнати — човекът ги преглежда и комитва сам.]`
1542
+ }
1543
+
1544
+ // Run a headless AI session for an `ai_run` command and stream its output back.
1545
+ // Long-running and fire-and-forget: it wires up async handlers and returns
1546
+ // immediately so the agent's snapshot loop is never blocked. The engine is
1547
+ // whichever CLI the task's project picked — Claude Code or Codex (gd-491); the
1548
+ // streaming/exit machinery below is shared, only the spawn + parser differ.
1288
1549
  function runAiCommand(cfg, cmd, repoPath) {
1289
1550
  const runId = cmd.payload?.runId
1290
1551
  const prompt = cmd.payload?.prompt ?? ''
1291
1552
  const allowCommit = cmd.payload?.allowCommit === true
1292
1553
  const model = aiModelArg(cmd.payload?.model)
1293
- // gd-466: a token-reset resume asks us to continue claude's own conversation.
1554
+ const provider = aiProviderArg(cmd.payload?.provider)
1555
+ const isCodex = provider === 'codex'
1556
+ // gd-466: a token-reset resume asks us to continue the CLI's own conversation.
1294
1557
  const resumeId = cmd.payload?.resume || null
1295
1558
  if (!runId || !prompt) {
1296
1559
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
1297
1560
  return
1298
1561
  }
1299
1562
 
1300
- const { path: claudePath, shell, found } = findClaude()
1563
+ const { path: cliPath, shell, found } = isCodex ? findCodex() : findClaude()
1301
1564
  if (!found) {
1302
- const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
1303
- log(`✗ ai_run ${runId}: claude not found`)
1304
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', 'claude not found')
1305
- reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
1565
+ const msg = cliNotFoundMessage(provider, cfg.hostname)
1566
+ log(`✗ ai_run ${runId}: ${provider} not found`)
1567
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', `${provider} not found`)
1568
+ reportCommandResult(cfg, cmd.id, 'error', `${provider} not found`)
1306
1569
  return
1307
1570
  }
1308
1571
 
1572
+ // Claude-only: the PostToolUse hook + MCP config files. Codex reports tool
1573
+ // output inline in its own stream and takes MCP via `-c` overrides.
1309
1574
  let settingsPath
1310
- try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
1311
1575
  let mcpConfigPath
1312
- try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1576
+ if (!isCodex) {
1577
+ try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
1578
+ try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
1579
+ }
1313
1580
 
1314
1581
  // The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
1315
1582
  // A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
1316
1583
  // mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
1317
1584
  // it), so Claude received no prompt and fell back to an empty stdin
1318
1585
  // ("no stdin data received…"), then ran blind off whatever was in the repo.
1319
- // Piping it to stdin is robust for both the native exe and the shim.
1320
- const args = [
1321
- '-p',
1322
- '--output-format', 'stream-json',
1323
- '--verbose',
1324
- // Stream text token-by-token + thinking deltas so the terminal shows the
1325
- // reply being written live, ред по ред, not in whole-block batches (gd-419).
1326
- '--include-partial-messages',
1327
- // gd-466: resume claude's own prior conversation for this task when we're
1328
- // continuing after a token reset it keeps its context/todo. A stale/missing
1329
- // session errors out fast; the watchdog then re-dispatches fresh (no resume).
1330
- ...(resumeId ? ['--resume', resumeId] : []),
1331
- // Model chosen in gitDone (project default / task); omitted machine default (gd-354).
1332
- ...(model ? ['--model', model] : []),
1333
- '--permission-mode', 'acceptEdits',
1334
- '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1335
- // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1336
- ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1337
- ...(settingsPath ? ['--settings', settingsPath] : []),
1338
- // Our own gitdone MCP, tagged with this machine (gd-308).
1339
- ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1340
- ]
1586
+ // Piping it to stdin is robust for both the native exe and the shim; `codex
1587
+ // exec -` reads its prompt from stdin the same way.
1588
+ const args = isCodex
1589
+ ? codexExecArgs(cfg, { model, resumeId })
1590
+ : [
1591
+ '-p',
1592
+ '--output-format', 'stream-json',
1593
+ '--verbose',
1594
+ // Stream text token-by-token + thinking deltas so the terminal shows the
1595
+ // reply being written live, ред по ред, not in whole-block batches (gd-419).
1596
+ '--include-partial-messages',
1597
+ // gd-466: resume claude's own prior conversation for this task when we're
1598
+ // continuing after a token reset it keeps its context/todo. A stale/missing
1599
+ // session errors out fast; the watchdog then re-dispatches fresh (no resume).
1600
+ ...(resumeId ? ['--resume', resumeId] : []),
1601
+ // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1602
+ ...(model ? ['--model', model] : []),
1603
+ '--permission-mode', 'acceptEdits',
1604
+ '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1605
+ // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
1606
+ ...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
1607
+ ...(settingsPath ? ['--settings', settingsPath] : []),
1608
+ // Our own gitdone MCP, tagged with this machine (gd-308).
1609
+ ...(mcpConfigPath ? ['--mcp-config', mcpConfigPath, '--strict-mcp-config'] : []),
1610
+ ]
1611
+
1612
+ const parseLine = isCodex ? parseCodexLine : parseStreamLine
1341
1613
 
1342
1614
  // The PostToolUse hook reads these from its env to post raw tool output.
1615
+ // GITDONE_KEY doubles as the bearer token for Codex's MCP client (gd-491).
1343
1616
  const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
1344
1617
 
1345
1618
  // Batch events on a timer so we don't hammer the server per token/line.
@@ -1379,6 +1652,8 @@ function runAiCommand(cfg, cmd, repoPath) {
1379
1652
  if (type === 'thinking-start') { liveActivity = ''; return }
1380
1653
  // Keep only the tail — the freshest thought is what the gray line shows.
1381
1654
  if (type === 'thinking') { liveActivity = (liveActivity + chunk).slice(-4000); return }
1655
+ // Codex resends the whole message-so-far rather than a delta (gd-491).
1656
+ if (type === 'text-replace') { liveText = chunk; return }
1382
1657
  liveText += chunk
1383
1658
  }
1384
1659
  const timer = setInterval(flush, 500)
@@ -1403,12 +1678,12 @@ function runAiCommand(cfg, cmd, repoPath) {
1403
1678
  const onInit = (sid) => { if (sid) capturedSession = sid }
1404
1679
  let stderrBuf = ''
1405
1680
 
1406
- log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
1407
- postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
1681
+ log(`▶ ai_run ${runId} @ ${repoPath} via ${cliPath} (${provider})`)
1682
+ postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на ${isCodex ? 'Codex (ChatGPT)' : 'Claude Code'} в ${repoPath}…` }], 'running')
1408
1683
 
1409
1684
  let child
1410
1685
  try {
1411
- child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1686
+ child = spawn(cliPath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
1412
1687
  } catch (err) {
1413
1688
  clearInterval(timer)
1414
1689
  postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
@@ -1420,7 +1695,7 @@ function runAiCommand(cfg, cmd, repoPath) {
1420
1695
  // Swallow EPIPE in case the process exits before we finish writing.
1421
1696
  if (child.stdin) {
1422
1697
  child.stdin.on('error', () => {})
1423
- try { child.stdin.write(prompt); child.stdin.end() }
1698
+ try { child.stdin.write(isCodex ? codexPrompt(prompt, allowCommit) : prompt); child.stdin.end() }
1424
1699
  catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
1425
1700
  }
1426
1701
 
@@ -1431,14 +1706,14 @@ function runAiCommand(cfg, cmd, repoPath) {
1431
1706
  while ((nl = buf.indexOf('\n')) >= 0) {
1432
1707
  const line = buf.slice(0, nl).trim()
1433
1708
  buf = buf.slice(nl + 1)
1434
- if (line) parseStreamLine(line, push, onInit, onDelta, onMeta)
1709
+ if (line) parseLine(line, push, onInit, onDelta, onMeta)
1435
1710
  }
1436
1711
  })
1437
1712
  child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) { stderrBuf = (stderrBuf + '\n' + s).slice(-8000); push('SYSTEM', s) } })
1438
1713
  child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
1439
1714
  child.on('close', async (code) => {
1440
1715
  clearInterval(timer)
1441
- if (buf.trim()) parseStreamLine(buf.trim(), push, onInit, onDelta, onMeta)
1716
+ if (buf.trim()) parseLine(buf.trim(), push, onInit, onDelta, onMeta)
1442
1717
  liveText = '' // run is over — drop any lingering live preview / thought
1443
1718
  liveActivity = ''
1444
1719
  await flush()
@@ -1507,6 +1782,17 @@ async function downloadSessionImages(sessionId, urls) {
1507
1782
  return { dir, paths }
1508
1783
  }
1509
1784
 
1785
+ // Point the AI at the images the user attached: neither CLI renders an image URL
1786
+ // off stdin, so they're downloaded locally and referenced by path for its Read
1787
+ // tool. Returns the prompt unchanged when nothing was attached (or every
1788
+ // download failed), so an image-only turn still says something.
1789
+ function withImageNote(prompt, paths) {
1790
+ if (!paths || paths.length === 0) return prompt
1791
+ const list = paths.map((p) => `- ${p}`).join('\n')
1792
+ const note = `Потребителят прикачи ${paths.length} изображени${paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
1793
+ return prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1794
+ }
1795
+
1510
1796
  // ─── Persistent chat processes (gd-421) ───────────────────────────────────────
1511
1797
  // One long-lived `claude` process per chat session, keyed by sessionId.
1512
1798
  // Spawning a fresh CLI per turn (`claude -p --resume`) cost 5–15s of process
@@ -1710,6 +1996,105 @@ function killTree(child) {
1710
1996
  }
1711
1997
  }
1712
1998
 
1999
+ // Run one turn of a Codex (ChatGPT) chat session (gd-491).
2000
+ //
2001
+ // Unlike Claude, `codex exec` has no persistent stdin protocol — it runs ONE
2002
+ // turn and exits — so the gd-421 process pool doesn't apply: every turn spawns
2003
+ // its own process, continuing the conversation with `codex exec resume <thread>`
2004
+ // (the thread id we captured from the first turn). It still registers in
2005
+ // chatProcs under the same entry shape, so ai_chat_stop and the stuck-turn
2006
+ // sweeper keep working untouched.
2007
+ function runCodexChatTurn(cfg, cmd, repoPath, opts) {
2008
+ const { sessionId, prompt, resumeId, model, allowCommit } = opts
2009
+
2010
+ const { path: codexPath, shell, found } = findCodex()
2011
+ if (!found) {
2012
+ const msg = cliNotFoundMessage('codex', cfg.hostname)
2013
+ log(`✗ ai_chat ${sessionId}: codex not found`)
2014
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
2015
+ reportCommandResult(cfg, cmd.id, 'error', 'codex not found')
2016
+ return
2017
+ }
2018
+
2019
+ const args = codexExecArgs(cfg, { model, resumeId })
2020
+ const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
2021
+
2022
+ let child
2023
+ try {
2024
+ child = spawn(codexPath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
2025
+ } catch (err) {
2026
+ postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
2027
+ reportCommandResult(cfg, cmd.id, 'error', err.message)
2028
+ return
2029
+ }
2030
+
2031
+ const entry = {
2032
+ sessionId, child, busy: true, stopped: false, lastUsedAt: Date.now(),
2033
+ allowCommit, capturedSession: resumeId || null, turn: null,
2034
+ }
2035
+ chatProcs.set(sessionId, entry)
2036
+ ensureChatSweep()
2037
+
2038
+ const push = (kind, text) => {
2039
+ const t = entry.turn
2040
+ if (!t || text == null || String(text) === '') return
2041
+ if (kind === 'TEXT') t.liveText = ''
2042
+ t.liveActivity = ''
2043
+ t.pending.push({ kind, text: String(text) })
2044
+ }
2045
+ const onInit = (sid) => { if (sid && !entry.capturedSession) entry.capturedSession = sid }
2046
+ const onDelta = (chunk, type) => {
2047
+ const t = entry.turn
2048
+ if (!t) return
2049
+ if (type === 'thinking-start') { t.liveActivity = ''; return }
2050
+ if (type === 'thinking') { t.liveActivity = (t.liveActivity + chunk).slice(-4000); return }
2051
+ // Codex resends the whole message-so-far, not a delta — replace, or the
2052
+ // preview would repeat itself as it grows.
2053
+ if (type === 'text-replace') { t.liveText = chunk; return }
2054
+ t.liveText += chunk
2055
+ }
2056
+ const onTurnEnd = () => { finishChatTurn(cfg, entry, { ok: true }) }
2057
+
2058
+ // Open the turn BEFORE writing the prompt so the earliest output is captured.
2059
+ entry.turn = {
2060
+ cmdId: cmd.id, imgDir: opts.imgDir, startedAt: Date.now(), timer: null, flushing: false,
2061
+ pending: [], liveText: '', sentLive: '', liveActivity: '', sentActivity: '', lastPostAt: 0,
2062
+ }
2063
+ entry.turn.timer = setInterval(() => flushChatTurn(cfg, entry), 250)
2064
+
2065
+ if (child.stdin) {
2066
+ child.stdin.on('error', () => {})
2067
+ try { child.stdin.write(codexPrompt(prompt, allowCommit)); child.stdin.end() }
2068
+ catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
2069
+ }
2070
+
2071
+ let buf = ''
2072
+ child.stdout.on('data', (d) => {
2073
+ buf += d.toString()
2074
+ let nl
2075
+ while ((nl = buf.indexOf('\n')) >= 0) {
2076
+ const line = buf.slice(0, nl).trim()
2077
+ buf = buf.slice(nl + 1)
2078
+ if (line) parseCodexLine(line, push, onInit, onDelta, undefined, onTurnEnd)
2079
+ }
2080
+ })
2081
+ child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
2082
+ child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
2083
+ child.on('close', async (code) => {
2084
+ if (chatProcs.get(sessionId) === entry) chatProcs.delete(sessionId)
2085
+ if (buf.trim()) parseCodexLine(buf.trim(), push, onInit, onDelta, undefined, onTurnEnd)
2086
+ // `turn.completed` normally ends the turn just before the process exits, in
2087
+ // which case entry.turn is already null and finishChatTurn no-ops. This is
2088
+ // the fallback for a process that died without one.
2089
+ if (entry.turn) {
2090
+ await finishChatTurn(cfg, entry, entry.stopped ? { stopped: true } : code === 0 ? { ok: true } : { ok: false, code })
2091
+ }
2092
+ log(`■ ai_chat codex ${sessionId} приключи (code ${code})`)
2093
+ })
2094
+
2095
+ log(`▶ ai_chat codex ход ${sessionId} @ ${repoPath} (resume=${resumeId ? 'yes' : 'no'}) via ${codexPath}`)
2096
+ }
2097
+
1713
2098
  // Run one turn of an interactive chat session (gd-421): reuse the session's
1714
2099
  // persistent claude process when it's alive — the message is one stream-json
1715
2100
  // line on its stdin and the reply starts within seconds. Only the FIRST turn
@@ -1722,6 +2107,7 @@ async function runAiChat(cfg, cmd, repoPath) {
1722
2107
  const claudeSessionId = cmd.payload?.claudeSessionId || null
1723
2108
  const allowCommit = cmd.payload?.allowCommit === true
1724
2109
  const model = aiModelArg(cmd.payload?.model)
2110
+ const provider = aiProviderArg(cmd.payload?.provider)
1725
2111
  // A turn may be text-only, image-only, or both — but needs at least one.
1726
2112
  if (!sessionId || (!prompt && images.length === 0)) {
1727
2113
  reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
@@ -1744,10 +2130,26 @@ async function runAiChat(cfg, cmd, repoPath) {
1744
2130
  return
1745
2131
  }
1746
2132
 
2133
+ // Codex (gd-491): one process per turn, prompt on stdin at spawn — so the
2134
+ // images have to be on disk BEFORE we start it, unlike the Claude path where
2135
+ // the message is written into an already-running process.
2136
+ if (provider === 'codex') {
2137
+ const dl = images.length > 0 ? await downloadSessionImages(sessionId, images) : null
2138
+ runCodexChatTurn(cfg, cmd, repoPath, {
2139
+ sessionId,
2140
+ prompt: withImageNote(prompt, dl?.paths ?? []),
2141
+ imgDir: dl?.dir ?? null,
2142
+ resumeId: claudeSessionId,
2143
+ model,
2144
+ allowCommit,
2145
+ })
2146
+ return
2147
+ }
2148
+
1747
2149
  if (!entry) {
1748
2150
  const { path: claudePath, shell, found } = findClaude()
1749
2151
  if (!found) {
1750
- const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
2152
+ const msg = cliNotFoundMessage('claude', cfg.hostname)
1751
2153
  log(`✗ ai_chat ${sessionId}: claude not found`)
1752
2154
  postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
1753
2155
  reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
@@ -1768,17 +2170,13 @@ async function runAiChat(cfg, cmd, repoPath) {
1768
2170
  }
1769
2171
 
1770
2172
  // Fetch any attached images locally and fold their paths into the prompt so
1771
- // Claude reads them with its Read tool (URLs on stdin don't render).
2173
+ // the AI reads them with its Read tool (URLs on stdin don't render).
1772
2174
  let imgDir = null
1773
2175
  let fullPrompt = prompt
1774
2176
  if (images.length > 0) {
1775
2177
  const dl = await downloadSessionImages(sessionId, images)
1776
2178
  imgDir = dl.dir
1777
- if (dl.paths.length > 0) {
1778
- const list = dl.paths.map((p) => `- ${p}`).join('\n')
1779
- const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
1780
- fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
1781
- }
2179
+ fullPrompt = withImageNote(prompt, dl.paths)
1782
2180
  }
1783
2181
 
1784
2182
  // Open the turn BEFORE writing the message, so even the earliest output
@@ -1990,6 +2388,198 @@ async function readClaudeUsage() {
1990
2388
  }
1991
2389
  }
1992
2390
 
2391
+ // ─── Codex (ChatGPT) plan usage (gd-492) ───────────────────────────────────
2392
+ // Ask Codex itself for the ChatGPT account rate limits, instead of guessing from
2393
+ // a previous run's transcript. The app-server API is the same local surface the
2394
+ // clients use for account/rateLimits/read; it returns primary/secondary windows
2395
+ // when the account has both a short and a weekly/general Codex bucket.
2396
+ //
2397
+ // If that live read fails, fall back to the last rollout file so old Codex
2398
+ // installs still show something. Rollout values are telemetry only and may not
2399
+ // include every limit the CLI enforces.
2400
+
2401
+ // Newest entry in a dir, by name (the sessions tree is zero-padded YYYY/MM/DD,
2402
+ // so the lexicographic max IS the newest) or by mtime for the files themselves.
2403
+ function newestChild(dir, { dirs }) {
2404
+ let entries
2405
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return [] }
2406
+ const wanted = entries.filter((e) => (dirs ? e.isDirectory() : e.isFile() && e.name.endsWith('.jsonl')))
2407
+ if (dirs) return wanted.map((e) => e.name).sort().reverse().map((n) => join(dir, n))
2408
+ return wanted
2409
+ .map((e) => {
2410
+ const p = join(dir, e.name)
2411
+ try { return { p, t: statSync(p).mtimeMs } } catch { return { p, t: 0 } }
2412
+ })
2413
+ .sort((a, b) => b.t - a.t)
2414
+ .map((x) => x.p)
2415
+ }
2416
+
2417
+ // Rollout files hold the whole transcript, so read only the tail — the newest
2418
+ // token_count is at the end, and a long session can be megabytes.
2419
+ const CODEX_TAIL_BYTES = 256 * 1024
2420
+ function readTail(file, bytes) {
2421
+ const size = statSync(file).size
2422
+ const start = Math.max(0, size - bytes)
2423
+ const len = size - start
2424
+ if (len <= 0) return ''
2425
+ const buf = Buffer.alloc(len)
2426
+ const fd = openSync(file, 'r')
2427
+ try { readSync(fd, buf, 0, len, start) } finally { closeSync(fd) }
2428
+ const text = buf.toString('utf8')
2429
+ // A tail read almost certainly starts mid-line — drop that partial first line
2430
+ // so JSON.parse below isn't fed a fragment.
2431
+ return start > 0 ? text.slice(text.indexOf('\n') + 1) : text
2432
+ }
2433
+
2434
+ function codexAppWindow(w) {
2435
+ if (!w || typeof w !== 'object') return null
2436
+ const pct = Number(w.usedPercent)
2437
+ if (!Number.isFinite(pct)) return null
2438
+ const resets = Number(w.resetsAt)
2439
+ return {
2440
+ pct: Math.round(pct),
2441
+ windowMin: Number.isFinite(Number(w.windowDurationMins)) ? Math.round(Number(w.windowDurationMins)) : null,
2442
+ resetsAt: Number.isFinite(resets) && resets > 0 ? new Date(resets * 1000).toISOString() : null,
2443
+ }
2444
+ }
2445
+
2446
+ // One rollout window of a Codex rate limit → the shape the server stores.
2447
+ function codexRolloutWindow(w) {
2448
+ if (!w || typeof w !== 'object') return null
2449
+ const pct = Number(w.used_percent)
2450
+ if (!Number.isFinite(pct)) return null
2451
+ // resets_at is epoch SECONDS here (Claude's endpoint returns an ISO string).
2452
+ const resets = Number(w.resets_at)
2453
+ return {
2454
+ pct: Math.round(pct),
2455
+ windowMin: Number.isFinite(Number(w.window_minutes)) ? Math.round(Number(w.window_minutes)) : null,
2456
+ resetsAt: Number.isFinite(resets) && resets > 0 ? new Date(resets * 1000).toISOString() : null,
2457
+ }
2458
+ }
2459
+
2460
+ function codexUsageFromSnapshot(snapshot) {
2461
+ if (!snapshot || typeof snapshot !== 'object') return null
2462
+ const primary = codexAppWindow(snapshot.primary)
2463
+ const secondary = codexAppWindow(snapshot.secondary)
2464
+ if (!primary && !secondary) return null
2465
+ return {
2466
+ primary,
2467
+ secondary,
2468
+ planType: typeof snapshot.planType === 'string' ? snapshot.planType : null,
2469
+ observedAt: new Date().toISOString(),
2470
+ }
2471
+ }
2472
+
2473
+ function readCodexUsageFromAppServer() {
2474
+ return new Promise((resolve) => {
2475
+ const { path: codexPath, shell, found } = findCodex()
2476
+ if (!found) return resolve(null)
2477
+
2478
+ let settled = false
2479
+ let child
2480
+ const finish = (value) => {
2481
+ if (settled) return
2482
+ settled = true
2483
+ clearTimeout(timer)
2484
+ try { child?.kill() } catch { /* best-effort */ }
2485
+ resolve(value)
2486
+ }
2487
+ const timer = setTimeout(() => finish(null), 12_000)
2488
+
2489
+ try {
2490
+ child = spawn(codexPath, ['app-server', '--stdio'], {
2491
+ stdio: ['pipe', 'pipe', 'ignore'],
2492
+ shell,
2493
+ windowsHide: true,
2494
+ })
2495
+ } catch {
2496
+ return finish(null)
2497
+ }
2498
+
2499
+ child.on('error', () => finish(null))
2500
+ child.on('exit', () => finish(null))
2501
+
2502
+ const rl = readline.createInterface({ input: child.stdout })
2503
+ rl.on('line', (line) => {
2504
+ let msg
2505
+ try { msg = JSON.parse(line) } catch { return }
2506
+ if (msg.id !== 2) return
2507
+ if (msg.error) return finish(null)
2508
+ const result = msg.result
2509
+ const byId = result?.rateLimitsByLimitId
2510
+ const snapshot = byId?.codex || result?.rateLimits
2511
+ finish(codexUsageFromSnapshot(snapshot))
2512
+ })
2513
+
2514
+ const send = (message) => {
2515
+ try { child.stdin.write(`${JSON.stringify(message)}\n`) } catch { finish(null) }
2516
+ }
2517
+ send({
2518
+ method: 'initialize',
2519
+ id: 1,
2520
+ params: {
2521
+ clientInfo: { name: 'gitdone-agent', title: 'gitDone Agent', version: AGENT_VERSION },
2522
+ capabilities: { experimentalApi: true },
2523
+ },
2524
+ })
2525
+ send({ method: 'initialized', params: {} })
2526
+ send({ method: 'account/rateLimits/read', id: 2 })
2527
+ })
2528
+ }
2529
+
2530
+ function readCodexUsageFromRollout() {
2531
+ try {
2532
+ const root = join(homedir(), '.codex', 'sessions')
2533
+ if (!existsSync(root)) return null
2534
+ // Descend newest year → month → day. Check a few of the newest day folders,
2535
+ // since the very newest could be empty (a crashed/cleaned run).
2536
+ let dayDirs = []
2537
+ for (const y of newestChild(root, { dirs: true }).slice(0, 2)) {
2538
+ for (const m of newestChild(y, { dirs: true }).slice(0, 2)) {
2539
+ dayDirs.push(...newestChild(m, { dirs: true }).slice(0, 3))
2540
+ if (dayDirs.length >= 3) break
2541
+ }
2542
+ if (dayDirs.length >= 3) break
2543
+ }
2544
+ for (const day of dayDirs.slice(0, 3)) {
2545
+ for (const file of newestChild(day, { dirs: false }).slice(0, 3)) {
2546
+ const lines = readTail(file, CODEX_TAIL_BYTES).split('\n')
2547
+ // Walk backwards: the freshest rate_limits wins.
2548
+ for (let i = lines.length - 1; i >= 0; i--) {
2549
+ const line = lines[i].trim()
2550
+ if (!line || !line.includes('rate_limits')) continue
2551
+ let ev
2552
+ try { ev = JSON.parse(line) } catch { continue }
2553
+ const rl = ev?.payload?.rate_limits
2554
+ if (!rl || typeof rl !== 'object') continue
2555
+ const primary = codexRolloutWindow(rl.primary)
2556
+ const secondary = codexRolloutWindow(rl.secondary)
2557
+ if (!primary && !secondary) continue
2558
+ return {
2559
+ primary,
2560
+ secondary,
2561
+ planType: typeof rl.plan_type === 'string' ? rl.plan_type : null,
2562
+ // When these numbers were actually true — the run that produced them.
2563
+ observedAt: typeof ev.timestamp === 'string' ? ev.timestamp : new Date(statSync(file).mtimeMs).toISOString(),
2564
+ }
2565
+ }
2566
+ }
2567
+ }
2568
+ return null
2569
+ } catch {
2570
+ return null // no codex / unreadable rollout — stay quiet, same as Claude's
2571
+ }
2572
+ }
2573
+
2574
+ let codexUsageCache = { at: 0, data: null }
2575
+ async function readCodexUsage() {
2576
+ const now = Date.now()
2577
+ if (codexUsageCache.data && now - codexUsageCache.at < 60_000) return codexUsageCache.data
2578
+ const data = await readCodexUsageFromAppServer() || readCodexUsageFromRollout()
2579
+ if (data) codexUsageCache = { at: now, data }
2580
+ return data
2581
+ }
2582
+
1993
2583
  // The discovered repo list barely ever changes, but re-upserting every repo's
1994
2584
  // row on the server each tick is pure idle write load at scale. So we ship the
1995
2585
  // full `repos` list only when the discovered set actually changed, or every
@@ -2003,6 +2593,9 @@ let syncFullCountdown = 0
2003
2593
 
2004
2594
  async function sync(cfg, discovered) {
2005
2595
  const usage = await readClaudeUsage()
2596
+ // gd-492: the ChatGPT side's own limits, so a Codex console shows ITS numbers
2597
+ // instead of Claude's. Both ride along — one machine can run both engines.
2598
+ const codexUsage = await readCodexUsage()
2006
2599
  const sig = createHash('sha1').update(JSON.stringify(discovered)).digest('hex')
2007
2600
  const full = sig !== syncRepoSig || syncFullCountdown <= 0
2008
2601
  const data = await api(cfg, '/api/v1/agent/sync', {
@@ -2012,6 +2605,7 @@ async function sync(cfg, discovered) {
2012
2605
  roots: cfg.roots,
2013
2606
  repos: full ? discovered : [],
2014
2607
  ...(usage ? { usage } : {}),
2608
+ ...(codexUsage ? { codexUsage } : {}),
2015
2609
  })
2016
2610
  if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
2017
2611
  else syncFullCountdown--
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.7",
3
+ "version": "0.8.1",
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": {