gitdone-agent 0.7.7 → 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.
- package/index.js +580 -68
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -13,7 +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,
|
|
16
|
+
openSync, writeSync, fsyncSync, closeSync, readSync,
|
|
17
17
|
} from 'node:fs'
|
|
18
18
|
import { resolve, join } from 'node:path'
|
|
19
19
|
import { homedir, hostname, tmpdir } from 'node:os'
|
|
@@ -28,7 +28,7 @@ import { randomUUID, createHash } from 'node:crypto'
|
|
|
28
28
|
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
29
29
|
// Keep in lockstep with packages/agent/package.json "version" AND
|
|
30
30
|
// src/lib/agentVersion.ts LATEST_AGENT_VERSION.
|
|
31
|
-
const AGENT_VERSION = '0.
|
|
31
|
+
const AGENT_VERSION = '0.8.0'
|
|
32
32
|
|
|
33
33
|
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
34
34
|
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
@@ -229,42 +229,65 @@ function getNodePath() {
|
|
|
229
229
|
return process.execPath
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
// Locate
|
|
233
|
-
//
|
|
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
|
|
234
234
|
// multi-line, non-ASCII prompt as a single argv element with no escaping. An
|
|
235
|
-
// npm shim (
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
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) {
|
|
240
245
|
const tryCmd = (c) => {
|
|
241
246
|
try {
|
|
242
247
|
const out = execSync(c, { encoding: 'utf8' }).trim()
|
|
243
248
|
return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
|
|
244
249
|
} catch { return [] }
|
|
245
250
|
}
|
|
246
|
-
const cands = [...tryCmd(
|
|
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
|
+
}
|
|
247
255
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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() {
|
|
252
264
|
const home = homedir()
|
|
253
265
|
const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
|
|
254
|
-
|
|
266
|
+
return findCli('claude', [
|
|
255
267
|
join(home, '.local', 'bin', 'claude.exe'), // Windows native installer
|
|
256
268
|
join(appData, 'npm', 'claude.cmd'), // Windows npm global shim
|
|
257
269
|
join(home, '.local', 'bin', 'claude'), // macOS/Linux native installer
|
|
258
270
|
'/usr/local/bin/claude', // Homebrew (Intel) / manual install
|
|
259
271
|
'/opt/homebrew/bin/claude', // Homebrew (Apple silicon)
|
|
260
|
-
])
|
|
261
|
-
|
|
262
|
-
}
|
|
272
|
+
])
|
|
273
|
+
}
|
|
263
274
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
])
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
// ─── Single instance (gd-407) ─────────────────────────────────────────────────
|
|
@@ -549,6 +572,8 @@ function runDoctor() {
|
|
|
549
572
|
console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
|
|
550
573
|
const claude = findClaude()
|
|
551
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)'}`)
|
|
552
577
|
if (cfg) {
|
|
553
578
|
console.log(` machineId : ${cfg.machineId}`)
|
|
554
579
|
console.log(` server : ${cfg.url}`)
|
|
@@ -1144,6 +1169,162 @@ function parseStreamLine(line, push, onInit, onDelta, onMeta, onTurnEnd) {
|
|
|
1144
1169
|
}
|
|
1145
1170
|
}
|
|
1146
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
|
+
|
|
1147
1328
|
// Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
|
|
1148
1329
|
// (Bash stdout, edit results, …) to the run's console, plus the settings file
|
|
1149
1330
|
// that registers it. The hook reads GITDONE_* from its env (set per run on the
|
|
@@ -1282,64 +1463,155 @@ function aiModelArg(raw) {
|
|
|
1282
1463
|
return /^[A-Za-z0-9._-]+$/.test(m) ? m : null
|
|
1283
1464
|
}
|
|
1284
1465
|
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
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.
|
|
1288
1548
|
function runAiCommand(cfg, cmd, repoPath) {
|
|
1289
1549
|
const runId = cmd.payload?.runId
|
|
1290
1550
|
const prompt = cmd.payload?.prompt ?? ''
|
|
1291
1551
|
const allowCommit = cmd.payload?.allowCommit === true
|
|
1292
1552
|
const model = aiModelArg(cmd.payload?.model)
|
|
1293
|
-
|
|
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.
|
|
1294
1556
|
const resumeId = cmd.payload?.resume || null
|
|
1295
1557
|
if (!runId || !prompt) {
|
|
1296
1558
|
reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
|
|
1297
1559
|
return
|
|
1298
1560
|
}
|
|
1299
1561
|
|
|
1300
|
-
const { path:
|
|
1562
|
+
const { path: cliPath, shell, found } = isCodex ? findCodex() : findClaude()
|
|
1301
1563
|
if (!found) {
|
|
1302
|
-
const msg =
|
|
1303
|
-
log(`✗ ai_run ${runId}:
|
|
1304
|
-
postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error',
|
|
1305
|
-
reportCommandResult(cfg, cmd.id, 'error',
|
|
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`)
|
|
1306
1568
|
return
|
|
1307
1569
|
}
|
|
1308
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.
|
|
1309
1573
|
let settingsPath
|
|
1310
|
-
try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
|
|
1311
1574
|
let mcpConfigPath
|
|
1312
|
-
|
|
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
|
+
}
|
|
1313
1579
|
|
|
1314
1580
|
// The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
|
|
1315
1581
|
// A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
|
|
1316
1582
|
// mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
|
|
1317
1583
|
// it), so Claude received no prompt and fell back to an empty stdin
|
|
1318
1584
|
// ("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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
|
1341
1612
|
|
|
1342
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).
|
|
1343
1615
|
const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
|
|
1344
1616
|
|
|
1345
1617
|
// Batch events on a timer so we don't hammer the server per token/line.
|
|
@@ -1379,6 +1651,8 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1379
1651
|
if (type === 'thinking-start') { liveActivity = ''; return }
|
|
1380
1652
|
// Keep only the tail — the freshest thought is what the gray line shows.
|
|
1381
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 }
|
|
1382
1656
|
liveText += chunk
|
|
1383
1657
|
}
|
|
1384
1658
|
const timer = setInterval(flush, 500)
|
|
@@ -1403,12 +1677,12 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1403
1677
|
const onInit = (sid) => { if (sid) capturedSession = sid }
|
|
1404
1678
|
let stderrBuf = ''
|
|
1405
1679
|
|
|
1406
|
-
log(`▶ ai_run ${runId} @ ${repoPath} via ${
|
|
1407
|
-
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')
|
|
1408
1682
|
|
|
1409
1683
|
let child
|
|
1410
1684
|
try {
|
|
1411
|
-
child = spawn(
|
|
1685
|
+
child = spawn(cliPath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
|
|
1412
1686
|
} catch (err) {
|
|
1413
1687
|
clearInterval(timer)
|
|
1414
1688
|
postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
|
|
@@ -1420,7 +1694,7 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1420
1694
|
// Swallow EPIPE in case the process exits before we finish writing.
|
|
1421
1695
|
if (child.stdin) {
|
|
1422
1696
|
child.stdin.on('error', () => {})
|
|
1423
|
-
try { child.stdin.write(prompt); child.stdin.end() }
|
|
1697
|
+
try { child.stdin.write(isCodex ? codexPrompt(prompt, allowCommit) : prompt); child.stdin.end() }
|
|
1424
1698
|
catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
|
|
1425
1699
|
}
|
|
1426
1700
|
|
|
@@ -1431,14 +1705,14 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
1431
1705
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
1432
1706
|
const line = buf.slice(0, nl).trim()
|
|
1433
1707
|
buf = buf.slice(nl + 1)
|
|
1434
|
-
if (line)
|
|
1708
|
+
if (line) parseLine(line, push, onInit, onDelta, onMeta)
|
|
1435
1709
|
}
|
|
1436
1710
|
})
|
|
1437
1711
|
child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) { stderrBuf = (stderrBuf + '\n' + s).slice(-8000); push('SYSTEM', s) } })
|
|
1438
1712
|
child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
|
|
1439
1713
|
child.on('close', async (code) => {
|
|
1440
1714
|
clearInterval(timer)
|
|
1441
|
-
if (buf.trim())
|
|
1715
|
+
if (buf.trim()) parseLine(buf.trim(), push, onInit, onDelta, onMeta)
|
|
1442
1716
|
liveText = '' // run is over — drop any lingering live preview / thought
|
|
1443
1717
|
liveActivity = ''
|
|
1444
1718
|
await flush()
|
|
@@ -1507,6 +1781,17 @@ async function downloadSessionImages(sessionId, urls) {
|
|
|
1507
1781
|
return { dir, paths }
|
|
1508
1782
|
}
|
|
1509
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
|
+
|
|
1510
1795
|
// ─── Persistent chat processes (gd-421) ───────────────────────────────────────
|
|
1511
1796
|
// One long-lived `claude` process per chat session, keyed by sessionId.
|
|
1512
1797
|
// Spawning a fresh CLI per turn (`claude -p --resume`) cost 5–15s of process
|
|
@@ -1710,6 +1995,105 @@ function killTree(child) {
|
|
|
1710
1995
|
}
|
|
1711
1996
|
}
|
|
1712
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
|
+
|
|
1713
2097
|
// Run one turn of an interactive chat session (gd-421): reuse the session's
|
|
1714
2098
|
// persistent claude process when it's alive — the message is one stream-json
|
|
1715
2099
|
// line on its stdin and the reply starts within seconds. Only the FIRST turn
|
|
@@ -1722,6 +2106,7 @@ async function runAiChat(cfg, cmd, repoPath) {
|
|
|
1722
2106
|
const claudeSessionId = cmd.payload?.claudeSessionId || null
|
|
1723
2107
|
const allowCommit = cmd.payload?.allowCommit === true
|
|
1724
2108
|
const model = aiModelArg(cmd.payload?.model)
|
|
2109
|
+
const provider = aiProviderArg(cmd.payload?.provider)
|
|
1725
2110
|
// A turn may be text-only, image-only, or both — but needs at least one.
|
|
1726
2111
|
if (!sessionId || (!prompt && images.length === 0)) {
|
|
1727
2112
|
reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
|
|
@@ -1744,10 +2129,26 @@ async function runAiChat(cfg, cmd, repoPath) {
|
|
|
1744
2129
|
return
|
|
1745
2130
|
}
|
|
1746
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
|
+
|
|
1747
2148
|
if (!entry) {
|
|
1748
2149
|
const { path: claudePath, shell, found } = findClaude()
|
|
1749
2150
|
if (!found) {
|
|
1750
|
-
const msg =
|
|
2151
|
+
const msg = cliNotFoundMessage('claude', cfg.hostname)
|
|
1751
2152
|
log(`✗ ai_chat ${sessionId}: claude not found`)
|
|
1752
2153
|
postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
|
|
1753
2154
|
reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
|
|
@@ -1768,17 +2169,13 @@ async function runAiChat(cfg, cmd, repoPath) {
|
|
|
1768
2169
|
}
|
|
1769
2170
|
|
|
1770
2171
|
// Fetch any attached images locally and fold their paths into the prompt so
|
|
1771
|
-
//
|
|
2172
|
+
// the AI reads them with its Read tool (URLs on stdin don't render).
|
|
1772
2173
|
let imgDir = null
|
|
1773
2174
|
let fullPrompt = prompt
|
|
1774
2175
|
if (images.length > 0) {
|
|
1775
2176
|
const dl = await downloadSessionImages(sessionId, images)
|
|
1776
2177
|
imgDir = dl.dir
|
|
1777
|
-
|
|
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
|
-
}
|
|
2178
|
+
fullPrompt = withImageNote(prompt, dl.paths)
|
|
1782
2179
|
}
|
|
1783
2180
|
|
|
1784
2181
|
// Open the turn BEFORE writing the message, so even the earliest output
|
|
@@ -1990,6 +2387,117 @@ async function readClaudeUsage() {
|
|
|
1990
2387
|
}
|
|
1991
2388
|
}
|
|
1992
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
|
+
|
|
1993
2501
|
// The discovered repo list barely ever changes, but re-upserting every repo's
|
|
1994
2502
|
// row on the server each tick is pure idle write load at scale. So we ship the
|
|
1995
2503
|
// full `repos` list only when the discovered set actually changed, or every
|
|
@@ -2003,6 +2511,9 @@ let syncFullCountdown = 0
|
|
|
2003
2511
|
|
|
2004
2512
|
async function sync(cfg, discovered) {
|
|
2005
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()
|
|
2006
2517
|
const sig = createHash('sha1').update(JSON.stringify(discovered)).digest('hex')
|
|
2007
2518
|
const full = sig !== syncRepoSig || syncFullCountdown <= 0
|
|
2008
2519
|
const data = await api(cfg, '/api/v1/agent/sync', {
|
|
@@ -2012,6 +2523,7 @@ async function sync(cfg, discovered) {
|
|
|
2012
2523
|
roots: cfg.roots,
|
|
2013
2524
|
repos: full ? discovered : [],
|
|
2014
2525
|
...(usage ? { usage } : {}),
|
|
2526
|
+
...(codexUsage ? { codexUsage } : {}),
|
|
2015
2527
|
})
|
|
2016
2528
|
if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
|
|
2017
2529
|
else syncFullCountdown--
|