@sagentlab/navarch-runtime 0.1.20 → 0.1.21

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.
@@ -6,6 +6,7 @@ exports.hasExplicitPermissionPolicy = hasExplicitPermissionPolicy;
6
6
  const node_child_process_1 = require("node:child_process");
7
7
  const node_fs_1 = require("node:fs");
8
8
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
9
+ const codex_pricing_cjs_1 = require("../codex-pricing.cjs");
9
10
  /**
10
11
  * Headless OpenAI Codex CLI adapter — the Codex sibling of claude.cts's
11
12
  * `runClaudeCodeAdapter`, implementing the same AgentAdapter interface
@@ -63,7 +64,7 @@ async function runCodexAdapter(options) {
63
64
  const raw = runOptions.dockerExec
64
65
  ? await runViaDocker(runOptions, args)
65
66
  : await runOnHost(runOptions, args);
66
- return attachUsage(raw);
67
+ return attachUsage(raw, options.model);
67
68
  }
68
69
  /** Operator policy wins over Navarch's generated host-mode profile. */
69
70
  function hasExplicitPermissionPolicy(args) {
@@ -139,13 +140,15 @@ function safeEnvSegment(value) {
139
140
  /**
140
141
  * Parses stdout for `codex exec --json` usage/final-message events and folds
141
142
  * them onto the raw result. Codex currently reports token counts but no USD
142
- * cost, so costUsd remains absent unless the stream explicitly provides one.
143
+ * cost, so known models receive a standard-tier API-equivalent estimate. An
144
+ * explicit cost from a compatible future event always takes precedence.
143
145
  */
144
- function attachUsage(result) {
146
+ function attachUsage(result, model) {
145
147
  const events = (0, exit_conditions_cjs_1.parseCodexJsonEvents)(result.stdout);
146
148
  if (events.length === 0)
147
149
  return result;
148
150
  const usage = (0, exit_conditions_cjs_1.extractUsageFromCodexEvents)(events);
151
+ const costUsd = usage.costUsd ?? (0, codex_pricing_cjs_1.estimateCodexCostUsd)(events, model);
149
152
  const reportText = (0, exit_conditions_cjs_1.extractFinalMessageFromCodexEvents)(events) ?? undefined;
150
153
  return {
151
154
  ...result,
@@ -153,7 +156,7 @@ function attachUsage(result) {
153
156
  // measured zero — leave the fields unset so aggregation skips them.
154
157
  ...(usage.tokensIn !== undefined ? { tokensIn: usage.tokensIn } : {}),
155
158
  ...(usage.tokensOut !== undefined ? { tokensOut: usage.tokensOut } : {}),
156
- ...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}),
159
+ ...(costUsd !== undefined ? { costUsd } : {}),
157
160
  ...(reportText !== undefined ? { reportText } : {}),
158
161
  };
159
162
  }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.estimateCodexCostUsd = estimateCodexCostUsd;
4
+ /**
5
+ * Standard-tier USD prices per 1M tokens. Codex's JSONL stream reports usage
6
+ * but not dollars, so the runtime needs a local price snapshot to turn that
7
+ * usage into project spend. Keep this deliberately limited to models Navarch
8
+ * offers rather than silently applying the wrong price to custom/gateway ids.
9
+ *
10
+ * Source (checked 2026-08-05): https://developers.openai.com/api/docs/pricing
11
+ */
12
+ const CODEX_RATES = {
13
+ "gpt-5.6-sol": {
14
+ input: 5,
15
+ cachedInput: 0.5,
16
+ cacheWrite: 6.25,
17
+ output: 30,
18
+ },
19
+ };
20
+ function nonnegative(value) {
21
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
22
+ }
23
+ function priceUsage(usage, rates) {
24
+ const input = nonnegative(usage.input);
25
+ // Cached reads and cache writes are components of input_tokens, not extra
26
+ // tokens. Clamp malformed/provider-newer detail fields to the reported total
27
+ // so an odd event cannot produce negative uncached input or inflated spend.
28
+ const cachedInput = Math.min(input, nonnegative(usage.cachedInput));
29
+ const cacheWrite = Math.min(input - cachedInput, nonnegative(usage.cacheWrite));
30
+ const uncachedInput = input - cachedInput - cacheWrite;
31
+ const output = nonnegative(usage.output);
32
+ return (uncachedInput * rates.input +
33
+ cachedInput * rates.cachedInput +
34
+ cacheWrite * rates.cacheWrite +
35
+ output * rates.output) / 1_000_000;
36
+ }
37
+ /**
38
+ * Estimate standard-tier API-equivalent USD cost for a Codex JSONL run.
39
+ * Verified turn.completed events are per-turn and must be summed; legacy
40
+ * token_count envelopes are cumulative, so only their final total is priced.
41
+ * Returns undefined for unknown models or when no usage event was observed.
42
+ */
43
+ function estimateCodexCostUsd(events, model) {
44
+ if (!model)
45
+ return undefined;
46
+ const rates = CODEX_RATES[model];
47
+ if (!rates)
48
+ return undefined;
49
+ let sawTurnUsage = false;
50
+ let turnCost = 0;
51
+ let legacyUsage;
52
+ for (const event of events) {
53
+ if (event.type === "turn.completed" && event.usage) {
54
+ sawTurnUsage = true;
55
+ turnCost += priceUsage({
56
+ input: event.usage.input_tokens,
57
+ cachedInput: event.usage.cached_input_tokens,
58
+ cacheWrite: event.usage.cache_write_input_tokens,
59
+ output: event.usage.output_tokens,
60
+ }, rates);
61
+ }
62
+ if (event.msg?.type === "token_count") {
63
+ const usage = event.msg.info?.total_token_usage;
64
+ legacyUsage = usage
65
+ ? {
66
+ input: usage.input_tokens,
67
+ cachedInput: usage.cached_input_tokens,
68
+ cacheWrite: usage.cache_write_input_tokens,
69
+ output: usage.output_tokens,
70
+ }
71
+ : {
72
+ // In the oldest flat envelope cached_input_tokens was additional
73
+ // to input_tokens; normalize it to the newer inclusive total.
74
+ input: nonnegative(event.msg.input_tokens) + nonnegative(event.msg.cached_input_tokens),
75
+ cachedInput: event.msg.cached_input_tokens,
76
+ output: event.msg.output_tokens,
77
+ };
78
+ }
79
+ if (event.type === "event_msg" && event.payload?.type === "token_count") {
80
+ const usage = event.payload.info?.total_token_usage;
81
+ if (usage) {
82
+ legacyUsage = {
83
+ input: usage.input_tokens,
84
+ cachedInput: usage.cached_input_tokens,
85
+ cacheWrite: usage.cache_write_input_tokens,
86
+ output: usage.output_tokens,
87
+ };
88
+ }
89
+ }
90
+ }
91
+ if (sawTurnUsage)
92
+ return turnCost;
93
+ return legacyUsage ? priceUsage(legacyUsage, rates) : undefined;
94
+ }
@@ -24,6 +24,8 @@ class GitWorktree {
24
24
  cloneUrl;
25
25
  githubToken;
26
26
  taskBranchSuffix;
27
+ legacyTaskBranchSuffix;
28
+ taskOwnershipTrailer;
27
29
  constructor(options) {
28
30
  const projectKey = safePathSegment(options.projectId);
29
31
  const sessionKey = safePathSegment(options.sessionId);
@@ -34,7 +36,10 @@ class GitWorktree {
34
36
  // One delivery task owns one remote branch across every retry. A
35
37
  // session-scoped branch lets a failed attempt push useful work, then makes
36
38
  // the retry start from main and open a second PR for the same task.
37
- this.taskBranchSuffix = `-${taskKey.toLowerCase()}`;
39
+ const normalizedTaskId = taskKey.toLowerCase();
40
+ this.taskBranchSuffix = `-${normalizedTaskId.slice(0, 8)}`;
41
+ this.legacyTaskBranchSuffix = `-${normalizedTaskId}`;
42
+ this.taskOwnershipTrailer = `Navarch-Task-ID: ${normalizedTaskId}`;
38
43
  this.branch = `navarch/${branchSlug(options)}${this.taskBranchSuffix}`;
39
44
  this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
40
45
  this.cloneUrl = options.cloneUrl;
@@ -84,11 +89,13 @@ class GitWorktree {
84
89
  // A failed session may have pushed before it could report completion. The
85
90
  // next session must resume that task-owned branch, not fork again from the
86
91
  // default branch and create a competing delivery PR.
87
- const startRef = (await this.resolveTaskBranchRef()) ?? (await this.resolveStartRef());
92
+ const taskRef = await this.resolveTaskBranchRef();
93
+ const startRef = taskRef ?? (await this.resolveStartRef());
88
94
  if (!startRef) {
89
95
  // Empty remote (no commits yet): there is nothing to base the session on,
90
96
  // so bootstrap an orphan branch the session can push as the first commit.
91
97
  await this.addOrphanWorktree();
98
+ await this.createTaskOwnershipCommit();
92
99
  return;
93
100
  }
94
101
  await this.runGit([
@@ -101,16 +108,32 @@ class GitWorktree {
101
108
  this.worktreePath,
102
109
  startRef,
103
110
  ], false);
111
+ if (!taskRef)
112
+ await this.createTaskOwnershipCommit();
104
113
  }
105
114
  /** Returns the fetched task-owned remote branch when an earlier attempt pushed it. */
106
115
  async resolveTaskBranchRef() {
107
116
  const refs = await this.runGit(["--git-dir", this.repositoryPath, "for-each-ref", "--format=%(refname)", "refs/remotes/origin/navarch"], false);
108
- const matches = refs.stdout
117
+ const suffixMatches = refs.stdout
109
118
  .split("\n")
110
119
  .map((line) => line.trim())
111
120
  .filter((ref) => ref.endsWith(this.taskBranchSuffix));
112
- if (matches.length === 0)
121
+ const legacyMatches = refs.stdout
122
+ .split("\n")
123
+ .map((line) => line.trim())
124
+ .filter((ref) => ref.endsWith(this.legacyTaskBranchSuffix));
125
+ const shortMatches = suffixMatches.filter((ref) => !legacyMatches.includes(ref));
126
+ const ownedShortMatches = (await Promise.all(shortMatches.map(async (ref) => ({ ref, owned: await this.hasTaskOwnershipMarker(ref) }))))
127
+ .filter(({ owned }) => owned)
128
+ .map(({ ref }) => ref);
129
+ const matches = [...ownedShortMatches, ...legacyMatches];
130
+ if (matches.length === 0) {
131
+ if (shortMatches.length > 0) {
132
+ throw new Error(`Remote branch ${shortMatches[0]} shares task suffix ${this.taskBranchSuffix} but does not ` +
133
+ `contain ownership marker ${this.taskOwnershipTrailer}; refusing to resume or fork.`);
134
+ }
113
135
  return null;
136
+ }
114
137
  if (matches.length > 1) {
115
138
  throw new Error(`Multiple remote branches claim task suffix ${this.taskBranchSuffix}; refusing to fork another delivery branch.`);
116
139
  }
@@ -118,6 +141,38 @@ class GitWorktree {
118
141
  this.branch = remoteRef.replace(/^refs\/remotes\/origin\//, "");
119
142
  return remoteRef;
120
143
  }
144
+ /** Short branch names are ambiguous without a full task-ID marker in their history. */
145
+ async hasTaskOwnershipMarker(ref) {
146
+ const log = await this.runGit([
147
+ "--git-dir",
148
+ this.repositoryPath,
149
+ "log",
150
+ "--format=%B",
151
+ "--fixed-strings",
152
+ `--grep=${this.taskOwnershipTrailer}`,
153
+ ref,
154
+ ], false);
155
+ return log.stdout
156
+ .split("\n")
157
+ .some((line) => line.trim() === this.taskOwnershipTrailer);
158
+ }
159
+ /** Records full task ownership without lengthening the human-facing branch name. */
160
+ async createTaskOwnershipCommit() {
161
+ await this.runGit([
162
+ "-C",
163
+ this.worktreePath,
164
+ "-c",
165
+ "user.name=Navarch",
166
+ "-c",
167
+ "user.email=runtime@navarch.local",
168
+ "commit",
169
+ "--allow-empty",
170
+ "-m",
171
+ "chore(navarch): claim task branch",
172
+ "-m",
173
+ this.taskOwnershipTrailer,
174
+ ], false);
175
+ }
121
176
  /**
122
177
  * Resolves the remote-tracking ref new session branches start from, or null
123
178
  * when the remote has no branches at all (a freshly provisioned empty repo).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",