@yul-labs/agent-relay 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -103,7 +103,9 @@ prompt ──> spawn agent in a PTY (its real TUI) ──> watch terminal output
103
103
  Codex with `-s workspace-write -a never`. The agent rarely asks — but the
104
104
  prompts that *still* appear (the directory-trust dialog, the occasional choice)
105
105
  are what the Decider answers. `approvalPolicy: "gated"` makes the agent ask
106
- more (Claude `--permission-mode acceptEdits`, Codex `-a on-request`);
106
+ more — and routes those asks to the Decider — via Claude `--permission-mode
107
+ default` (its normal "ask before each edit/command" mode; **not** `acceptEdits`,
108
+ which would silently auto-approve edits) and Codex `-a on-request`;
107
109
  `"readonly"` sandboxes it (Claude `--permission-mode plan`, Codex `-s read-only`).
108
110
  2. When the terminal goes idle, agent-relay strips ANSI and **detects** a prompt:
109
111
  a numbered/▶ **menu** (single choice), a **multi-select** menu (checkboxes
@@ -144,6 +146,31 @@ arg of the keymap, so a TUI that submits with Enter-on-a-Next-row can be retuned
144
146
  The `rule` decider keeps the current selection and submits (no judgment); an LLM
145
147
  decider picks the task-relevant rows.
146
148
 
149
+ ### Getting a result back (what you can and can't read)
150
+
151
+ agent-relay's job is to **drive** the agent and **answer its prompts** — not to
152
+ recover the agent's prose answer from the screen. The TUI text is heavily mangled
153
+ after ANSI stripping (words run together, spinners interleave), so **scraping a
154
+ structured answer out of stdout is not reliable** and is deliberately not
155
+ attempted. Two things you *can* rely on:
156
+
157
+ - **For a structured result, have the agent WRITE IT TO A FILE.** Put it in the
158
+ prompt — e.g. *"...and write the final JSON to `result.json`"* — then read that
159
+ file after the run. This is the validated pattern for getting data back out.
160
+ - **Token usage is surfaced** as `result.meta.usage`, read from the agent's OWN
161
+ session transcript (`~/.claude/projects/…` for Claude, `~/.codex/sessions/…` for
162
+ Codex) — so it works on every machine **regardless of TUI / status-line
163
+ settings**, and the token counts are the API's real numbers, not a scrape.
164
+ Fields: `{ source, model, inputTokens, outputTokens, cachedInputTokens,
165
+ cacheCreationTokens, reasoningTokens, totalTokens }`. `contextPercent` and
166
+ `sessionCostUsd` are best-effort *extras* scraped from the status line only when
167
+ it is enabled (cost reads `0` on subscription/Team billing). `source` is
168
+ `"transcript"` (authoritative) or `"status-line"` (scrape) so you know which.
169
+
170
+ The proper long-term fix for the *answer text* is the **structured-first** path
171
+ (Claude `--input-format stream-json` / Codex's JSON protocol) — see *Remaining
172
+ TODO*.
173
+
147
174
  ## The Decider
148
175
 
149
176
  The Decider answers every detected prompt. Choose it in config (`decider`) or
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import fs6, { promises, constants, statSync, chmodSync } from 'fs';
4
- import path5 from 'path';
3
+ import fs7, { promises, constants, statSync, chmodSync } from 'fs';
4
+ import path6 from 'path';
5
5
  import { z } from 'zod';
6
- import { execa } from 'execa';
7
6
  import os from 'os';
7
+ import { execa } from 'execa';
8
8
  import * as nodePty from 'node-pty';
9
9
  import { createRequire } from 'module';
10
10
  import { randomUUID } from 'crypto';
@@ -185,7 +185,7 @@ ${detail}`,
185
185
  return result.data;
186
186
  }
187
187
  function configPath(rootDir) {
188
- return path5.resolve(rootDir, CONFIG_FILENAME);
188
+ return path6.resolve(rootDir, CONFIG_FILENAME);
189
189
  }
190
190
  async function loadConfig(rootDir) {
191
191
  const file = configPath(rootDir);
@@ -225,7 +225,7 @@ function stringifyConfig(config) {
225
225
  }
226
226
  async function saveConfig(rootDir, config) {
227
227
  const file = configPath(rootDir);
228
- await promises.mkdir(path5.dirname(file), { recursive: true });
228
+ await promises.mkdir(path6.dirname(file), { recursive: true });
229
229
  await promises.writeFile(file, stringifyConfig(config), "utf8");
230
230
  return file;
231
231
  }
@@ -240,7 +240,7 @@ async function dirExists(p) {
240
240
  }
241
241
  }
242
242
  async function runInit(options) {
243
- const rootDir = path5.resolve(options.rootDir);
243
+ const rootDir = path6.resolve(options.rootDir);
244
244
  const cfgPath = configPath(rootDir);
245
245
  let configCreated = false;
246
246
  let configExists = false;
@@ -255,8 +255,8 @@ async function runInit(options) {
255
255
  configCreated = true;
256
256
  }
257
257
  const config = await loadConfig(rootDir);
258
- const sessionsDir = path5.resolve(rootDir, config.sessionsDir);
259
- const logsDir = path5.resolve(rootDir, config.logsDir);
258
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
259
+ const logsDir = path6.resolve(rootDir, config.logsDir);
260
260
  const createdDirs = [];
261
261
  for (const dir of [sessionsDir, logsDir]) {
262
262
  if (!await dirExists(dir)) {
@@ -272,6 +272,100 @@ async function runInit(options) {
272
272
  createdDirs
273
273
  };
274
274
  }
275
+ async function realish(p) {
276
+ try {
277
+ return await promises.realpath(p);
278
+ } catch {
279
+ return p;
280
+ }
281
+ }
282
+ function projectDirCandidates(absCwd) {
283
+ return [absCwd.replace(/[/.]/g, "-"), absCwd.replace(/\//g, "-")];
284
+ }
285
+ async function resolveProjectDir(root, cwd) {
286
+ const abs = await realish(cwd);
287
+ for (const name of projectDirCandidates(abs)) {
288
+ const dir = path6.join(root, name);
289
+ try {
290
+ const st = await promises.stat(dir);
291
+ if (st.isDirectory()) return dir;
292
+ } catch {
293
+ }
294
+ }
295
+ return void 0;
296
+ }
297
+ async function findLatestClaudeTranscript(opts) {
298
+ const root = opts.projectsDir ?? path6.join(os.homedir(), ".claude", "projects");
299
+ const since = opts.sinceMs ?? 0;
300
+ const dir = await resolveProjectDir(root, opts.cwd);
301
+ if (!dir) return void 0;
302
+ let files;
303
+ try {
304
+ files = await promises.readdir(dir);
305
+ } catch {
306
+ return void 0;
307
+ }
308
+ const candidates = [];
309
+ for (const f of files) {
310
+ if (!f.endsWith(".jsonl")) continue;
311
+ const file = path6.join(dir, f);
312
+ try {
313
+ const st = await promises.stat(file);
314
+ if (st.mtimeMs + 2e3 < since) continue;
315
+ candidates.push({ file, mtimeMs: st.mtimeMs });
316
+ } catch {
317
+ }
318
+ }
319
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
320
+ return candidates[0]?.file;
321
+ }
322
+ async function sumClaudeUsage(file) {
323
+ let text;
324
+ try {
325
+ text = await promises.readFile(file, "utf8");
326
+ } catch {
327
+ return void 0;
328
+ }
329
+ let input = 0;
330
+ let output = 0;
331
+ let cacheCreate = 0;
332
+ let cacheRead = 0;
333
+ let model;
334
+ let any = false;
335
+ for (const line of text.split("\n")) {
336
+ if (!line.trim()) continue;
337
+ let rec;
338
+ try {
339
+ rec = JSON.parse(line);
340
+ } catch {
341
+ continue;
342
+ }
343
+ const u = rec.message?.usage;
344
+ if (!u || typeof u !== "object") continue;
345
+ any = true;
346
+ input += u.input_tokens ?? 0;
347
+ output += u.output_tokens ?? 0;
348
+ cacheCreate += u.cache_creation_input_tokens ?? 0;
349
+ cacheRead += u.cache_read_input_tokens ?? 0;
350
+ if (!model && typeof rec.message?.model === "string")
351
+ model = rec.message.model;
352
+ }
353
+ if (!any) return void 0;
354
+ return {
355
+ source: "transcript",
356
+ model,
357
+ inputTokens: input,
358
+ outputTokens: output,
359
+ cacheCreationTokens: cacheCreate,
360
+ cachedInputTokens: cacheRead,
361
+ totalTokens: input + output + cacheCreate + cacheRead
362
+ };
363
+ }
364
+ async function findLatestClaudeUsage(opts) {
365
+ const file = await findLatestClaudeTranscript(opts);
366
+ if (!file) return void 0;
367
+ return sumClaudeUsage(file);
368
+ }
275
369
 
276
370
  // src/core/util/json.ts
277
371
  function safeJsonParse(text) {
@@ -493,6 +587,7 @@ var CommandDecider = class {
493
587
  }
494
588
  }
495
589
  };
590
+ var MIN_API_MAX_TOKENS = 512;
496
591
  var ApiDecider = class {
497
592
  constructor(opts) {
498
593
  this.opts = opts;
@@ -522,7 +617,7 @@ var ApiDecider = class {
522
617
  body: JSON.stringify({
523
618
  model: this.opts.model ?? "default",
524
619
  messages: [{ role: "user", content: render(req) }],
525
- max_tokens: this.opts.maxTokens ?? 2048,
620
+ max_tokens: Math.max(this.opts.maxTokens ?? 2048, MIN_API_MAX_TOKENS),
526
621
  temperature: this.opts.temperature ?? 0,
527
622
  stream: false
528
623
  }),
@@ -594,8 +689,8 @@ async function isExecutableFile(file) {
594
689
  async function which(command) {
595
690
  if (!command) return null;
596
691
  const exts = pathExtensions();
597
- if (command.includes(path5.sep) || command.includes("/")) {
598
- const base = path5.resolve(command);
692
+ if (command.includes(path6.sep) || command.includes("/")) {
693
+ const base = path6.resolve(command);
599
694
  for (const ext of exts) {
600
695
  const candidate = base + ext;
601
696
  if (await isExecutableFile(candidate)) return candidate;
@@ -603,11 +698,11 @@ async function which(command) {
603
698
  return null;
604
699
  }
605
700
  const pathEnv = process.env.PATH ?? process.env.Path ?? "";
606
- const dirs = pathEnv.split(path5.delimiter).filter(Boolean);
607
- const fallbacks = process.platform === "win32" ? [] : ["/usr/local/bin", "/opt/homebrew/bin", path5.join(os.homedir(), ".local/bin")];
701
+ const dirs = pathEnv.split(path6.delimiter).filter(Boolean);
702
+ const fallbacks = process.platform === "win32" ? [] : ["/usr/local/bin", "/opt/homebrew/bin", path6.join(os.homedir(), ".local/bin")];
608
703
  for (const dir of [...dirs, ...fallbacks]) {
609
704
  for (const ext of exts) {
610
- const candidate = path5.join(dir, command + ext);
705
+ const candidate = path6.join(dir, command + ext);
611
706
  if (await isExecutableFile(candidate)) return candidate;
612
707
  }
613
708
  }
@@ -733,11 +828,11 @@ function ensurePtyHelperExecutable() {
733
828
  if (process.platform === "win32") return;
734
829
  try {
735
830
  const require2 = createRequire(import.meta.url);
736
- const root = path5.dirname(require2.resolve("node-pty/package.json"));
831
+ const root = path6.dirname(require2.resolve("node-pty/package.json"));
737
832
  const candidates = [
738
- path5.join(root, "build", "Release", "spawn-helper"),
739
- path5.join(root, "build", "Debug", "spawn-helper"),
740
- path5.join(
833
+ path6.join(root, "build", "Release", "spawn-helper"),
834
+ path6.join(root, "build", "Debug", "spawn-helper"),
835
+ path6.join(
741
836
  root,
742
837
  "prebuilds",
743
838
  `${process.platform}-${process.arch}`,
@@ -899,6 +994,7 @@ function runPtySession(opts, ctx) {
899
994
  let handling = false;
900
995
  let quitting = false;
901
996
  let settled = false;
997
+ let usage;
902
998
  let finished = false;
903
999
  let disposeData;
904
1000
  let disposeExit;
@@ -994,6 +1090,10 @@ function runPtySession(opts, ctx) {
994
1090
  };
995
1091
  const onSettle = async () => {
996
1092
  if (finished || handling || quitting) return;
1093
+ if (opts.scrapeUsage) {
1094
+ const u = opts.scrapeUsage(cleanTerminalText(buffer));
1095
+ if (u) usage = { ...usage, ...u };
1096
+ }
997
1097
  if (pendingComment !== void 0) {
998
1098
  const comment = pendingComment;
999
1099
  pendingComment = void 0;
@@ -1130,7 +1230,7 @@ function runPtySession(opts, ctx) {
1130
1230
  exitCode,
1131
1231
  error,
1132
1232
  sessionRef: void 0,
1133
- meta: { interactions, settled }
1233
+ meta: { interactions, settled, ...usage ? { usage } : {} }
1134
1234
  });
1135
1235
  });
1136
1236
  if (ctx.signal.aborted) onAbort();
@@ -1244,6 +1344,7 @@ var InteractivePtyAdapter = class {
1244
1344
  completionPattern: this.cfg.completionPattern,
1245
1345
  completionIdleMs: input.completionIdleMs ?? this.cfg.completionIdleMs,
1246
1346
  workingPattern: this.cfg.workingPattern,
1347
+ scrapeUsage: this.cfg.scrapeUsage,
1247
1348
  quitKeys: this.cfg.quitKeys,
1248
1349
  setup: extra?.setup,
1249
1350
  promptAfterSetup: extra?.promptAfterSetup,
@@ -1261,6 +1362,17 @@ var InteractivePtyAdapter = class {
1261
1362
  };
1262
1363
 
1263
1364
  // src/adapters/interactive/claude-interactive.ts
1365
+ function scrapeClaudeStatusLine(text) {
1366
+ const u = { source: "status-line" };
1367
+ const ctx = text.match(/context[^\n]*?(\d{1,3})\s*%/i);
1368
+ if (ctx) {
1369
+ u.contextPercent = Number(ctx[1]);
1370
+ u.raw = ctx[0].trim().replace(/\s+/g, " ");
1371
+ }
1372
+ const cost = text.match(/session\s*\$\s*([\d.]+)/i);
1373
+ if (cost) u.sessionCostUsd = Number(cost[1]);
1374
+ return u.contextPercent !== void 0 || u.sessionCostUsd !== void 0 ? u : void 0;
1375
+ }
1264
1376
  var DEFINITION = {
1265
1377
  name: "claude",
1266
1378
  type: "claude",
@@ -1269,6 +1381,9 @@ var DEFINITION = {
1269
1381
  supportsResume: true
1270
1382
  };
1271
1383
  var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends InteractivePtyAdapter {
1384
+ clock;
1385
+ /** Override the projects root (~/.claude/projects) for tests. */
1386
+ projectsDir;
1272
1387
  constructor(opts = {}) {
1273
1388
  super({
1274
1389
  definition: DEFINITION,
@@ -1285,14 +1400,14 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1285
1400
  if (input.approvalMode === "readonly")
1286
1401
  return [...head, "--permission-mode", "plan"];
1287
1402
  if (input.approvalMode === "gated")
1288
- return [...head, "--permission-mode", "acceptEdits"];
1403
+ return [...head, "--permission-mode", "default"];
1289
1404
  return [...head, "--dangerously-skip-permissions"];
1290
1405
  },
1291
1406
  // Resume the most recent conversation in the cwd and send the follow-up
1292
1407
  // prompt. (`--continue` picks the latest session; the native id is not
1293
1408
  // captured in PTY mode, so a specific `--resume <id>` is not used.)
1294
1409
  resumeArgs: (input) => {
1295
- const mode = input.approvalMode === "readonly" ? ["--permission-mode", "plan"] : input.approvalMode === "gated" ? ["--permission-mode", "acceptEdits"] : ["--dangerously-skip-permissions"];
1410
+ const mode = input.approvalMode === "readonly" ? ["--permission-mode", "plan"] : input.approvalMode === "gated" ? ["--permission-mode", "default"] : ["--dangerously-skip-permissions"];
1296
1411
  return ["--continue", "--effort", "xhigh", ...mode];
1297
1412
  },
1298
1413
  // Unattended ultracode: once Claude is idle (past the trust dialog), type
@@ -1306,6 +1421,10 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1306
1421
  approvalPattern: /(allow|grant|permission|approve|trust|proceed|do you want)[^\n]{0,60}\?|\[y\/n\]|\(y\/n\)/i
1307
1422
  },
1308
1423
  keymap: new ArrowKeymap(),
1424
+ // SUPPLEMENT only: grab context% / session $ from the status line when it
1425
+ // is enabled. The authoritative token counts come from the transcript in
1426
+ // run() below and overwrite these on the same meta.usage object.
1427
+ scrapeUsage: scrapeClaudeStatusLine,
1309
1428
  // Claude's TUI stays open after a task; quit once it's been idle a while.
1310
1429
  completionIdleMs: 8e3,
1311
1430
  // While Claude is ACTIVELY working it shows "(esc to interrupt)" — that is
@@ -1319,6 +1438,32 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1319
1438
  workingPattern: /interrupt/i,
1320
1439
  quitKeys: ""
1321
1440
  });
1441
+ this.clock = opts.now ?? (() => /* @__PURE__ */ new Date());
1442
+ this.projectsDir = opts.projectsDir;
1443
+ }
1444
+ /**
1445
+ * Run Claude, then read AUTHORITATIVE token usage from its session transcript
1446
+ * (~/.claude/projects/<cwd>/<id>.jsonl) and surface it as `meta.usage`. This is
1447
+ * device-independent — it works regardless of whether the user has a usage
1448
+ * status line — and overwrites the best-effort status-line scrape's token
1449
+ * figures while keeping its context%/cost extras. Best-effort: if no transcript
1450
+ * is found, the status-line usage (if any) is left as-is.
1451
+ */
1452
+ async run(input, ctx) {
1453
+ const startedAt = this.clock().getTime();
1454
+ const result = await super.run(input, ctx);
1455
+ if (result.error) return result;
1456
+ const transcript = await findLatestClaudeUsage({
1457
+ cwd: input.cwd,
1458
+ sinceMs: startedAt,
1459
+ projectsDir: this.projectsDir
1460
+ });
1461
+ if (!transcript) return result;
1462
+ const prior = result.meta?.usage ?? {};
1463
+ return {
1464
+ ...result,
1465
+ meta: { ...result.meta, usage: { ...prior, ...transcript } }
1466
+ };
1322
1467
  }
1323
1468
  static fromConfig(config) {
1324
1469
  return new _ClaudeInteractiveAdapter({
@@ -1328,7 +1473,7 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1328
1473
  }
1329
1474
  };
1330
1475
  var ROLLOUT_UUID_RE = /-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/;
1331
- async function realish(p) {
1476
+ async function realish2(p) {
1332
1477
  try {
1333
1478
  return await promises.realpath(p);
1334
1479
  } catch {
@@ -1344,7 +1489,7 @@ async function listRollouts(root) {
1344
1489
  return out;
1345
1490
  }
1346
1491
  for (const y of years) {
1347
- const yDir = path5.join(root, y);
1492
+ const yDir = path6.join(root, y);
1348
1493
  let months;
1349
1494
  try {
1350
1495
  months = await promises.readdir(yDir);
@@ -1352,7 +1497,7 @@ async function listRollouts(root) {
1352
1497
  continue;
1353
1498
  }
1354
1499
  for (const m of months) {
1355
- const mDir = path5.join(yDir, m);
1500
+ const mDir = path6.join(yDir, m);
1356
1501
  let days;
1357
1502
  try {
1358
1503
  days = await promises.readdir(mDir);
@@ -1360,7 +1505,7 @@ async function listRollouts(root) {
1360
1505
  continue;
1361
1506
  }
1362
1507
  for (const d of days) {
1363
- const dDir = path5.join(mDir, d);
1508
+ const dDir = path6.join(mDir, d);
1364
1509
  let files;
1365
1510
  try {
1366
1511
  files = await promises.readdir(dDir);
@@ -1369,7 +1514,7 @@ async function listRollouts(root) {
1369
1514
  }
1370
1515
  for (const f of files) {
1371
1516
  if (!f.startsWith("rollout-") || !f.endsWith(".jsonl")) continue;
1372
- const file = path5.join(dDir, f);
1517
+ const file = path6.join(dDir, f);
1373
1518
  try {
1374
1519
  const st = await promises.stat(file);
1375
1520
  out.push({ file, mtimeMs: st.mtimeMs });
@@ -1402,25 +1547,55 @@ async function readSessionMeta(file) {
1402
1547
  }
1403
1548
  }
1404
1549
  function uuidFromRolloutFilename(file) {
1405
- const m = ROLLOUT_UUID_RE.exec(path5.basename(file));
1550
+ const m = ROLLOUT_UUID_RE.exec(path6.basename(file));
1406
1551
  return m?.[1];
1407
1552
  }
1408
- async function findLatestCodexSessionId(opts) {
1409
- const root = opts.sessionsDir ?? path5.join(os.homedir(), ".codex", "sessions");
1553
+ async function findLatestCodexRollout(opts) {
1554
+ const root = opts.sessionsDir ?? path6.join(os.homedir(), ".codex", "sessions");
1410
1555
  const since = opts.sinceMs ?? 0;
1411
- const targetCwd = await realish(opts.cwd);
1556
+ const targetCwd = await realish2(opts.cwd);
1412
1557
  const rollouts = await listRollouts(root);
1413
1558
  for (const { file, mtimeMs } of rollouts) {
1414
1559
  if (mtimeMs + 2e3 < since) continue;
1415
1560
  const meta = await readSessionMeta(file);
1416
1561
  if (!meta?.cwd) continue;
1417
- const metaCwd = await realish(meta.cwd);
1562
+ const metaCwd = await realish2(meta.cwd);
1418
1563
  if (metaCwd !== targetCwd) continue;
1419
1564
  const id = meta.id ?? uuidFromRolloutFilename(file);
1420
- if (id) return id;
1565
+ return { file, id };
1421
1566
  }
1422
1567
  return void 0;
1423
1568
  }
1569
+ async function sumCodexUsage(file) {
1570
+ let text;
1571
+ try {
1572
+ text = await promises.readFile(file, "utf8");
1573
+ } catch {
1574
+ return void 0;
1575
+ }
1576
+ let total;
1577
+ for (const line of text.split("\n")) {
1578
+ if (!line.includes("token_count")) continue;
1579
+ let rec;
1580
+ try {
1581
+ rec = JSON.parse(line);
1582
+ } catch {
1583
+ continue;
1584
+ }
1585
+ if (rec.payload?.type !== "token_count") continue;
1586
+ const t = rec.payload.info?.total_token_usage;
1587
+ if (t && typeof t === "object") total = t;
1588
+ }
1589
+ if (!total) return void 0;
1590
+ return {
1591
+ source: "transcript",
1592
+ inputTokens: total.input_tokens,
1593
+ cachedInputTokens: total.cached_input_tokens,
1594
+ outputTokens: total.output_tokens,
1595
+ reasoningTokens: total.reasoning_output_tokens,
1596
+ totalTokens: total.total_tokens
1597
+ };
1598
+ }
1424
1599
 
1425
1600
  // src/adapters/interactive/codex-interactive.ts
1426
1601
  var DEFINITION2 = {
@@ -1478,29 +1653,28 @@ var CodexInteractiveAdapter = class _CodexInteractiveAdapter extends Interactive
1478
1653
  this.sessionsDir = opts.sessionsDir;
1479
1654
  }
1480
1655
  /**
1481
- * Run Codex, then capture its NATIVE session id (the rollout UUID) for this
1482
- * cwd and attach it to the result's `sessionRef` so the runner persists it and
1483
- * a later resume can use `codex resume <id> "<prompt>"`. Capture is best-effort:
1484
- * if no rollout matches (or any I/O fails) the result is returned unchanged, so
1485
- * the run still resumes via the `--last` fallback.
1656
+ * Run Codex, then read its rollout for this cwd to capture (a) the NATIVE
1657
+ * session id (the rollout UUID) for `sessionRef` so a later resume can use
1658
+ * `codex resume <id> "<prompt>"`, and (b) authoritative token usage for
1659
+ * `meta.usage` (device-independent from Codex's own log, not the TUI). Both
1660
+ * are best-effort: if no rollout matches (or any I/O fails) the result is
1661
+ * returned unchanged, so the run still resumes via the `--last` fallback.
1486
1662
  */
1487
1663
  async run(input, ctx) {
1488
1664
  const startedAt = this.clock().getTime();
1489
1665
  const result = await super.run(input, ctx);
1490
1666
  if (result.error) return result;
1491
- const nativeSessionId = await findLatestCodexSessionId({
1667
+ const rollout = await findLatestCodexRollout({
1492
1668
  cwd: input.cwd,
1493
1669
  sinceMs: startedAt,
1494
1670
  sessionsDir: this.sessionsDir
1495
1671
  });
1496
- if (!nativeSessionId) return result;
1672
+ if (!rollout) return result;
1673
+ const usage = await sumCodexUsage(rollout.file);
1497
1674
  return {
1498
1675
  ...result,
1499
- sessionRef: {
1500
- adapter: this.definition.name,
1501
- nativeSessionId,
1502
- resumable: true
1503
- }
1676
+ sessionRef: rollout.id ? { adapter: this.definition.name, nativeSessionId: rollout.id, resumable: true } : result.sessionRef,
1677
+ meta: usage ? { ...result.meta, usage } : result.meta
1504
1678
  };
1505
1679
  }
1506
1680
  static fromConfig(config) {
@@ -1780,11 +1954,11 @@ async function commandCheck(name, command, installHint) {
1780
1954
  };
1781
1955
  }
1782
1956
  async function runDoctor(options) {
1783
- const rootDir = path5.resolve(options.rootDir);
1957
+ const rootDir = path6.resolve(options.rootDir);
1784
1958
  const checks = [];
1785
1959
  checks.push(checkNode());
1786
- let sessionsDir = path5.resolve(rootDir, ".agent-relay/sessions");
1787
- let logsDir = path5.resolve(rootDir, ".agent-relay/logs");
1960
+ let sessionsDir = path6.resolve(rootDir, ".agent-relay/sessions");
1961
+ let logsDir = path6.resolve(rootDir, ".agent-relay/logs");
1788
1962
  try {
1789
1963
  const config = await loadConfig(rootDir);
1790
1964
  checks.push({
@@ -1792,8 +1966,8 @@ async function runDoctor(options) {
1792
1966
  level: "ok",
1793
1967
  detail: `Valid config (defaultAdapter: ${config.defaultAdapter})`
1794
1968
  });
1795
- sessionsDir = path5.resolve(rootDir, config.sessionsDir);
1796
- logsDir = path5.resolve(rootDir, config.logsDir);
1969
+ sessionsDir = path6.resolve(rootDir, config.sessionsDir);
1970
+ logsDir = path6.resolve(rootDir, config.logsDir);
1797
1971
  } catch (err) {
1798
1972
  const message = err instanceof Error ? err.message : String(err);
1799
1973
  const missing = message.includes("not found");
@@ -1909,7 +2083,7 @@ var RunLogger = class {
1909
2083
  if (this.opts.maxBytes && this.bytes >= this.opts.maxBytes) {
1910
2084
  if (!this.truncated) {
1911
2085
  this.truncated = true;
1912
- fs6.appendFileSync(
2086
+ fs7.appendFileSync(
1913
2087
  this.logFile,
1914
2088
  `... [log truncated at ${this.opts.maxBytes} bytes \u2014 raise --max-log-bytes for more]
1915
2089
  `
@@ -1917,12 +2091,12 @@ var RunLogger = class {
1917
2091
  }
1918
2092
  return;
1919
2093
  }
1920
- fs6.appendFileSync(this.logFile, text);
2094
+ fs7.appendFileSync(this.logFile, text);
1921
2095
  this.bytes += Buffer.byteLength(text);
1922
2096
  }
1923
2097
  /** Ensure the log directory exists and write the run header. */
1924
2098
  start(header) {
1925
- fs6.mkdirSync(path5.dirname(this.logFile), { recursive: true });
2099
+ fs7.mkdirSync(path6.dirname(this.logFile), { recursive: true });
1926
2100
  const lines = [
1927
2101
  "================ agent-relay run ================",
1928
2102
  `sessionId : ${header.sessionId}`,
@@ -1983,7 +2157,7 @@ var SessionManager = class {
1983
2157
  sessionsDir;
1984
2158
  /** Absolute path to a session's JSON metadata file. */
1985
2159
  filePath(sessionId) {
1986
- return path5.join(this.sessionsDir, `${sessionId}.json`);
2160
+ return path6.join(this.sessionsDir, `${sessionId}.json`);
1987
2161
  }
1988
2162
  /** Build a fresh session record in the `created` state (not yet persisted). */
1989
2163
  create(input) {
@@ -2050,7 +2224,7 @@ var SessionManager = class {
2050
2224
  if (!entry.endsWith(".json")) continue;
2051
2225
  try {
2052
2226
  const raw = await promises.readFile(
2053
- path5.join(this.sessionsDir, entry),
2227
+ path6.join(this.sessionsDir, entry),
2054
2228
  "utf8"
2055
2229
  );
2056
2230
  metas.push(JSON.parse(raw));
@@ -2074,12 +2248,12 @@ async function runAgent(options) {
2074
2248
  }
2075
2249
  const adapter = options.resolveAdapter(adapterName, config);
2076
2250
  const detector = options.detector ?? new CompositeCompletionDetector();
2077
- const rootDir = path5.resolve(options.rootDir);
2078
- const cwd = path5.resolve(options.cwd ?? rootDir);
2079
- const sessionsDir = path5.resolve(rootDir, config.sessionsDir);
2080
- const logsDir = path5.resolve(rootDir, config.logsDir);
2251
+ const rootDir = path6.resolve(options.rootDir);
2252
+ const cwd = path6.resolve(options.cwd ?? rootDir);
2253
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
2254
+ const logsDir = path6.resolve(rootDir, config.logsDir);
2081
2255
  const sessionId = options.sessionId ?? randomUUID();
2082
- const logFile = path5.join(logsDir, `${sessionId}.log`);
2256
+ const logFile = path6.join(logsDir, `${sessionId}.log`);
2083
2257
  const sessions = new SessionManager(sessionsDir);
2084
2258
  const startedAt = iso();
2085
2259
  const session = sessions.create({
@@ -2375,7 +2549,7 @@ async function resolvePrompt(options) {
2375
2549
  );
2376
2550
  }
2377
2551
  if (options.promptFile) {
2378
- const file = path5.resolve(options.rootDir, options.promptFile);
2552
+ const file = path6.resolve(options.rootDir, options.promptFile);
2379
2553
  try {
2380
2554
  const text = await promises.readFile(file, "utf8");
2381
2555
  if (!text.trim()) {
@@ -2402,7 +2576,7 @@ async function resolvePrompt(options) {
2402
2576
  );
2403
2577
  }
2404
2578
  async function runCommand(options) {
2405
- const rootDir = path5.resolve(options.rootDir);
2579
+ const rootDir = path6.resolve(options.rootDir);
2406
2580
  const config = options.config ?? await loadConfigOrDefault(rootDir, options.onDefaultConfig);
2407
2581
  const prompt = await resolvePrompt({
2408
2582
  prompt: options.prompt,
@@ -2434,9 +2608,9 @@ async function runCommand(options) {
2434
2608
  });
2435
2609
  }
2436
2610
  async function resumeCommand(options) {
2437
- const rootDir = path5.resolve(options.rootDir);
2611
+ const rootDir = path6.resolve(options.rootDir);
2438
2612
  const config = options.config ?? await loadConfigOrDefault(rootDir, options.onDefaultConfig);
2439
- const sessionsDir = path5.resolve(rootDir, config.sessionsDir);
2613
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
2440
2614
  const sessions = new SessionManager(sessionsDir);
2441
2615
  const session = await sessions.load(options.sessionId);
2442
2616
  const resolveAdapter = options.resolveAdapter ?? createAdapterFactory();
@@ -2487,9 +2661,9 @@ async function resumeCommand(options) {
2487
2661
  return { session, resumable, resumed: true, outcome };
2488
2662
  }
2489
2663
  async function resolveManager(opts) {
2490
- const rootDir = path5.resolve(opts.rootDir);
2664
+ const rootDir = path6.resolve(opts.rootDir);
2491
2665
  const config = await loadConfigOrDefault(rootDir, opts.onDefaultConfig);
2492
- const sessionsDir = path5.resolve(rootDir, config.sessionsDir);
2666
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
2493
2667
  const sessions = new SessionManager(sessionsDir);
2494
2668
  return { sessions, metas: await sessions.list() };
2495
2669
  }
@@ -2810,7 +2984,7 @@ program.command("run").description("Run a prompt through an agent adapter").opti
2810
2984
  "api decider: OpenAI-compatible chat-completions URL"
2811
2985
  ).option("--decider-model <model>", "api decider: model name").option("--decider-key <key>", "api decider: bearer API key").option(
2812
2986
  "--decider-max-tokens <n>",
2813
- "api decider: max tokens",
2987
+ "api decider: max tokens (default 2048; floor 512 \u2014 reasoning models need headroom)",
2814
2988
  positiveIntArg("--decider-max-tokens")
2815
2989
  ).option(
2816
2990
  "--decider-timeout-ms <n>",
@@ -2882,7 +3056,7 @@ program.command("resume").description("Inspect a prior session and optionally se
2882
3056
  "Decider override: rule | always-approve | command | api"
2883
3057
  ).option("--decider-command <cmdline>", "command decider: full command line").option("--decider-url <url>", "api decider: chat-completions URL").option("--decider-model <model>", "api decider: model name").option("--decider-key <key>", "api decider: bearer API key").option(
2884
3058
  "--decider-max-tokens <n>",
2885
- "api decider: max tokens",
3059
+ "api decider: max tokens (default 2048; floor 512 \u2014 reasoning models need headroom)",
2886
3060
  positiveIntArg("--decider-max-tokens")
2887
3061
  ).option(
2888
3062
  "--decider-timeout-ms <n>",