gitdone-agent 0.8.0 → 0.8.3

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 +135 -31
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -10,6 +10,7 @@
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,
@@ -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.8.0'
32
+ const AGENT_VERSION = '0.8.3'
32
33
 
33
34
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
34
35
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -1470,6 +1471,17 @@ function aiProviderArg(raw) {
1470
1471
  return raw === 'codex' ? 'codex' : 'claude'
1471
1472
  }
1472
1473
 
1474
+ // Reasoning effort — how hard the model thinks per turn (gd-507). Both CLIs
1475
+ // take the same five levels, by different spellings (gd-510): Claude Code as
1476
+ // `--effort <level>`, Codex as a `model_reasoning_effort` config override.
1477
+ // Whitelisted rather than pattern-matched, since an unknown level makes either
1478
+ // CLI refuse to start. null (incl. a gitDone older than gd-507) → don't pass
1479
+ // it, and the machine's own setting decides.
1480
+ const AI_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
1481
+ function aiEffortArg(raw) {
1482
+ return typeof raw === 'string' && AI_EFFORT_LEVELS.includes(raw.trim()) ? raw.trim() : null
1483
+ }
1484
+
1473
1485
  function cliNotFoundMessage(provider, hostname) {
1474
1486
  return provider === 'codex'
1475
1487
  ? `Codex CLI (ChatGPT) не е намерен на този компютър (${hostname}). Инсталирай го (npm i -g @openai/codex), влез с ChatGPT акаунт през „codex login", увери се, че „codex" е в PATH, после рестартирай агента.`
@@ -1494,7 +1506,7 @@ function cliNotFoundMessage(provider, hostname) {
1494
1506
  // The commit policy is NOT here: Codex has no tool-level deny list to mirror
1495
1507
  // Claude's --disallowedTools, so it rides along in the prompt (codexPrompt).
1496
1508
  function codexExecArgs(cfg, opts) {
1497
- const { model, resumeId } = opts
1509
+ const { model, effort, resumeId } = opts
1498
1510
  const flags = [
1499
1511
  '--json',
1500
1512
  '--skip-git-repo-check',
@@ -1517,6 +1529,9 @@ function codexExecArgs(cfg, opts) {
1517
1529
  // it, which killed every resumed turn with "unexpected argument".)
1518
1530
  '--dangerously-bypass-approvals-and-sandbox',
1519
1531
  ...(model ? ['--model', model] : []),
1532
+ // Reasoning effort chosen in gitDone (gd-507). There is no flag for it, so
1533
+ // it goes in as a config override like the MCP settings below.
1534
+ ...(effort ? ['-c', `model_reasoning_effort="${effort}"`] : []),
1520
1535
  '-c', `mcp_servers.gitdone.url="${cfg.url}/api/mcp"`,
1521
1536
  '-c', 'mcp_servers.gitdone.bearer_token_env_var="GITDONE_KEY"',
1522
1537
  // Tags the AI agents this run registers with THIS computer (gd-308).
@@ -1550,6 +1565,7 @@ function runAiCommand(cfg, cmd, repoPath) {
1550
1565
  const prompt = cmd.payload?.prompt ?? ''
1551
1566
  const allowCommit = cmd.payload?.allowCommit === true
1552
1567
  const model = aiModelArg(cmd.payload?.model)
1568
+ const effort = aiEffortArg(cmd.payload?.effort)
1553
1569
  const provider = aiProviderArg(cmd.payload?.provider)
1554
1570
  const isCodex = provider === 'codex'
1555
1571
  // gd-466: a token-reset resume asks us to continue the CLI's own conversation.
@@ -1585,7 +1601,7 @@ function runAiCommand(cfg, cmd, repoPath) {
1585
1601
  // Piping it to stdin is robust for both the native exe and the shim; `codex
1586
1602
  // exec -` reads its prompt from stdin the same way.
1587
1603
  const args = isCodex
1588
- ? codexExecArgs(cfg, { model, resumeId })
1604
+ ? codexExecArgs(cfg, { model, effort, resumeId })
1589
1605
  : [
1590
1606
  '-p',
1591
1607
  '--output-format', 'stream-json',
@@ -1599,6 +1615,8 @@ function runAiCommand(cfg, cmd, repoPath) {
1599
1615
  ...(resumeId ? ['--resume', resumeId] : []),
1600
1616
  // Model chosen in gitDone (project default / task); omitted → machine default (gd-354).
1601
1617
  ...(model ? ['--model', model] : []),
1618
+ // Reasoning effort chosen in gitDone (gd-510); omitted → machine default.
1619
+ ...(effort ? ['--effort', effort] : []),
1602
1620
  '--permission-mode', 'acceptEdits',
1603
1621
  '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1604
1622
  // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
@@ -1893,7 +1911,7 @@ async function finishChatTurn(cfg, entry, outcome) {
1893
1911
  // Spawn the persistent claude process for one session and wire its stream
1894
1912
  // handlers once. Turns come and go via entry.turn; the process stays.
1895
1913
  function spawnChatProc(cfg, opts) {
1896
- const { sessionId, repoPath, model, allowCommit, resumeId, claudePath, shell, settingsPath, mcpConfigPath } = opts
1914
+ const { sessionId, repoPath, model, effort, allowCommit, resumeId, claudePath, shell, settingsPath, mcpConfigPath } = opts
1897
1915
 
1898
1916
  // Room in the pool: evict the least-recently-used idle process first.
1899
1917
  if (chatProcs.size >= CHAT_PROC_MAX) {
@@ -1913,6 +1931,9 @@ function spawnChatProc(cfg, opts) {
1913
1931
  // Model resolved for this session (picker / project default); omitted →
1914
1932
  // machine default (gd-354). Kept consistent across the session's turns.
1915
1933
  ...(model ? ['--model', model] : []),
1934
+ // Same for the session's reasoning effort (gd-510) — frozen at spawn, so a
1935
+ // change in gitDone reaches the pooled process on its next respawn.
1936
+ ...(effort ? ['--effort', effort] : []),
1916
1937
  '--permission-mode', 'acceptEdits',
1917
1938
  '--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
1918
1939
  // Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
@@ -2004,7 +2025,7 @@ function killTree(child) {
2004
2025
  // chatProcs under the same entry shape, so ai_chat_stop and the stuck-turn
2005
2026
  // sweeper keep working untouched.
2006
2027
  function runCodexChatTurn(cfg, cmd, repoPath, opts) {
2007
- const { sessionId, prompt, resumeId, model, allowCommit } = opts
2028
+ const { sessionId, prompt, resumeId, model, effort, allowCommit } = opts
2008
2029
 
2009
2030
  const { path: codexPath, shell, found } = findCodex()
2010
2031
  if (!found) {
@@ -2015,7 +2036,7 @@ function runCodexChatTurn(cfg, cmd, repoPath, opts) {
2015
2036
  return
2016
2037
  }
2017
2038
 
2018
- const args = codexExecArgs(cfg, { model, resumeId })
2039
+ const args = codexExecArgs(cfg, { model, effort, resumeId })
2019
2040
  const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
2020
2041
 
2021
2042
  let child
@@ -2106,6 +2127,7 @@ async function runAiChat(cfg, cmd, repoPath) {
2106
2127
  const claudeSessionId = cmd.payload?.claudeSessionId || null
2107
2128
  const allowCommit = cmd.payload?.allowCommit === true
2108
2129
  const model = aiModelArg(cmd.payload?.model)
2130
+ const effort = aiEffortArg(cmd.payload?.effort)
2109
2131
  const provider = aiProviderArg(cmd.payload?.provider)
2110
2132
  // A turn may be text-only, image-only, or both — but needs at least one.
2111
2133
  if (!sessionId || (!prompt && images.length === 0)) {
@@ -2140,6 +2162,7 @@ async function runAiChat(cfg, cmd, repoPath) {
2140
2162
  imgDir: dl?.dir ?? null,
2141
2163
  resumeId: claudeSessionId,
2142
2164
  model,
2165
+ effort,
2143
2166
  allowCommit,
2144
2167
  })
2145
2168
  return
@@ -2159,7 +2182,7 @@ async function runAiChat(cfg, cmd, repoPath) {
2159
2182
  let mcpConfigPath
2160
2183
  try { mcpConfigPath = ensureAiMcpConfig(cfg) } catch (e) { log(`✗ ai mcp config setup failed: ${e.message}`) }
2161
2184
  try {
2162
- entry = spawnChatProc(cfg, { sessionId, repoPath, model, allowCommit, resumeId: claudeSessionId, claudePath, shell, settingsPath, mcpConfigPath })
2185
+ entry = spawnChatProc(cfg, { sessionId, repoPath, model, effort, allowCommit, resumeId: claudeSessionId, claudePath, shell, settingsPath, mcpConfigPath })
2163
2186
  } catch (err) {
2164
2187
  postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
2165
2188
  reportCommandResult(cfg, cmd.id, 'error', err.message)
@@ -2388,19 +2411,14 @@ async function readClaudeUsage() {
2388
2411
  }
2389
2412
 
2390
2413
  // ─── 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.
2414
+ // Ask Codex itself for the ChatGPT account rate limits, instead of guessing from
2415
+ // a previous run's transcript. The app-server API is the same local surface the
2416
+ // clients use for account/rateLimits/read; it returns primary/secondary windows
2417
+ // when the account has both a short and a weekly/general Codex bucket.
2399
2418
  //
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.
2419
+ // If that live read fails, fall back to the last rollout file so old Codex
2420
+ // installs still show something. Rollout values are telemetry only and may not
2421
+ // include every limit the CLI enforces.
2404
2422
 
2405
2423
  // Newest entry in a dir, by name (the sessions tree is zero-padded YYYY/MM/DD,
2406
2424
  // so the lexicographic max IS the newest) or by mtime for the files themselves.
@@ -2435,8 +2453,20 @@ function readTail(file, bytes) {
2435
2453
  return start > 0 ? text.slice(text.indexOf('\n') + 1) : text
2436
2454
  }
2437
2455
 
2438
- // One window of a Codex rate limit → the shape the server stores.
2439
- function codexWindow(w) {
2456
+ function codexAppWindow(w) {
2457
+ if (!w || typeof w !== 'object') return null
2458
+ const pct = Number(w.usedPercent)
2459
+ if (!Number.isFinite(pct)) return null
2460
+ const resets = Number(w.resetsAt)
2461
+ return {
2462
+ pct: Math.round(pct),
2463
+ windowMin: Number.isFinite(Number(w.windowDurationMins)) ? Math.round(Number(w.windowDurationMins)) : null,
2464
+ resetsAt: Number.isFinite(resets) && resets > 0 ? new Date(resets * 1000).toISOString() : null,
2465
+ }
2466
+ }
2467
+
2468
+ // One rollout window of a Codex rate limit → the shape the server stores.
2469
+ function codexRolloutWindow(w) {
2440
2470
  if (!w || typeof w !== 'object') return null
2441
2471
  const pct = Number(w.used_percent)
2442
2472
  if (!Number.isFinite(pct)) return null
@@ -2449,10 +2479,77 @@ function codexWindow(w) {
2449
2479
  }
2450
2480
  }
2451
2481
 
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
2482
+ function codexUsageFromSnapshot(snapshot) {
2483
+ if (!snapshot || typeof snapshot !== 'object') return null
2484
+ const primary = codexAppWindow(snapshot.primary)
2485
+ const secondary = codexAppWindow(snapshot.secondary)
2486
+ if (!primary && !secondary) return null
2487
+ return {
2488
+ primary,
2489
+ secondary,
2490
+ planType: typeof snapshot.planType === 'string' ? snapshot.planType : null,
2491
+ observedAt: new Date().toISOString(),
2492
+ }
2493
+ }
2494
+
2495
+ function readCodexUsageFromAppServer() {
2496
+ return new Promise((resolve) => {
2497
+ const { path: codexPath, shell, found } = findCodex()
2498
+ if (!found) return resolve(null)
2499
+
2500
+ let settled = false
2501
+ let child
2502
+ const finish = (value) => {
2503
+ if (settled) return
2504
+ settled = true
2505
+ clearTimeout(timer)
2506
+ try { child?.kill() } catch { /* best-effort */ }
2507
+ resolve(value)
2508
+ }
2509
+ const timer = setTimeout(() => finish(null), 12_000)
2510
+
2511
+ try {
2512
+ child = spawn(codexPath, ['app-server', '--stdio'], {
2513
+ stdio: ['pipe', 'pipe', 'ignore'],
2514
+ shell,
2515
+ windowsHide: true,
2516
+ })
2517
+ } catch {
2518
+ return finish(null)
2519
+ }
2520
+
2521
+ child.on('error', () => finish(null))
2522
+ child.on('exit', () => finish(null))
2523
+
2524
+ const rl = readline.createInterface({ input: child.stdout })
2525
+ rl.on('line', (line) => {
2526
+ let msg
2527
+ try { msg = JSON.parse(line) } catch { return }
2528
+ if (msg.id !== 2) return
2529
+ if (msg.error) return finish(null)
2530
+ const result = msg.result
2531
+ const byId = result?.rateLimitsByLimitId
2532
+ const snapshot = byId?.codex || result?.rateLimits
2533
+ finish(codexUsageFromSnapshot(snapshot))
2534
+ })
2535
+
2536
+ const send = (message) => {
2537
+ try { child.stdin.write(`${JSON.stringify(message)}\n`) } catch { finish(null) }
2538
+ }
2539
+ send({
2540
+ method: 'initialize',
2541
+ id: 1,
2542
+ params: {
2543
+ clientInfo: { name: 'gitdone-agent', title: 'gitDone Agent', version: AGENT_VERSION },
2544
+ capabilities: { experimentalApi: true },
2545
+ },
2546
+ })
2547
+ send({ method: 'initialized', params: {} })
2548
+ send({ method: 'account/rateLimits/read', id: 2 })
2549
+ })
2550
+ }
2551
+
2552
+ function readCodexUsageFromRollout() {
2456
2553
  try {
2457
2554
  const root = join(homedir(), '.codex', 'sessions')
2458
2555
  if (!existsSync(root)) return null
@@ -2477,18 +2574,16 @@ function readCodexUsage() {
2477
2574
  try { ev = JSON.parse(line) } catch { continue }
2478
2575
  const rl = ev?.payload?.rate_limits
2479
2576
  if (!rl || typeof rl !== 'object') continue
2480
- const primary = codexWindow(rl.primary)
2481
- const secondary = codexWindow(rl.secondary)
2577
+ const primary = codexRolloutWindow(rl.primary)
2578
+ const secondary = codexRolloutWindow(rl.secondary)
2482
2579
  if (!primary && !secondary) continue
2483
- const data = {
2580
+ return {
2484
2581
  primary,
2485
2582
  secondary,
2486
2583
  planType: typeof rl.plan_type === 'string' ? rl.plan_type : null,
2487
2584
  // When these numbers were actually true — the run that produced them.
2488
2585
  observedAt: typeof ev.timestamp === 'string' ? ev.timestamp : new Date(statSync(file).mtimeMs).toISOString(),
2489
2586
  }
2490
- codexUsageCache = { at: now, data }
2491
- return data
2492
2587
  }
2493
2588
  }
2494
2589
  }
@@ -2498,6 +2593,15 @@ function readCodexUsage() {
2498
2593
  }
2499
2594
  }
2500
2595
 
2596
+ let codexUsageCache = { at: 0, data: null }
2597
+ async function readCodexUsage() {
2598
+ const now = Date.now()
2599
+ if (codexUsageCache.data && now - codexUsageCache.at < 60_000) return codexUsageCache.data
2600
+ const data = await readCodexUsageFromAppServer() || readCodexUsageFromRollout()
2601
+ if (data) codexUsageCache = { at: now, data }
2602
+ return data
2603
+ }
2604
+
2501
2605
  // The discovered repo list barely ever changes, but re-upserting every repo's
2502
2606
  // row on the server each tick is pure idle write load at scale. So we ship the
2503
2607
  // full `repos` list only when the discovered set actually changed, or every
@@ -2513,7 +2617,7 @@ async function sync(cfg, discovered) {
2513
2617
  const usage = await readClaudeUsage()
2514
2618
  // gd-492: the ChatGPT side's own limits, so a Codex console shows ITS numbers
2515
2619
  // instead of Claude's. Both ride along — one machine can run both engines.
2516
- const codexUsage = readCodexUsage()
2620
+ const codexUsage = await readCodexUsage()
2517
2621
  const sig = createHash('sha1').update(JSON.stringify(discovered)).digest('hex')
2518
2622
  const full = sig !== syncRepoSig || syncFullCountdown <= 0
2519
2623
  const data = await api(cfg, '/api/v1/agent/sync', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.0",
3
+ "version": "0.8.3",
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": {