@yul-labs/agent-relay 0.1.1 → 0.1.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.
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';
@@ -90,7 +90,7 @@ function resolveApprovalMode(defaults, adapter) {
90
90
  return adapter?.approvalPolicy ?? defaults.approvalPolicy ?? (defaults.requireApprovalOnRiskyActions ? "gated" : "auto");
91
91
  }
92
92
  function resolveSandbox(defaults, adapter) {
93
- return adapter?.sandbox ?? defaults.sandbox ?? "workspace-write";
93
+ return adapter?.sandbox ?? defaults.sandbox ?? "danger-full-access";
94
94
  }
95
95
  var hooksSchema = z.object({
96
96
  /** Shell command run just before the agent starts. */
@@ -117,6 +117,14 @@ var deciderSchema = z.object({
117
117
  apiKey: z.string().optional(),
118
118
  maxTokens: z.number().int().positive().optional()
119
119
  }).strict();
120
+ var pricingRuleSchema = z.object({
121
+ /** Regex (case-insensitive) matched against the model id; first match wins. */
122
+ match: z.string().min(1),
123
+ input: z.number().nonnegative(),
124
+ output: z.number().nonnegative(),
125
+ cacheWrite: z.number().nonnegative().optional(),
126
+ cacheRead: z.number().nonnegative().optional()
127
+ }).strict();
120
128
  var configSchema = z.object({
121
129
  defaultAdapter: z.string().min(1),
122
130
  sessionsDir: z.string().min(1).default(".agent-relay/sessions"),
@@ -125,6 +133,8 @@ var configSchema = z.object({
125
133
  adapters: z.record(adapterConfigSchema),
126
134
  /** Which decider answers interactive prompts. */
127
135
  decider: deciderSchema.optional(),
136
+ /** Override the built-in per-model token pricing (USD per 1M tokens). */
137
+ pricing: z.array(pricingRuleSchema).optional(),
128
138
  /** Optional shell-command lifecycle hooks. */
129
139
  hooks: hooksSchema.optional()
130
140
  }).strict().superRefine((cfg, ctx) => {
@@ -145,9 +155,10 @@ function createDefaultConfig() {
145
155
  maxTurns: 20,
146
156
  timeoutMs: 18e5,
147
157
  idleTimeoutMs: 3e5,
148
- // Autonomous by default: agents run unattended without asking.
158
+ // Autonomous by default: agents run unattended without asking, with full
159
+ // bypass so they can act freely (tighten `sandbox` for guarded runs).
149
160
  approvalPolicy: "auto",
150
- sandbox: "workspace-write",
161
+ sandbox: "danger-full-access",
151
162
  requireApprovalOnRiskyActions: false
152
163
  },
153
164
  // Interactive (PTY) is the default mode: the agent runs in its real TUI and
@@ -185,7 +196,7 @@ ${detail}`,
185
196
  return result.data;
186
197
  }
187
198
  function configPath(rootDir) {
188
- return path5.resolve(rootDir, CONFIG_FILENAME);
199
+ return path6.resolve(rootDir, CONFIG_FILENAME);
189
200
  }
190
201
  async function loadConfig(rootDir) {
191
202
  const file = configPath(rootDir);
@@ -225,7 +236,7 @@ function stringifyConfig(config) {
225
236
  }
226
237
  async function saveConfig(rootDir, config) {
227
238
  const file = configPath(rootDir);
228
- await promises.mkdir(path5.dirname(file), { recursive: true });
239
+ await promises.mkdir(path6.dirname(file), { recursive: true });
229
240
  await promises.writeFile(file, stringifyConfig(config), "utf8");
230
241
  return file;
231
242
  }
@@ -240,7 +251,7 @@ async function dirExists(p) {
240
251
  }
241
252
  }
242
253
  async function runInit(options) {
243
- const rootDir = path5.resolve(options.rootDir);
254
+ const rootDir = path6.resolve(options.rootDir);
244
255
  const cfgPath = configPath(rootDir);
245
256
  let configCreated = false;
246
257
  let configExists = false;
@@ -255,8 +266,8 @@ async function runInit(options) {
255
266
  configCreated = true;
256
267
  }
257
268
  const config = await loadConfig(rootDir);
258
- const sessionsDir = path5.resolve(rootDir, config.sessionsDir);
259
- const logsDir = path5.resolve(rootDir, config.logsDir);
269
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
270
+ const logsDir = path6.resolve(rootDir, config.logsDir);
260
271
  const createdDirs = [];
261
272
  for (const dir of [sessionsDir, logsDir]) {
262
273
  if (!await dirExists(dir)) {
@@ -272,6 +283,100 @@ async function runInit(options) {
272
283
  createdDirs
273
284
  };
274
285
  }
286
+ async function realish(p) {
287
+ try {
288
+ return await promises.realpath(p);
289
+ } catch {
290
+ return p;
291
+ }
292
+ }
293
+ function projectDirCandidates(absCwd) {
294
+ return [absCwd.replace(/[/.]/g, "-"), absCwd.replace(/\//g, "-")];
295
+ }
296
+ async function resolveProjectDir(root, cwd) {
297
+ const abs = await realish(cwd);
298
+ for (const name of projectDirCandidates(abs)) {
299
+ const dir = path6.join(root, name);
300
+ try {
301
+ const st = await promises.stat(dir);
302
+ if (st.isDirectory()) return dir;
303
+ } catch {
304
+ }
305
+ }
306
+ return void 0;
307
+ }
308
+ async function findLatestClaudeTranscript(opts) {
309
+ const root = opts.projectsDir ?? path6.join(os.homedir(), ".claude", "projects");
310
+ const since = opts.sinceMs ?? 0;
311
+ const dir = await resolveProjectDir(root, opts.cwd);
312
+ if (!dir) return void 0;
313
+ let files;
314
+ try {
315
+ files = await promises.readdir(dir);
316
+ } catch {
317
+ return void 0;
318
+ }
319
+ const candidates = [];
320
+ for (const f of files) {
321
+ if (!f.endsWith(".jsonl")) continue;
322
+ const file = path6.join(dir, f);
323
+ try {
324
+ const st = await promises.stat(file);
325
+ if (st.mtimeMs + 2e3 < since) continue;
326
+ candidates.push({ file, mtimeMs: st.mtimeMs });
327
+ } catch {
328
+ }
329
+ }
330
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
331
+ return candidates[0]?.file;
332
+ }
333
+ async function sumClaudeUsage(file) {
334
+ let text;
335
+ try {
336
+ text = await promises.readFile(file, "utf8");
337
+ } catch {
338
+ return void 0;
339
+ }
340
+ let input = 0;
341
+ let output = 0;
342
+ let cacheCreate = 0;
343
+ let cacheRead = 0;
344
+ let model;
345
+ let any = false;
346
+ for (const line of text.split("\n")) {
347
+ if (!line.trim()) continue;
348
+ let rec;
349
+ try {
350
+ rec = JSON.parse(line);
351
+ } catch {
352
+ continue;
353
+ }
354
+ const u = rec.message?.usage;
355
+ if (!u || typeof u !== "object") continue;
356
+ any = true;
357
+ input += u.input_tokens ?? 0;
358
+ output += u.output_tokens ?? 0;
359
+ cacheCreate += u.cache_creation_input_tokens ?? 0;
360
+ cacheRead += u.cache_read_input_tokens ?? 0;
361
+ if (!model && typeof rec.message?.model === "string")
362
+ model = rec.message.model;
363
+ }
364
+ if (!any) return void 0;
365
+ return {
366
+ source: "transcript",
367
+ model,
368
+ inputTokens: input,
369
+ outputTokens: output,
370
+ cacheCreationTokens: cacheCreate,
371
+ cachedInputTokens: cacheRead,
372
+ totalTokens: input + output + cacheCreate + cacheRead
373
+ };
374
+ }
375
+ async function findLatestClaudeUsage(opts) {
376
+ const file = await findLatestClaudeTranscript(opts);
377
+ if (!file) return void 0;
378
+ return sumClaudeUsage(file);
379
+ }
275
380
 
276
381
  // src/core/util/json.ts
277
382
  function safeJsonParse(text) {
@@ -493,6 +598,7 @@ var CommandDecider = class {
493
598
  }
494
599
  }
495
600
  };
601
+ var MIN_API_MAX_TOKENS = 512;
496
602
  var ApiDecider = class {
497
603
  constructor(opts) {
498
604
  this.opts = opts;
@@ -522,7 +628,7 @@ var ApiDecider = class {
522
628
  body: JSON.stringify({
523
629
  model: this.opts.model ?? "default",
524
630
  messages: [{ role: "user", content: render(req) }],
525
- max_tokens: this.opts.maxTokens ?? 2048,
631
+ max_tokens: Math.max(this.opts.maxTokens ?? 2048, MIN_API_MAX_TOKENS),
526
632
  temperature: this.opts.temperature ?? 0,
527
633
  stream: false
528
634
  }),
@@ -594,8 +700,8 @@ async function isExecutableFile(file) {
594
700
  async function which(command) {
595
701
  if (!command) return null;
596
702
  const exts = pathExtensions();
597
- if (command.includes(path5.sep) || command.includes("/")) {
598
- const base = path5.resolve(command);
703
+ if (command.includes(path6.sep) || command.includes("/")) {
704
+ const base = path6.resolve(command);
599
705
  for (const ext of exts) {
600
706
  const candidate = base + ext;
601
707
  if (await isExecutableFile(candidate)) return candidate;
@@ -603,11 +709,11 @@ async function which(command) {
603
709
  return null;
604
710
  }
605
711
  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")];
712
+ const dirs = pathEnv.split(path6.delimiter).filter(Boolean);
713
+ const fallbacks = process.platform === "win32" ? [] : ["/usr/local/bin", "/opt/homebrew/bin", path6.join(os.homedir(), ".local/bin")];
608
714
  for (const dir of [...dirs, ...fallbacks]) {
609
715
  for (const ext of exts) {
610
- const candidate = path5.join(dir, command + ext);
716
+ const candidate = path6.join(dir, command + ext);
611
717
  if (await isExecutableFile(candidate)) return candidate;
612
718
  }
613
719
  }
@@ -733,11 +839,11 @@ function ensurePtyHelperExecutable() {
733
839
  if (process.platform === "win32") return;
734
840
  try {
735
841
  const require2 = createRequire(import.meta.url);
736
- const root = path5.dirname(require2.resolve("node-pty/package.json"));
842
+ const root = path6.dirname(require2.resolve("node-pty/package.json"));
737
843
  const candidates = [
738
- path5.join(root, "build", "Release", "spawn-helper"),
739
- path5.join(root, "build", "Debug", "spawn-helper"),
740
- path5.join(
844
+ path6.join(root, "build", "Release", "spawn-helper"),
845
+ path6.join(root, "build", "Debug", "spawn-helper"),
846
+ path6.join(
741
847
  root,
742
848
  "prebuilds",
743
849
  `${process.platform}-${process.arch}`,
@@ -899,6 +1005,7 @@ function runPtySession(opts, ctx) {
899
1005
  let handling = false;
900
1006
  let quitting = false;
901
1007
  let settled = false;
1008
+ let usage;
902
1009
  let finished = false;
903
1010
  let disposeData;
904
1011
  let disposeExit;
@@ -915,6 +1022,7 @@ function runPtySession(opts, ctx) {
915
1022
  const triggerQuit = (reason) => {
916
1023
  if (finished || quitting) return;
917
1024
  quitting = true;
1025
+ settled = true;
918
1026
  if (completionTimer) clearTimeout(completionTimer);
919
1027
  completionTimer = void 0;
920
1028
  emit("status", `task appears complete (${reason})`);
@@ -994,6 +1102,10 @@ function runPtySession(opts, ctx) {
994
1102
  };
995
1103
  const onSettle = async () => {
996
1104
  if (finished || handling || quitting) return;
1105
+ if (opts.scrapeUsage) {
1106
+ const u = opts.scrapeUsage(cleanTerminalText(buffer));
1107
+ if (u) usage = { ...usage, ...u };
1108
+ }
997
1109
  if (pendingComment !== void 0) {
998
1110
  const comment = pendingComment;
999
1111
  pendingComment = void 0;
@@ -1130,7 +1242,14 @@ function runPtySession(opts, ctx) {
1130
1242
  exitCode,
1131
1243
  error,
1132
1244
  sessionRef: void 0,
1133
- meta: { interactions, settled }
1245
+ // `completedCleanly` is an unambiguous success flag (exit 0, not aborted/
1246
+ // error) — use it instead of `settled` for "did the run finish well?".
1247
+ meta: {
1248
+ interactions,
1249
+ settled,
1250
+ completedCleanly: success,
1251
+ ...usage ? { usage } : {}
1252
+ }
1134
1253
  });
1135
1254
  });
1136
1255
  if (ctx.signal.aborted) onAbort();
@@ -1230,8 +1349,17 @@ var InteractivePtyAdapter = class {
1230
1349
  else args.push(input.prompt);
1231
1350
  return this.spawn(args, initialInput, input, ctx);
1232
1351
  }
1233
- spawn(args, initialInput, input, ctx, extra) {
1352
+ async spawn(args, initialInput, input, ctx, extra) {
1234
1353
  const decider = ctx.decider ?? new RuleDecider();
1354
+ try {
1355
+ const resolved = await which(this.cfg.command);
1356
+ ctx.onEvent({
1357
+ type: "status",
1358
+ timestamp: (this.cfg.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
1359
+ text: resolved ? `resolved ${this.cfg.command} \u2192 ${resolved}` : `${this.cfg.command} not found on PATH; relying on spawn-time resolution`
1360
+ });
1361
+ } catch {
1362
+ }
1235
1363
  return runPtySession(
1236
1364
  {
1237
1365
  command: this.cfg.command,
@@ -1244,6 +1372,7 @@ var InteractivePtyAdapter = class {
1244
1372
  completionPattern: this.cfg.completionPattern,
1245
1373
  completionIdleMs: input.completionIdleMs ?? this.cfg.completionIdleMs,
1246
1374
  workingPattern: this.cfg.workingPattern,
1375
+ scrapeUsage: this.cfg.scrapeUsage,
1247
1376
  quitKeys: this.cfg.quitKeys,
1248
1377
  setup: extra?.setup,
1249
1378
  promptAfterSetup: extra?.promptAfterSetup,
@@ -1261,6 +1390,17 @@ var InteractivePtyAdapter = class {
1261
1390
  };
1262
1391
 
1263
1392
  // src/adapters/interactive/claude-interactive.ts
1393
+ function scrapeClaudeStatusLine(text) {
1394
+ const u = { source: "status-line" };
1395
+ const ctx = text.match(/context[^\n]*?(\d{1,3})\s*%/i);
1396
+ if (ctx) {
1397
+ u.contextPercent = Number(ctx[1]);
1398
+ u.raw = ctx[0].trim().replace(/\s+/g, " ");
1399
+ }
1400
+ const cost = text.match(/session\s*\$\s*([\d.]+)/i);
1401
+ if (cost) u.subscriptionSessionCostUsd = Number(cost[1]);
1402
+ return u.contextPercent !== void 0 || u.subscriptionSessionCostUsd !== void 0 ? u : void 0;
1403
+ }
1264
1404
  var DEFINITION = {
1265
1405
  name: "claude",
1266
1406
  type: "claude",
@@ -1269,6 +1409,9 @@ var DEFINITION = {
1269
1409
  supportsResume: true
1270
1410
  };
1271
1411
  var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends InteractivePtyAdapter {
1412
+ clock;
1413
+ /** Override the projects root (~/.claude/projects) for tests. */
1414
+ projectsDir;
1272
1415
  constructor(opts = {}) {
1273
1416
  super({
1274
1417
  definition: DEFINITION,
@@ -1285,14 +1428,14 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1285
1428
  if (input.approvalMode === "readonly")
1286
1429
  return [...head, "--permission-mode", "plan"];
1287
1430
  if (input.approvalMode === "gated")
1288
- return [...head, "--permission-mode", "acceptEdits"];
1431
+ return [...head, "--permission-mode", "default"];
1289
1432
  return [...head, "--dangerously-skip-permissions"];
1290
1433
  },
1291
1434
  // Resume the most recent conversation in the cwd and send the follow-up
1292
1435
  // prompt. (`--continue` picks the latest session; the native id is not
1293
1436
  // captured in PTY mode, so a specific `--resume <id>` is not used.)
1294
1437
  resumeArgs: (input) => {
1295
- const mode = input.approvalMode === "readonly" ? ["--permission-mode", "plan"] : input.approvalMode === "gated" ? ["--permission-mode", "acceptEdits"] : ["--dangerously-skip-permissions"];
1438
+ const mode = input.approvalMode === "readonly" ? ["--permission-mode", "plan"] : input.approvalMode === "gated" ? ["--permission-mode", "default"] : ["--dangerously-skip-permissions"];
1296
1439
  return ["--continue", "--effort", "xhigh", ...mode];
1297
1440
  },
1298
1441
  // Unattended ultracode: once Claude is idle (past the trust dialog), type
@@ -1306,6 +1449,10 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1306
1449
  approvalPattern: /(allow|grant|permission|approve|trust|proceed|do you want)[^\n]{0,60}\?|\[y\/n\]|\(y\/n\)/i
1307
1450
  },
1308
1451
  keymap: new ArrowKeymap(),
1452
+ // SUPPLEMENT only: grab context% / session $ from the status line when it
1453
+ // is enabled. The authoritative token counts come from the transcript in
1454
+ // run() below and overwrite these on the same meta.usage object.
1455
+ scrapeUsage: scrapeClaudeStatusLine,
1309
1456
  // Claude's TUI stays open after a task; quit once it's been idle a while.
1310
1457
  completionIdleMs: 8e3,
1311
1458
  // While Claude is ACTIVELY working it shows "(esc to interrupt)" — that is
@@ -1319,6 +1466,32 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1319
1466
  workingPattern: /interrupt/i,
1320
1467
  quitKeys: ""
1321
1468
  });
1469
+ this.clock = opts.now ?? (() => /* @__PURE__ */ new Date());
1470
+ this.projectsDir = opts.projectsDir;
1471
+ }
1472
+ /**
1473
+ * Run Claude, then read AUTHORITATIVE token usage from its session transcript
1474
+ * (~/.claude/projects/<cwd>/<id>.jsonl) and surface it as `meta.usage`. This is
1475
+ * device-independent — it works regardless of whether the user has a usage
1476
+ * status line — and overwrites the best-effort status-line scrape's token
1477
+ * figures while keeping its context%/cost extras. Best-effort: if no transcript
1478
+ * is found, the status-line usage (if any) is left as-is.
1479
+ */
1480
+ async run(input, ctx) {
1481
+ const startedAt = this.clock().getTime();
1482
+ const result = await super.run(input, ctx);
1483
+ if (result.error) return result;
1484
+ const transcript = await findLatestClaudeUsage({
1485
+ cwd: input.cwd,
1486
+ sinceMs: startedAt,
1487
+ projectsDir: this.projectsDir
1488
+ });
1489
+ if (!transcript) return result;
1490
+ const prior = result.meta?.usage ?? {};
1491
+ return {
1492
+ ...result,
1493
+ meta: { ...result.meta, usage: { ...prior, ...transcript } }
1494
+ };
1322
1495
  }
1323
1496
  static fromConfig(config) {
1324
1497
  return new _ClaudeInteractiveAdapter({
@@ -1328,7 +1501,7 @@ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends Interacti
1328
1501
  }
1329
1502
  };
1330
1503
  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) {
1504
+ async function realish2(p) {
1332
1505
  try {
1333
1506
  return await promises.realpath(p);
1334
1507
  } catch {
@@ -1344,7 +1517,7 @@ async function listRollouts(root) {
1344
1517
  return out;
1345
1518
  }
1346
1519
  for (const y of years) {
1347
- const yDir = path5.join(root, y);
1520
+ const yDir = path6.join(root, y);
1348
1521
  let months;
1349
1522
  try {
1350
1523
  months = await promises.readdir(yDir);
@@ -1352,7 +1525,7 @@ async function listRollouts(root) {
1352
1525
  continue;
1353
1526
  }
1354
1527
  for (const m of months) {
1355
- const mDir = path5.join(yDir, m);
1528
+ const mDir = path6.join(yDir, m);
1356
1529
  let days;
1357
1530
  try {
1358
1531
  days = await promises.readdir(mDir);
@@ -1360,7 +1533,7 @@ async function listRollouts(root) {
1360
1533
  continue;
1361
1534
  }
1362
1535
  for (const d of days) {
1363
- const dDir = path5.join(mDir, d);
1536
+ const dDir = path6.join(mDir, d);
1364
1537
  let files;
1365
1538
  try {
1366
1539
  files = await promises.readdir(dDir);
@@ -1369,7 +1542,7 @@ async function listRollouts(root) {
1369
1542
  }
1370
1543
  for (const f of files) {
1371
1544
  if (!f.startsWith("rollout-") || !f.endsWith(".jsonl")) continue;
1372
- const file = path5.join(dDir, f);
1545
+ const file = path6.join(dDir, f);
1373
1546
  try {
1374
1547
  const st = await promises.stat(file);
1375
1548
  out.push({ file, mtimeMs: st.mtimeMs });
@@ -1402,25 +1575,55 @@ async function readSessionMeta(file) {
1402
1575
  }
1403
1576
  }
1404
1577
  function uuidFromRolloutFilename(file) {
1405
- const m = ROLLOUT_UUID_RE.exec(path5.basename(file));
1578
+ const m = ROLLOUT_UUID_RE.exec(path6.basename(file));
1406
1579
  return m?.[1];
1407
1580
  }
1408
- async function findLatestCodexSessionId(opts) {
1409
- const root = opts.sessionsDir ?? path5.join(os.homedir(), ".codex", "sessions");
1581
+ async function findLatestCodexRollout(opts) {
1582
+ const root = opts.sessionsDir ?? path6.join(os.homedir(), ".codex", "sessions");
1410
1583
  const since = opts.sinceMs ?? 0;
1411
- const targetCwd = await realish(opts.cwd);
1584
+ const targetCwd = await realish2(opts.cwd);
1412
1585
  const rollouts = await listRollouts(root);
1413
1586
  for (const { file, mtimeMs } of rollouts) {
1414
1587
  if (mtimeMs + 2e3 < since) continue;
1415
1588
  const meta = await readSessionMeta(file);
1416
1589
  if (!meta?.cwd) continue;
1417
- const metaCwd = await realish(meta.cwd);
1590
+ const metaCwd = await realish2(meta.cwd);
1418
1591
  if (metaCwd !== targetCwd) continue;
1419
1592
  const id = meta.id ?? uuidFromRolloutFilename(file);
1420
- if (id) return id;
1593
+ return { file, id };
1421
1594
  }
1422
1595
  return void 0;
1423
1596
  }
1597
+ async function sumCodexUsage(file) {
1598
+ let text;
1599
+ try {
1600
+ text = await promises.readFile(file, "utf8");
1601
+ } catch {
1602
+ return void 0;
1603
+ }
1604
+ let total;
1605
+ for (const line of text.split("\n")) {
1606
+ if (!line.includes("token_count")) continue;
1607
+ let rec;
1608
+ try {
1609
+ rec = JSON.parse(line);
1610
+ } catch {
1611
+ continue;
1612
+ }
1613
+ if (rec.payload?.type !== "token_count") continue;
1614
+ const t = rec.payload.info?.total_token_usage;
1615
+ if (t && typeof t === "object") total = t;
1616
+ }
1617
+ if (!total) return void 0;
1618
+ return {
1619
+ source: "transcript",
1620
+ inputTokens: total.input_tokens,
1621
+ cachedInputTokens: total.cached_input_tokens,
1622
+ outputTokens: total.output_tokens,
1623
+ reasoningTokens: total.reasoning_output_tokens,
1624
+ totalTokens: total.total_tokens
1625
+ };
1626
+ }
1424
1627
 
1425
1628
  // src/adapters/interactive/codex-interactive.ts
1426
1629
  var DEFINITION2 = {
@@ -1442,7 +1645,7 @@ var CodexInteractiveAdapter = class _CodexInteractiveAdapter extends Interactive
1442
1645
  now: opts.now,
1443
1646
  installHint: "Install the Codex CLI (`npm i -g @openai/codex`), run `codex login`, and ensure `codex` is on your PATH.",
1444
1647
  preArgs: (input) => {
1445
- const sandbox = input.approvalMode === "readonly" ? "read-only" : input.sandbox ?? "workspace-write";
1648
+ const sandbox = input.approvalMode === "readonly" ? "read-only" : input.sandbox ?? "danger-full-access";
1446
1649
  const approval = input.approvalMode === "gated" ? "on-request" : "never";
1447
1650
  return ["-s", sandbox, "-a", approval];
1448
1651
  },
@@ -1478,29 +1681,28 @@ var CodexInteractiveAdapter = class _CodexInteractiveAdapter extends Interactive
1478
1681
  this.sessionsDir = opts.sessionsDir;
1479
1682
  }
1480
1683
  /**
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.
1684
+ * Run Codex, then read its rollout for this cwd to capture (a) the NATIVE
1685
+ * session id (the rollout UUID) for `sessionRef` so a later resume can use
1686
+ * `codex resume <id> "<prompt>"`, and (b) authoritative token usage for
1687
+ * `meta.usage` (device-independent from Codex's own log, not the TUI). Both
1688
+ * are best-effort: if no rollout matches (or any I/O fails) the result is
1689
+ * returned unchanged, so the run still resumes via the `--last` fallback.
1486
1690
  */
1487
1691
  async run(input, ctx) {
1488
1692
  const startedAt = this.clock().getTime();
1489
1693
  const result = await super.run(input, ctx);
1490
1694
  if (result.error) return result;
1491
- const nativeSessionId = await findLatestCodexSessionId({
1695
+ const rollout = await findLatestCodexRollout({
1492
1696
  cwd: input.cwd,
1493
1697
  sinceMs: startedAt,
1494
1698
  sessionsDir: this.sessionsDir
1495
1699
  });
1496
- if (!nativeSessionId) return result;
1700
+ if (!rollout) return result;
1701
+ const usage = await sumCodexUsage(rollout.file);
1497
1702
  return {
1498
1703
  ...result,
1499
- sessionRef: {
1500
- adapter: this.definition.name,
1501
- nativeSessionId,
1502
- resumable: true
1503
- }
1704
+ sessionRef: rollout.id ? { adapter: this.definition.name, nativeSessionId: rollout.id, resumable: true } : result.sessionRef,
1705
+ meta: usage ? { ...result.meta, usage } : result.meta
1504
1706
  };
1505
1707
  }
1506
1708
  static fromConfig(config) {
@@ -1780,11 +1982,11 @@ async function commandCheck(name, command, installHint) {
1780
1982
  };
1781
1983
  }
1782
1984
  async function runDoctor(options) {
1783
- const rootDir = path5.resolve(options.rootDir);
1985
+ const rootDir = path6.resolve(options.rootDir);
1784
1986
  const checks = [];
1785
1987
  checks.push(checkNode());
1786
- let sessionsDir = path5.resolve(rootDir, ".agent-relay/sessions");
1787
- let logsDir = path5.resolve(rootDir, ".agent-relay/logs");
1988
+ let sessionsDir = path6.resolve(rootDir, ".agent-relay/sessions");
1989
+ let logsDir = path6.resolve(rootDir, ".agent-relay/logs");
1788
1990
  try {
1789
1991
  const config = await loadConfig(rootDir);
1790
1992
  checks.push({
@@ -1792,8 +1994,8 @@ async function runDoctor(options) {
1792
1994
  level: "ok",
1793
1995
  detail: `Valid config (defaultAdapter: ${config.defaultAdapter})`
1794
1996
  });
1795
- sessionsDir = path5.resolve(rootDir, config.sessionsDir);
1796
- logsDir = path5.resolve(rootDir, config.logsDir);
1997
+ sessionsDir = path6.resolve(rootDir, config.sessionsDir);
1998
+ logsDir = path6.resolve(rootDir, config.logsDir);
1797
1999
  } catch (err) {
1798
2000
  const message = err instanceof Error ? err.message : String(err);
1799
2001
  const missing = message.includes("not found");
@@ -1909,7 +2111,7 @@ var RunLogger = class {
1909
2111
  if (this.opts.maxBytes && this.bytes >= this.opts.maxBytes) {
1910
2112
  if (!this.truncated) {
1911
2113
  this.truncated = true;
1912
- fs6.appendFileSync(
2114
+ fs7.appendFileSync(
1913
2115
  this.logFile,
1914
2116
  `... [log truncated at ${this.opts.maxBytes} bytes \u2014 raise --max-log-bytes for more]
1915
2117
  `
@@ -1917,12 +2119,12 @@ var RunLogger = class {
1917
2119
  }
1918
2120
  return;
1919
2121
  }
1920
- fs6.appendFileSync(this.logFile, text);
2122
+ fs7.appendFileSync(this.logFile, text);
1921
2123
  this.bytes += Buffer.byteLength(text);
1922
2124
  }
1923
2125
  /** Ensure the log directory exists and write the run header. */
1924
2126
  start(header) {
1925
- fs6.mkdirSync(path5.dirname(this.logFile), { recursive: true });
2127
+ fs7.mkdirSync(path6.dirname(this.logFile), { recursive: true });
1926
2128
  const lines = [
1927
2129
  "================ agent-relay run ================",
1928
2130
  `sessionId : ${header.sessionId}`,
@@ -1976,6 +2178,27 @@ var RunLogger = class {
1976
2178
  this.append(lines.join("\n"));
1977
2179
  }
1978
2180
  };
2181
+
2182
+ // src/core/pricing.ts
2183
+ var DEFAULT_PRICING = [
2184
+ { match: /opus/i, pricing: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
2185
+ { match: /sonnet/i, pricing: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
2186
+ { match: /haiku/i, pricing: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } }
2187
+ ];
2188
+ function pricingForModel(model, overrides = []) {
2189
+ if (!model) return void 0;
2190
+ for (const rule of [...overrides, ...DEFAULT_PRICING]) {
2191
+ if (rule.match.test(model)) return rule.pricing;
2192
+ }
2193
+ return void 0;
2194
+ }
2195
+ function computeCostUsd(usage, overrides = []) {
2196
+ const p = pricingForModel(usage.model, overrides);
2197
+ if (!p) return null;
2198
+ const M = 1e6;
2199
+ const cost = ((usage.inputTokens ?? 0) * p.input + (usage.outputTokens ?? 0) * p.output + (usage.cacheCreationTokens ?? 0) * (p.cacheWrite ?? p.input) + (usage.cachedInputTokens ?? 0) * (p.cacheRead ?? 0)) / M;
2200
+ return Math.round(cost * 1e6) / 1e6;
2201
+ }
1979
2202
  var SessionManager = class {
1980
2203
  constructor(sessionsDir) {
1981
2204
  this.sessionsDir = sessionsDir;
@@ -1983,7 +2206,7 @@ var SessionManager = class {
1983
2206
  sessionsDir;
1984
2207
  /** Absolute path to a session's JSON metadata file. */
1985
2208
  filePath(sessionId) {
1986
- return path5.join(this.sessionsDir, `${sessionId}.json`);
2209
+ return path6.join(this.sessionsDir, `${sessionId}.json`);
1987
2210
  }
1988
2211
  /** Build a fresh session record in the `created` state (not yet persisted). */
1989
2212
  create(input) {
@@ -2050,7 +2273,7 @@ var SessionManager = class {
2050
2273
  if (!entry.endsWith(".json")) continue;
2051
2274
  try {
2052
2275
  const raw = await promises.readFile(
2053
- path5.join(this.sessionsDir, entry),
2276
+ path6.join(this.sessionsDir, entry),
2054
2277
  "utf8"
2055
2278
  );
2056
2279
  metas.push(JSON.parse(raw));
@@ -2074,12 +2297,12 @@ async function runAgent(options) {
2074
2297
  }
2075
2298
  const adapter = options.resolveAdapter(adapterName, config);
2076
2299
  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);
2300
+ const rootDir = path6.resolve(options.rootDir);
2301
+ const cwd = path6.resolve(options.cwd ?? rootDir);
2302
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
2303
+ const logsDir = path6.resolve(rootDir, config.logsDir);
2081
2304
  const sessionId = options.sessionId ?? randomUUID();
2082
- const logFile = path5.join(logsDir, `${sessionId}.log`);
2305
+ const logFile = path6.join(logsDir, `${sessionId}.log`);
2083
2306
  const sessions = new SessionManager(sessionsDir);
2084
2307
  const startedAt = iso();
2085
2308
  const session = sessions.create({
@@ -2297,6 +2520,28 @@ async function runAgent(options) {
2297
2520
  }
2298
2521
  externalSignal?.removeEventListener("abort", onExternalAbort);
2299
2522
  }
2523
+ if (result?.meta && typeof result.meta === "object") {
2524
+ const usage = result.meta.usage;
2525
+ if (usage && (usage.inputTokens != null || usage.outputTokens != null)) {
2526
+ const overrides = (config.pricing ?? []).map((r) => ({
2527
+ match: new RegExp(r.match, "i"),
2528
+ pricing: {
2529
+ input: r.input,
2530
+ output: r.output,
2531
+ cacheWrite: r.cacheWrite,
2532
+ cacheRead: r.cacheRead
2533
+ }
2534
+ }));
2535
+ usage.costUsd = computeCostUsd(usage, overrides);
2536
+ if (usage.costUsd === null) {
2537
+ onEvent({
2538
+ type: "status",
2539
+ timestamp: iso(),
2540
+ text: `usage: no price for model ${usage.model ? `"${usage.model}"` : "(unknown)"} \u2192 costUsd=null (set config.pricing to override)`
2541
+ });
2542
+ }
2543
+ }
2544
+ }
2300
2545
  const status = detector.detect({ result, events, abortReason, error: runError }) ?? "failed";
2301
2546
  const endedAt = iso();
2302
2547
  const error = runError && !result?.error ? { message: runError.message, code: "ADAPTER_THREW" } : result?.error ?? (abortReason === "timeout" || abortReason === "idle" ? {
@@ -2375,7 +2620,7 @@ async function resolvePrompt(options) {
2375
2620
  );
2376
2621
  }
2377
2622
  if (options.promptFile) {
2378
- const file = path5.resolve(options.rootDir, options.promptFile);
2623
+ const file = path6.resolve(options.rootDir, options.promptFile);
2379
2624
  try {
2380
2625
  const text = await promises.readFile(file, "utf8");
2381
2626
  if (!text.trim()) {
@@ -2402,7 +2647,7 @@ async function resolvePrompt(options) {
2402
2647
  );
2403
2648
  }
2404
2649
  async function runCommand(options) {
2405
- const rootDir = path5.resolve(options.rootDir);
2650
+ const rootDir = path6.resolve(options.rootDir);
2406
2651
  const config = options.config ?? await loadConfigOrDefault(rootDir, options.onDefaultConfig);
2407
2652
  const prompt = await resolvePrompt({
2408
2653
  prompt: options.prompt,
@@ -2434,9 +2679,9 @@ async function runCommand(options) {
2434
2679
  });
2435
2680
  }
2436
2681
  async function resumeCommand(options) {
2437
- const rootDir = path5.resolve(options.rootDir);
2682
+ const rootDir = path6.resolve(options.rootDir);
2438
2683
  const config = options.config ?? await loadConfigOrDefault(rootDir, options.onDefaultConfig);
2439
- const sessionsDir = path5.resolve(rootDir, config.sessionsDir);
2684
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
2440
2685
  const sessions = new SessionManager(sessionsDir);
2441
2686
  const session = await sessions.load(options.sessionId);
2442
2687
  const resolveAdapter = options.resolveAdapter ?? createAdapterFactory();
@@ -2487,9 +2732,9 @@ async function resumeCommand(options) {
2487
2732
  return { session, resumable, resumed: true, outcome };
2488
2733
  }
2489
2734
  async function resolveManager(opts) {
2490
- const rootDir = path5.resolve(opts.rootDir);
2735
+ const rootDir = path6.resolve(opts.rootDir);
2491
2736
  const config = await loadConfigOrDefault(rootDir, opts.onDefaultConfig);
2492
- const sessionsDir = path5.resolve(rootDir, config.sessionsDir);
2737
+ const sessionsDir = path6.resolve(rootDir, config.sessionsDir);
2493
2738
  const sessions = new SessionManager(sessionsDir);
2494
2739
  return { sessions, metas: await sessions.list() };
2495
2740
  }
@@ -2810,7 +3055,7 @@ program.command("run").description("Run a prompt through an agent adapter").opti
2810
3055
  "api decider: OpenAI-compatible chat-completions URL"
2811
3056
  ).option("--decider-model <model>", "api decider: model name").option("--decider-key <key>", "api decider: bearer API key").option(
2812
3057
  "--decider-max-tokens <n>",
2813
- "api decider: max tokens",
3058
+ "api decider: max tokens (default 2048; floor 512 \u2014 reasoning models need headroom)",
2814
3059
  positiveIntArg("--decider-max-tokens")
2815
3060
  ).option(
2816
3061
  "--decider-timeout-ms <n>",
@@ -2882,7 +3127,7 @@ program.command("resume").description("Inspect a prior session and optionally se
2882
3127
  "Decider override: rule | always-approve | command | api"
2883
3128
  ).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
3129
  "--decider-max-tokens <n>",
2885
- "api decider: max tokens",
3130
+ "api decider: max tokens (default 2048; floor 512 \u2014 reasoning models need headroom)",
2886
3131
  positiveIntArg("--decider-max-tokens")
2887
3132
  ).option(
2888
3133
  "--decider-timeout-ms <n>",