@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 +28 -1
- package/dist/cli.js +241 -67
- package/dist/index.d.ts +80 -10
- package/dist/index.js +235 -61
- package/package.json +1 -1
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
|
|
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
|
|
4
|
-
import
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
259
|
-
const 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(
|
|
598
|
-
const base =
|
|
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(
|
|
607
|
-
const fallbacks = process.platform === "win32" ? [] : ["/usr/local/bin", "/opt/homebrew/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 =
|
|
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 =
|
|
831
|
+
const root = path6.dirname(require2.resolve("node-pty/package.json"));
|
|
737
832
|
const candidates = [
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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", "
|
|
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", "
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
1550
|
+
const m = ROLLOUT_UUID_RE.exec(path6.basename(file));
|
|
1406
1551
|
return m?.[1];
|
|
1407
1552
|
}
|
|
1408
|
-
async function
|
|
1409
|
-
const root = opts.sessionsDir ??
|
|
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
|
|
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
|
|
1562
|
+
const metaCwd = await realish2(meta.cwd);
|
|
1418
1563
|
if (metaCwd !== targetCwd) continue;
|
|
1419
1564
|
const id = meta.id ?? uuidFromRolloutFilename(file);
|
|
1420
|
-
|
|
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
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
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
|
|
1667
|
+
const rollout = await findLatestCodexRollout({
|
|
1492
1668
|
cwd: input.cwd,
|
|
1493
1669
|
sinceMs: startedAt,
|
|
1494
1670
|
sessionsDir: this.sessionsDir
|
|
1495
1671
|
});
|
|
1496
|
-
if (!
|
|
1672
|
+
if (!rollout) return result;
|
|
1673
|
+
const usage = await sumCodexUsage(rollout.file);
|
|
1497
1674
|
return {
|
|
1498
1675
|
...result,
|
|
1499
|
-
sessionRef: {
|
|
1500
|
-
|
|
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 =
|
|
1957
|
+
const rootDir = path6.resolve(options.rootDir);
|
|
1784
1958
|
const checks = [];
|
|
1785
1959
|
checks.push(checkNode());
|
|
1786
|
-
let sessionsDir =
|
|
1787
|
-
let logsDir =
|
|
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 =
|
|
1796
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
2078
|
-
const cwd =
|
|
2079
|
-
const sessionsDir =
|
|
2080
|
-
const 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
2611
|
+
const rootDir = path6.resolve(options.rootDir);
|
|
2438
2612
|
const config = options.config ?? await loadConfigOrDefault(rootDir, options.onDefaultConfig);
|
|
2439
|
-
const 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 =
|
|
2664
|
+
const rootDir = path6.resolve(opts.rootDir);
|
|
2491
2665
|
const config = await loadConfigOrDefault(rootDir, opts.onDefaultConfig);
|
|
2492
|
-
const 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>",
|