@open-cr-agent/runtime-opencode 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/dist/binary.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  const PLATFORMS = { darwin: "darwin", linux: "linux", win32: "windows" };
6
6
  // Resolved from opencode-ai's platform packages directly, because newer npm
7
7
  // versions block the postinstall script that would otherwise place it.
8
8
  export function resolveOpencodeBinary(env) {
9
9
  const override = env.OCRA_OPENCODE_BIN;
10
+ // OpenCode runs in a directory of its own, where a relative path would
11
+ // not resolve; a bare name is still looked up in PATH.
10
12
  if (override)
11
- return override;
13
+ return /[\\/]/.test(override) ? resolve(override) : override;
12
14
  const platform = PLATFORMS[process.platform] ?? process.platform;
13
15
  const executable = platform === "windows" ? "opencode.exe" : "opencode";
14
16
  const fromOpencode = createRequire(createRequire(import.meta.url).resolve("opencode-ai/package.json"));
@@ -1,4 +1,4 @@
1
- import { type AgentEvent, type ModelTier } from "@open-cr-agent/core";
1
+ import { type AgentEvent, type ModelTier, type Usage } from "@open-cr-agent/core";
2
2
  import type { ModelHealth } from "./models.js";
3
3
  import type { SessionOutcome } from "./session-outcome.js";
4
4
  export interface FailbackOptions {
@@ -7,8 +7,17 @@ export interface FailbackOptions {
7
7
  chain: readonly string[];
8
8
  health: ModelHealth;
9
9
  signal: AbortSignal;
10
- attempt(model: string): Promise<SessionOutcome>;
10
+ attempt(model: string, onUsage: (spent: Usage) => void): Promise<SessionOutcome>;
11
11
  }
12
12
  export declare function withFailback(options: FailbackOptions): AsyncGenerator<AgentEvent>;
13
+ export declare class LiveUsage {
14
+ private seen;
15
+ private given;
16
+ private wake;
17
+ observe(spent: Usage): void;
18
+ changed(): Promise<void>;
19
+ take(): Usage;
20
+ rest(total: Usage): Usage;
21
+ }
13
22
  export declare function toolSummary(toolCalls: readonly string[]): string;
14
23
  //# sourceMappingURL=failback.d.ts.map
package/dist/failback.js CHANGED
@@ -1,4 +1,4 @@
1
- import { REVIEW_TOOLS } from "@open-cr-agent/core";
1
+ import { emptyUsage, REVIEW_TOOLS, } from "@open-cr-agent/core";
2
2
  import { sleep } from "./quota.js";
3
3
  // Findings from a failed attempt are still emitted: the pipeline deduplicates
4
4
  // by fingerprint, so a retry on the next model cannot double-report them.
@@ -21,8 +21,16 @@ export async function* withFailback(options) {
21
21
  return;
22
22
  }
23
23
  yield { type: "progress", taskId, message: `reviewing with ${model}` };
24
- const outcome = await options.attempt(model);
25
- yield { type: "usage", taskId, ...outcome.usage };
24
+ // Spend is reported while the attempt runs, so a run's spend limit
25
+ // can stop it; the finished attempt's total settles the rest.
26
+ const live = new LiveUsage();
27
+ const running = options.attempt(model, (spent) => live.observe(spent));
28
+ const finished = running.then(() => false, () => false);
29
+ while (await Promise.race([finished, live.changed().then(() => true)])) {
30
+ yield { type: "usage", taskId, ...live.take() };
31
+ }
32
+ const outcome = await running;
33
+ yield { type: "usage", taskId, ...live.rest(outcome.usage) };
26
34
  yield { type: "progress", taskId, message: attemptSummary(model, outcome) };
27
35
  for (const finding of outcome.findings)
28
36
  yield { type: "finding", taskId, finding };
@@ -59,6 +67,56 @@ export async function* withFailback(options) {
59
67
  retryable: true,
60
68
  };
61
69
  }
70
+ // Hands out what an attempt has spent in increments, each what grew since the
71
+ // last one; `rest` settles the finished attempt's total, so the increments
72
+ // add up to it and nothing is counted twice.
73
+ export class LiveUsage {
74
+ seen = emptyUsage();
75
+ given = emptyUsage();
76
+ wake;
77
+ observe(spent) {
78
+ this.seen = larger(this.seen, spent);
79
+ if (ahead(this.seen, this.given)) {
80
+ this.wake?.();
81
+ this.wake = undefined;
82
+ }
83
+ }
84
+ // Resolves once more has been seen than handed out.
85
+ changed() {
86
+ if (ahead(this.seen, this.given))
87
+ return Promise.resolve();
88
+ return new Promise((resolve) => {
89
+ this.wake = resolve;
90
+ });
91
+ }
92
+ take() {
93
+ const increment = beyond(this.seen, this.given);
94
+ this.given = this.seen;
95
+ return increment;
96
+ }
97
+ rest(total) {
98
+ const settled = larger(total, this.given);
99
+ const increment = beyond(settled, this.given);
100
+ this.given = settled;
101
+ return increment;
102
+ }
103
+ }
104
+ const FIELDS = [
105
+ "inputTokens",
106
+ "outputTokens",
107
+ "reasoningTokens",
108
+ "cachedTokens",
109
+ "costUsd",
110
+ ];
111
+ function larger(a, b) {
112
+ return Object.fromEntries(FIELDS.map((f) => [f, Math.max(a[f], b[f])]));
113
+ }
114
+ function beyond(a, b) {
115
+ return Object.fromEntries(FIELDS.map((f) => [f, Math.max(0, a[f] - b[f])]));
116
+ }
117
+ function ahead(a, b) {
118
+ return FIELDS.some((f) => a[f] > b[f]);
119
+ }
62
120
  // Which tools an attempt spent its steps on, and whether it finished: a
63
121
  // review that never called task_done was cut off, usually by the step cap.
64
122
  function attemptSummary(model, outcome) {
@@ -6,6 +6,7 @@ export interface OpencodeServer {
6
6
  }
7
7
  export interface StartOptions {
8
8
  binary: string;
9
+ cwd: string;
9
10
  env: Record<string, string>;
10
11
  config: unknown;
11
12
  startupTimeoutMs?: number;
@@ -6,6 +6,7 @@ export async function startOpencodeServer(options) {
6
6
  const port = await freePort();
7
7
  const password = randomBytes(24).toString("hex");
8
8
  const child = spawn(options.binary, ["serve", "--hostname=127.0.0.1", `--port=${port}`], {
9
+ cwd: options.cwd,
9
10
  env: {
10
11
  ...options.env,
11
12
  OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config),
package/dist/runtime.js CHANGED
@@ -92,7 +92,7 @@ export class OpenCodeRuntime {
92
92
  chain,
93
93
  health: this.health,
94
94
  signal,
95
- attempt: (model) => this.prompt(infra, {
95
+ attempt: (model, onUsage) => this.prompt(infra, {
96
96
  title: `ocra ${spec.taskId}`,
97
97
  agent: REVIEW_AGENT,
98
98
  model,
@@ -100,7 +100,7 @@ export class OpenCodeRuntime {
100
100
  user: spec.userPrompt,
101
101
  tools: DISABLED_BUILTINS,
102
102
  resume: REVIEW_RESUME,
103
- }, signal),
103
+ }, signal, onUsage),
104
104
  });
105
105
  }
106
106
  async complete(request, signal) {
@@ -154,8 +154,8 @@ export class OpenCodeRuntime {
154
154
  await Promise.allSettled([infra.server.close(), infra.tools.close(), infra.dispatcher.close()]);
155
155
  await rm(infra.root, { recursive: true, force: true });
156
156
  }
157
- prompt(infra, input, signal) {
158
- return promptSession(infra.client.session, input, `${MCP_SERVER}_${REVIEW_TOOLS.reportFinding}`, signal);
157
+ prompt(infra, input, signal, onUsage) {
158
+ return promptSession(infra.client.session, input, `${MCP_SERVER}_${REVIEW_TOOLS.reportFinding}`, signal, onUsage ? { onUsage } : {});
159
159
  }
160
160
  start() {
161
161
  this.infra ??= this.launch();
@@ -178,6 +178,7 @@ export class OpenCodeRuntime {
178
178
  try {
179
179
  const server = await startOpencodeServer({
180
180
  binary: this.options.binary ?? resolveOpencodeBinary(this.options.env),
181
+ cwd: workspace,
181
182
  env: serverEnv(this.options.env, dirs, providersOf(this.options.models)),
182
183
  config: openCodeConfig(tools, this.helperTools),
183
184
  });
@@ -45,4 +45,5 @@ export interface SessionOutcome {
45
45
  };
46
46
  }
47
47
  export declare function summarizeSession(messages: readonly SessionMessage[], reportTool: string): SessionOutcome;
48
+ export declare function sessionUsage(messages: readonly SessionMessage[]): Usage;
48
49
  //# sourceMappingURL=session-outcome.d.ts.map
@@ -14,13 +14,7 @@ export function summarizeSession(messages, reportTool) {
14
14
  .flatMap((m) => m.parts.filter((p) => p.type === "text").map((p) => p.text ?? ""))
15
15
  .join("\n")
16
16
  .trim(),
17
- usage: {
18
- inputTokens: sum(assistant, (m) => m.info.tokens?.input),
19
- outputTokens: sum(assistant, (m) => m.info.tokens?.output),
20
- reasoningTokens: sum(assistant, (m) => m.info.tokens?.reasoning),
21
- cachedTokens: sum(assistant, (m) => m.info.tokens?.cache?.read),
22
- costUsd: sum(assistant, (m) => m.info.cost),
23
- },
17
+ usage: sessionUsage(messages),
24
18
  };
25
19
  const errors = assistant.flatMap((m) => (m.info.error ? [m.info.error] : []));
26
20
  const last = errors.at(-1);
@@ -36,6 +30,18 @@ export function summarizeSession(messages, reportTool) {
36
30
  }
37
31
  return outcome;
38
32
  }
33
+ // Every finished step carries its tokens and cost, so this grows while the
34
+ // session runs.
35
+ export function sessionUsage(messages) {
36
+ const assistant = messages.filter((m) => m.info.role === "assistant");
37
+ return {
38
+ inputTokens: sum(assistant, (m) => m.info.tokens?.input),
39
+ outputTokens: sum(assistant, (m) => m.info.tokens?.output),
40
+ reasoningTokens: sum(assistant, (m) => m.info.tokens?.reasoning),
41
+ cachedTokens: sum(assistant, (m) => m.info.tokens?.cache?.read),
42
+ costUsd: sum(assistant, (m) => m.info.cost),
43
+ };
44
+ }
39
45
  function isAuthError(error) {
40
46
  return (error.name === "ProviderAuthError" ||
41
47
  AUTH_STATUS.has(error.data?.statusCode ?? 0) ||
@@ -1,11 +1,13 @@
1
+ import { type Usage } from "@open-cr-agent/core";
1
2
  import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2
3
  import { type SessionMessage, type SessionOutcome } from "./session-outcome.js";
3
4
  export declare const HARVEST_TIMEOUT_MS = 5000;
4
5
  export declare const INACTIVITY_MS: number;
5
- export declare const ACTIVITY_POLL_MS = 30000;
6
+ export declare const ACTIVITY_POLL_MS = 10000;
6
7
  export interface ActivityOptions {
7
8
  inactivityMs?: number;
8
9
  pollMs?: number;
10
+ onUsage?: (spent: Usage) => void;
9
11
  }
10
12
  export interface PromptInput {
11
13
  title: string;
@@ -1,6 +1,6 @@
1
1
  import { emptyUsage, errorMessage } from "@open-cr-agent/core";
2
2
  import { parseModel } from "./models.js";
3
- import { summarizeSession } from "./session-outcome.js";
3
+ import { sessionUsage, summarizeSession, } from "./session-outcome.js";
4
4
  // A session that was cut off has still spent tokens and may have reported
5
5
  // findings; this bounds the one extra request that collects them.
6
6
  export const HARVEST_TIMEOUT_MS = 5_000;
@@ -10,7 +10,10 @@ export const HARVEST_TIMEOUT_MS = 5_000;
10
10
  // stopped and the task moves to the next model instead of waiting for its
11
11
  // full timeout. Generous, so a slow step that is still writing survives.
12
12
  export const INACTIVITY_MS = 5 * 60_000;
13
- export const ACTIVITY_POLL_MS = 30_000;
13
+ // Each poll also reports what the session has spent, and a run's spend limit
14
+ // stops running tasks on those reports: this bounds how far past the limit a
15
+ // task can get before it is stopped.
16
+ export const ACTIVITY_POLL_MS = 10_000;
14
17
  // About one review attempt in twelve on Gemini ended after a step or two
15
18
  // with no text, no done tool and steps to spare (2026-09-28), and the task
16
19
  // counted as completed with its files unread. Continuing the same session
@@ -110,13 +113,22 @@ function watchActivity(session, sessionID, options) {
110
113
  let last = "";
111
114
  let changedAt = Date.now();
112
115
  let polling = false;
116
+ let reported = emptyUsage();
113
117
  const timer = setInterval(async () => {
114
118
  if (polling)
115
119
  return;
116
120
  polling = true;
117
121
  try {
118
122
  const messages = await session.messages({ sessionID }, { signal: AbortSignal.timeout(HARVEST_TIMEOUT_MS) });
119
- const now = activitySignature((messages.data ?? []));
123
+ const list = (messages.data ?? []);
124
+ if (options.onUsage) {
125
+ const spent = sessionUsage(list);
126
+ if (spent.costUsd > reported.costUsd || spent.inputTokens > reported.inputTokens) {
127
+ reported = spent;
128
+ options.onUsage(spent);
129
+ }
130
+ }
131
+ const now = activitySignature(list);
120
132
  if (now !== last) {
121
133
  last = now;
122
134
  changedAt = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/runtime-opencode",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode-based agent runtime for Open-CR-Agent",
5
5
  "keywords": [
6
6
  "code-review",
@@ -31,7 +31,7 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "^1.30.1",
34
- "@open-cr-agent/core": "0.1.1",
34
+ "@open-cr-agent/core": "0.1.2",
35
35
  "@opencode-ai/sdk": "1.18.32",
36
36
  "opencode-ai": "1.18.32",
37
37
  "undici": "^8.11.2",