@open-cr-agent/runtime-opencode 0.1.1 → 0.2.0
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.d.ts +2 -1
- package/dist/binary.js +38 -7
- package/dist/failback.d.ts +11 -2
- package/dist/failback.js +61 -3
- package/dist/opencode-server.d.ts +1 -0
- package/dist/opencode-server.js +1 -0
- package/dist/runtime.d.ts +22 -2
- package/dist/runtime.js +40 -8
- package/dist/server-env.d.ts +5 -3
- package/dist/server-env.js +33 -6
- package/dist/session-outcome.d.ts +1 -0
- package/dist/session-outcome.js +13 -7
- package/dist/session-prompt.d.ts +3 -1
- package/dist/session-prompt.js +15 -3
- package/package.json +2 -2
package/dist/binary.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface Host {
|
|
|
5
5
|
musl: boolean;
|
|
6
6
|
baseline: boolean;
|
|
7
7
|
}
|
|
8
|
-
export declare function resolveOpencodeBinary(env: Env): string;
|
|
8
|
+
export declare function resolveOpencodeBinary(env: Env, opencodePackage?: string): string;
|
|
9
|
+
export declare function isNativeExecutable(path: string): boolean;
|
|
9
10
|
export declare function binaryVariants(host: Host): string[];
|
|
10
11
|
//# sourceMappingURL=binary.d.ts.map
|
package/dist/binary.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } 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
|
-
// versions block the postinstall script that would otherwise place it.
|
|
8
|
-
|
|
7
|
+
// versions block the postinstall script that would otherwise place it. An
|
|
8
|
+
// install without optional dependencies has no platform package, but where
|
|
9
|
+
// that script ran, it downloaded the binary into opencode-ai's own bin/.
|
|
10
|
+
export function resolveOpencodeBinary(env, opencodePackage = createRequire(import.meta.url).resolve("opencode-ai/package.json")) {
|
|
9
11
|
const override = env.OCRA_OPENCODE_BIN;
|
|
12
|
+
// OpenCode runs in a directory of its own, where a relative path would
|
|
13
|
+
// not resolve; a bare name is still looked up in PATH.
|
|
10
14
|
if (override)
|
|
11
|
-
return override;
|
|
15
|
+
return /[\\/]/.test(override) ? resolve(override) : override;
|
|
12
16
|
const platform = PLATFORMS[process.platform] ?? process.platform;
|
|
13
17
|
const executable = platform === "windows" ? "opencode.exe" : "opencode";
|
|
14
|
-
const fromOpencode = createRequire(
|
|
18
|
+
const fromOpencode = createRequire(opencodePackage);
|
|
15
19
|
for (const variant of binaryVariants(currentHost(platform))) {
|
|
16
20
|
try {
|
|
17
21
|
const pkg = fromOpencode.resolve(`opencode-${platform}-${process.arch}${variant}/package.json`);
|
|
@@ -21,7 +25,34 @@ export function resolveOpencodeBinary(env) {
|
|
|
21
25
|
}
|
|
22
26
|
catch { }
|
|
23
27
|
}
|
|
24
|
-
|
|
28
|
+
// The script names the binary opencode.exe on every platform.
|
|
29
|
+
const placed = join(dirname(opencodePackage), "bin", "opencode.exe");
|
|
30
|
+
if (isNativeExecutable(placed))
|
|
31
|
+
return placed;
|
|
32
|
+
throw new Error(`No OpenCode binary for ${platform}-${process.arch}: install without --omit=optional, or set OCRA_OPENCODE_BIN`);
|
|
33
|
+
}
|
|
34
|
+
// Until the install script replaces it, opencode-ai's bin/opencode.exe is a
|
|
35
|
+
// shell script that only prints an error, so the file must be a real
|
|
36
|
+
// executable: ELF, Mach-O (either byte order, or universal) or Windows PE.
|
|
37
|
+
export function isNativeExecutable(path) {
|
|
38
|
+
let fd;
|
|
39
|
+
try {
|
|
40
|
+
fd = openSync(path, "r");
|
|
41
|
+
const head = Buffer.alloc(4);
|
|
42
|
+
if (readSync(fd, head, 0, 4, 0) < 4)
|
|
43
|
+
return false;
|
|
44
|
+
const magic = head.readUInt32BE(0);
|
|
45
|
+
return (magic === 0x7f454c46 ||
|
|
46
|
+
[0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(magic) ||
|
|
47
|
+
head.subarray(0, 2).toString("latin1") === "MZ");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (fd !== undefined)
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
}
|
|
25
56
|
}
|
|
26
57
|
// The builds to try, best first. npm installs every build whose os and cpu
|
|
27
58
|
// match, and the glibc builds declare no libc, so musl Linux has both kinds;
|
package/dist/failback.d.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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) {
|
package/dist/opencode-server.js
CHANGED
|
@@ -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.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentEvent, type AgentRuntime, type AgentTaskSpec, type CompletionRequest, type CompletionResult, type RuntimeOptions } from "@open-cr-agent/core";
|
|
1
|
+
import { type AgentEvent, type AgentRuntime, type AgentTaskSpec, type CompletionRequest, type CompletionResult, type CustomProvider, type RuntimeOptions } from "@open-cr-agent/core";
|
|
2
2
|
import { type ToolServer } from "./tool-server.js";
|
|
3
3
|
export declare const MCP_SERVER = "ocra";
|
|
4
4
|
export declare const MAX_AGENT_STEPS = 30;
|
|
@@ -27,9 +27,29 @@ export declare class OpenCodeRuntime implements AgentRuntime {
|
|
|
27
27
|
private start;
|
|
28
28
|
private launch;
|
|
29
29
|
}
|
|
30
|
-
export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean
|
|
30
|
+
export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>, custom?: Readonly<Record<string, CustomProvider>>): {
|
|
31
31
|
share: string;
|
|
32
32
|
autoupdate: boolean;
|
|
33
|
+
provider?: {
|
|
34
|
+
[k: string]: {
|
|
35
|
+
npm: string;
|
|
36
|
+
name: string;
|
|
37
|
+
options: {
|
|
38
|
+
baseURL: string;
|
|
39
|
+
apiKey?: string;
|
|
40
|
+
};
|
|
41
|
+
models: {
|
|
42
|
+
[k: string]: {
|
|
43
|
+
name: string;
|
|
44
|
+
cost: {
|
|
45
|
+
input: number;
|
|
46
|
+
output: number;
|
|
47
|
+
cache_read?: number;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
};
|
|
33
53
|
mcp: {
|
|
34
54
|
ocra: {
|
|
35
55
|
type: string;
|
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,15 +154,16 @@ 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();
|
|
162
162
|
return this.infra;
|
|
163
163
|
}
|
|
164
164
|
async launch() {
|
|
165
|
-
const
|
|
165
|
+
const custom = this.options.providers ?? {};
|
|
166
|
+
const missing = missingCredentials(this.options.env, providersOf(this.options.models), custom);
|
|
166
167
|
if (missing.length > 0)
|
|
167
168
|
throw new Error(missing.join("; "));
|
|
168
169
|
const root = await mkdtemp(join(tmpdir(), "ocra-opencode-"));
|
|
@@ -178,8 +179,9 @@ export class OpenCodeRuntime {
|
|
|
178
179
|
try {
|
|
179
180
|
const server = await startOpencodeServer({
|
|
180
181
|
binary: this.options.binary ?? resolveOpencodeBinary(this.options.env),
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
cwd: workspace,
|
|
183
|
+
env: serverEnv(this.options.env, dirs, providersOf(this.options.models), custom),
|
|
184
|
+
config: openCodeConfig(tools, this.helperTools, custom),
|
|
183
185
|
});
|
|
184
186
|
started = server;
|
|
185
187
|
const dispatcher = createUntimedDispatcher();
|
|
@@ -205,11 +207,12 @@ export class OpenCodeRuntime {
|
|
|
205
207
|
}
|
|
206
208
|
}
|
|
207
209
|
}
|
|
208
|
-
export function openCodeConfig(tools, helperTools) {
|
|
210
|
+
export function openCodeConfig(tools, helperTools, custom = {}) {
|
|
209
211
|
const permission = { edit: "deny", bash: "deny", webfetch: "deny", skill: "deny" };
|
|
210
212
|
return {
|
|
211
213
|
share: "disabled",
|
|
212
214
|
autoupdate: false,
|
|
215
|
+
...(Object.keys(custom).length > 0 ? { provider: providerConfig(custom) } : {}),
|
|
213
216
|
mcp: {
|
|
214
217
|
[MCP_SERVER]: {
|
|
215
218
|
type: "remote",
|
|
@@ -237,6 +240,35 @@ export function openCodeConfig(tools, helperTools) {
|
|
|
237
240
|
},
|
|
238
241
|
};
|
|
239
242
|
}
|
|
243
|
+
// OpenCode's form of a provider declared in configuration. OpenCode bundles
|
|
244
|
+
// the OpenAI-compatible client, so nothing is installed to reach it
|
|
245
|
+
// (docs/spikes/0002). The key stays in the environment: OpenCode reads
|
|
246
|
+
// {env:NAME} itself, and the configuration, which OpenCode may log, never
|
|
247
|
+
// holds it. Prices are per million tokens, as OpenCode's catalog has them.
|
|
248
|
+
function providerConfig(custom) {
|
|
249
|
+
return Object.fromEntries(Object.entries(custom).map(([id, provider]) => [
|
|
250
|
+
id,
|
|
251
|
+
{
|
|
252
|
+
npm: "@ai-sdk/openai-compatible",
|
|
253
|
+
name: id,
|
|
254
|
+
options: {
|
|
255
|
+
baseURL: provider.baseUrl,
|
|
256
|
+
...(provider.apiKeyEnv ? { apiKey: `{env:${provider.apiKeyEnv}}` } : {}),
|
|
257
|
+
},
|
|
258
|
+
models: Object.fromEntries(Object.entries(provider.models).map(([model, price]) => [
|
|
259
|
+
model,
|
|
260
|
+
{
|
|
261
|
+
name: model,
|
|
262
|
+
cost: {
|
|
263
|
+
input: price.input,
|
|
264
|
+
output: price.output,
|
|
265
|
+
...(price.cachedInput === undefined ? {} : { cache_read: price.cachedInput }),
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
])),
|
|
269
|
+
},
|
|
270
|
+
]));
|
|
271
|
+
}
|
|
240
272
|
function providersOf(models) {
|
|
241
273
|
return Object.values(models).flatMap((chain) => (chain ?? []).map((m) => parseModel(m).providerID));
|
|
242
274
|
}
|
package/dist/server-env.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { Env } from "@open-cr-agent/core";
|
|
1
|
+
import type { CustomProvider, Env } from "@open-cr-agent/core";
|
|
2
2
|
export interface IsolatedDirs {
|
|
3
3
|
config: string;
|
|
4
4
|
data: string;
|
|
5
5
|
state: string;
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
type CustomProviders = Readonly<Record<string, CustomProvider>>;
|
|
8
|
+
export declare function missingCredentials(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
|
|
8
9
|
export declare const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
|
|
9
|
-
export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[]): Record<string, string>;
|
|
10
|
+
export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[], custom?: CustomProviders): Record<string, string>;
|
|
11
|
+
export {};
|
|
10
12
|
//# sourceMappingURL=server-env.d.ts.map
|
package/dist/server-env.js
CHANGED
|
@@ -73,8 +73,13 @@ const REQUIRED_KEYS = {
|
|
|
73
73
|
xai: ["XAI_API_KEY"],
|
|
74
74
|
azure: ["AZURE_API_KEY"],
|
|
75
75
|
};
|
|
76
|
-
export function missingCredentials(base, providers) {
|
|
76
|
+
export function missingCredentials(base, providers, custom = {}) {
|
|
77
77
|
return [...new Set(providers)].flatMap((provider) => {
|
|
78
|
+
const declared = custom[provider];
|
|
79
|
+
if (declared) {
|
|
80
|
+
const name = declared.apiKeyEnv;
|
|
81
|
+
return name && !base[name] ? [`No API key for provider "${provider}": set ${name}`] : [];
|
|
82
|
+
}
|
|
78
83
|
const names = REQUIRED_KEYS[provider];
|
|
79
84
|
if (!names || names.some((name) => base[name]))
|
|
80
85
|
return [];
|
|
@@ -84,14 +89,28 @@ export function missingCredentials(base, providers) {
|
|
|
84
89
|
const GOOGLE_KEY = "GOOGLE_GENERATIVE_AI_API_KEY";
|
|
85
90
|
const GOOGLE_KEY_ALIASES = ["GEMINI_API_KEY", "GOOGLE_API_KEY"];
|
|
86
91
|
export const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
|
|
87
|
-
//
|
|
88
|
-
//
|
|
92
|
+
// OpenCode installs its plugin package from npm into every config directory
|
|
93
|
+
// when it starts, and the SDK of a provider it does not bundle when a model
|
|
94
|
+
// first uses it; no flag turns either off. ocra loads no OpenCode plugins and
|
|
95
|
+
// supports only bundled providers, so both installs get the discard port on
|
|
96
|
+
// this machine as their registry, and no retries: they fail at once, no code
|
|
97
|
+
// is fetched at review time and no .npmrc token is sent (docs/spikes/0002).
|
|
98
|
+
const NPM_SETTINGS = {
|
|
99
|
+
npm_config_registry: "http://127.0.0.1:9/",
|
|
100
|
+
NPM_CONFIG_REGISTRY: "http://127.0.0.1:9/",
|
|
101
|
+
npm_config_fetch_retries: "0",
|
|
102
|
+
};
|
|
103
|
+
// A model chain naming a provider such as "github", "gitlab" or "aws" must
|
|
104
|
+
// not carry the CI's or the cloud's credentials across by prefix (GitLab CI
|
|
105
|
+
// puts CI_JOB_TOKEN and CI_REGISTRY_PASSWORD in every job); name them in
|
|
89
106
|
// OCRA_RUNTIME_ENV when they are really meant for a provider.
|
|
90
107
|
const NEVER_BY_PREFIX = [
|
|
91
108
|
"GITHUB_",
|
|
92
109
|
"GH_",
|
|
93
110
|
"ACTIONS_",
|
|
94
111
|
"RUNNER_",
|
|
112
|
+
"GITLAB_",
|
|
113
|
+
"CI_",
|
|
95
114
|
"AWS_",
|
|
96
115
|
"AZURE_",
|
|
97
116
|
"NPM_",
|
|
@@ -99,9 +118,10 @@ const NEVER_BY_PREFIX = [
|
|
|
99
118
|
"OCRA_",
|
|
100
119
|
];
|
|
101
120
|
// The child sees only what it needs: system basics, the credentials of the
|
|
102
|
-
// providers in the configured model chains
|
|
121
|
+
// providers in the configured model chains (for a provider declared in
|
|
122
|
+
// configuration, only the variable it names), and names listed in
|
|
103
123
|
// OCRA_RUNTIME_ENV. Other secrets in the user's shell never reach it.
|
|
104
|
-
export function serverEnv(base, dirs, providers) {
|
|
124
|
+
export function serverEnv(base, dirs, providers, custom = {}) {
|
|
105
125
|
const env = {};
|
|
106
126
|
const copy = (name) => {
|
|
107
127
|
const value = base[name];
|
|
@@ -114,6 +134,12 @@ export function serverEnv(base, dirs, providers) {
|
|
|
114
134
|
if (name.startsWith("LC_"))
|
|
115
135
|
copy(name);
|
|
116
136
|
for (const provider of new Set(providers)) {
|
|
137
|
+
const declared = custom[provider];
|
|
138
|
+
if (declared) {
|
|
139
|
+
if (declared.apiKeyEnv)
|
|
140
|
+
copy(declared.apiKeyEnv);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
117
143
|
const known = PROVIDER_VARIABLES[provider];
|
|
118
144
|
if (known) {
|
|
119
145
|
for (const name of known)
|
|
@@ -132,13 +158,14 @@ export function serverEnv(base, dirs, providers) {
|
|
|
132
158
|
if (name.trim() !== "")
|
|
133
159
|
copy(name.trim());
|
|
134
160
|
}
|
|
135
|
-
if (providers.includes("google") && !env[GOOGLE_KEY]) {
|
|
161
|
+
if (providers.includes("google") && !custom.google && !env[GOOGLE_KEY]) {
|
|
136
162
|
const alias = GOOGLE_KEY_ALIASES.map((name) => base[name]).find((value) => value);
|
|
137
163
|
if (alias)
|
|
138
164
|
env[GOOGLE_KEY] = alias;
|
|
139
165
|
}
|
|
140
166
|
for (const flag of ISOLATION_FLAGS)
|
|
141
167
|
env[flag] = "1";
|
|
168
|
+
Object.assign(env, NPM_SETTINGS);
|
|
142
169
|
env.OPENCODE_CONFIG_DIR = dirs.config;
|
|
143
170
|
env.XDG_CONFIG_HOME = dirs.config;
|
|
144
171
|
env.XDG_DATA_HOME = dirs.data;
|
|
@@ -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
|
package/dist/session-outcome.js
CHANGED
|
@@ -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) ||
|
package/dist/session-prompt.d.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/session-prompt.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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.
|
|
34
|
+
"@open-cr-agent/core": "0.2.0",
|
|
35
35
|
"@opencode-ai/sdk": "1.18.32",
|
|
36
36
|
"opencode-ai": "1.18.32",
|
|
37
37
|
"undici": "^8.11.2",
|