opencode-harness-kit 0.5.4 → 0.5.6
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/CHANGELOG.md +207 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +514 -73
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +53 -11
- package/eval/measure-prune.mjs +228 -54
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +45 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +440 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/telemetry.ts +69 -9
- package/src/trust.js +182 -54
package/dist/index.js
CHANGED
|
@@ -16,10 +16,49 @@ var BASH_DENYLIST = [
|
|
|
16
16
|
// so opencode prompts for human approval before publishing.
|
|
17
17
|
"rm -rf *",
|
|
18
18
|
"rm -fr *",
|
|
19
|
+
// The short forms of the same flags, one at a time.
|
|
20
|
+
//
|
|
21
|
+
// The guard's rule is a TOKEN scanner: it blocks an rm whose short flag
|
|
22
|
+
// cluster contains r or f in any case, and an rm whose long flag is
|
|
23
|
+
// --recursive or --force. So `rm -f file` and `rm -r dir` are blocked
|
|
24
|
+
// there - and the permission map has to deny what the guard blocks, or
|
|
25
|
+
// the two layers contradict each other: the map would ask about a
|
|
26
|
+
// command the guard refuses.
|
|
27
|
+
//
|
|
28
|
+
// `-R` is named too because the guard's rule is case-insensitive and
|
|
29
|
+
// GNU rm reads `-R` as `--recursive`.
|
|
30
|
+
//
|
|
31
|
+
// The two layers are not the same matcher. The guard reads the tokens
|
|
32
|
+
// and can see a destructive flag in any position; this list is a flat
|
|
33
|
+
// glob over the command, so it can only name the spellings.
|
|
34
|
+
//
|
|
35
|
+
// A cluster a glob cannot express (`-rfv`) is caught by the guard and
|
|
36
|
+
// asked about by the config - the config asks about everything it does
|
|
37
|
+
// not name, and asking is the safe direction.
|
|
38
|
+
//
|
|
39
|
+
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by the GUARD, which reads
|
|
40
|
+
// the tokens and does not care which order they come in. The glob cannot
|
|
41
|
+
// name it - a glob here could only name one spelling of it, and the
|
|
42
|
+
// spelling would still be the one it missed. So the config asks about it,
|
|
43
|
+
// and a human decides, and the code-level block is the guard's.
|
|
44
|
+
"rm -f *",
|
|
45
|
+
"rm -r *",
|
|
46
|
+
"rm -R *",
|
|
47
|
+
// The long GNU names of the same two flags. A delete that is spelled
|
|
48
|
+
// `--recursive --force` is the delete the short forms name.
|
|
49
|
+
"rm --recursive *",
|
|
50
|
+
"rm --force *",
|
|
19
51
|
"curl * | sh",
|
|
20
52
|
"curl * | bash",
|
|
53
|
+
// The same pipe into a shell that is named by its path. The shell is the
|
|
54
|
+
// same shell either way, and the config can only match what it can see.
|
|
55
|
+
"curl * | */sh",
|
|
56
|
+
"curl * | */bash",
|
|
21
57
|
"chmod 777 *",
|
|
22
58
|
"chmod 777*",
|
|
59
|
+
// The same mode written with a leading zero.
|
|
60
|
+
"chmod 0777 *",
|
|
61
|
+
"chmod 0777*",
|
|
23
62
|
"gh pr create*",
|
|
24
63
|
"gh pr merge*",
|
|
25
64
|
"gh pr close*",
|
|
@@ -45,7 +84,7 @@ var BASH_DENYLIST = [
|
|
|
45
84
|
];
|
|
46
85
|
function matchesPattern(command, pattern) {
|
|
47
86
|
const re = new RegExp(
|
|
48
|
-
"^" + pattern.replace(/[.+^${}()|[\]
|
|
87
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
|
|
49
88
|
);
|
|
50
89
|
return re.test(command);
|
|
51
90
|
}
|
|
@@ -91,7 +130,7 @@ function heredocEnd(command, at) {
|
|
|
91
130
|
p = end + 1;
|
|
92
131
|
}
|
|
93
132
|
}
|
|
94
|
-
function
|
|
133
|
+
function split(command, kind) {
|
|
95
134
|
const parts = [];
|
|
96
135
|
let cur = "";
|
|
97
136
|
let quote = null;
|
|
@@ -129,13 +168,26 @@ function segments(command) {
|
|
|
129
168
|
continue;
|
|
130
169
|
}
|
|
131
170
|
}
|
|
132
|
-
if (ch === "
|
|
171
|
+
if (ch === "|") {
|
|
172
|
+
if (kind === "pipe") {
|
|
173
|
+
if (command[i + 1] === "|") {
|
|
174
|
+
cur += "||";
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
flush();
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
133
181
|
flush();
|
|
182
|
+
if (command[i + 1] === "|") i++;
|
|
134
183
|
continue;
|
|
135
184
|
}
|
|
136
|
-
if (
|
|
185
|
+
if (kind === "pipe") {
|
|
186
|
+
cur += ch;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (ch === "\n" || ch === ";") {
|
|
137
190
|
flush();
|
|
138
|
-
if (command[i + 1] === "|") i++;
|
|
139
191
|
continue;
|
|
140
192
|
}
|
|
141
193
|
if (ch === "&") {
|
|
@@ -156,6 +208,12 @@ function segments(command) {
|
|
|
156
208
|
flush();
|
|
157
209
|
return parts;
|
|
158
210
|
}
|
|
211
|
+
function segments(command) {
|
|
212
|
+
return split(command, "command");
|
|
213
|
+
}
|
|
214
|
+
function pipeStages(command) {
|
|
215
|
+
return split(command, "pipe");
|
|
216
|
+
}
|
|
159
217
|
function tokenize(segment) {
|
|
160
218
|
const tokens = [];
|
|
161
219
|
let cur = "";
|
|
@@ -211,6 +269,9 @@ var GIT_WRAPPERS = /* @__PURE__ */ new Set([
|
|
|
211
269
|
"xargs",
|
|
212
270
|
"npx"
|
|
213
271
|
]);
|
|
272
|
+
var SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
|
|
273
|
+
var SCRIPT_RUNNERS = /^python[0-9.]*$/i;
|
|
274
|
+
var DOWNLOADERS = /\b(curl|wget)\b/i;
|
|
214
275
|
var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
|
|
215
276
|
var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
216
277
|
"-C",
|
|
@@ -232,6 +293,35 @@ var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
|
|
|
232
293
|
"--glob-pathspecs",
|
|
233
294
|
"--icase-pathspecs"
|
|
234
295
|
]);
|
|
296
|
+
var TOOL_VALUE_FLAGS = {
|
|
297
|
+
gh: /* @__PURE__ */ new Set(["-R", "--repo", "-H", "--hostname"]),
|
|
298
|
+
npm: /* @__PURE__ */ new Set([
|
|
299
|
+
"--prefix",
|
|
300
|
+
"--cache",
|
|
301
|
+
"--registry",
|
|
302
|
+
"--userconfig",
|
|
303
|
+
"--globalconfig",
|
|
304
|
+
"--otp",
|
|
305
|
+
"--tag",
|
|
306
|
+
"--access",
|
|
307
|
+
"--loglevel",
|
|
308
|
+
"--workspace",
|
|
309
|
+
"--script-shell",
|
|
310
|
+
"--init-author-name",
|
|
311
|
+
"--init-author-email",
|
|
312
|
+
"--init-author-url",
|
|
313
|
+
"--init-license",
|
|
314
|
+
"--init-module",
|
|
315
|
+
"--init-version"
|
|
316
|
+
]),
|
|
317
|
+
yarn: /* @__PURE__ */ new Set(["--cwd", "--registry", "--cache-folder", "--modules-folder"]),
|
|
318
|
+
pnpm: /* @__PURE__ */ new Set(["--dir", "-C", "--filter", "--store-dir", "--registry"]),
|
|
319
|
+
bun: /* @__PURE__ */ new Set(["--cwd", "--config", "--registry"])
|
|
320
|
+
};
|
|
321
|
+
function baseName(word) {
|
|
322
|
+
const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"));
|
|
323
|
+
return slash === -1 ? word : word.slice(slash + 1);
|
|
324
|
+
}
|
|
235
325
|
function skipPrefixes(tokens) {
|
|
236
326
|
let i = 0;
|
|
237
327
|
for (; ; ) {
|
|
@@ -241,7 +331,7 @@ function skipPrefixes(tokens) {
|
|
|
241
331
|
i++;
|
|
242
332
|
continue;
|
|
243
333
|
}
|
|
244
|
-
if (GIT_WRAPPERS.has(word)) {
|
|
334
|
+
if (GIT_WRAPPERS.has(baseName(word))) {
|
|
245
335
|
i++;
|
|
246
336
|
while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
|
|
247
337
|
const flag = tokens[i];
|
|
@@ -258,10 +348,25 @@ function stripPrefixes(segment) {
|
|
|
258
348
|
const i = skipPrefixes(tokens);
|
|
259
349
|
if (i >= tokens.length) return "";
|
|
260
350
|
const rest = tokens.slice(i);
|
|
261
|
-
|
|
262
|
-
if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
|
|
351
|
+
rest[0] = baseName(rest[0]);
|
|
263
352
|
return rest.join(" ");
|
|
264
353
|
}
|
|
354
|
+
function stripToolFlags(command) {
|
|
355
|
+
const tokens = tokenize(command);
|
|
356
|
+
if (!tokens.length) return command;
|
|
357
|
+
const flags = TOOL_VALUE_FLAGS[baseName(tokens[0]).toLowerCase()];
|
|
358
|
+
if (!flags) return command;
|
|
359
|
+
const out = [];
|
|
360
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
361
|
+
const token = tokens[i];
|
|
362
|
+
if (!flags.has(token.split("=")[0])) {
|
|
363
|
+
out.push(token);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (!token.includes("=") && i + 1 < tokens.length) i++;
|
|
367
|
+
}
|
|
368
|
+
return out.join(" ");
|
|
369
|
+
}
|
|
265
370
|
function gitSubcommand(segment) {
|
|
266
371
|
const tokens = tokenize(segment);
|
|
267
372
|
let i = skipPrefixes(tokens);
|
|
@@ -282,25 +387,124 @@ function gitSubcommand(segment) {
|
|
|
282
387
|
}
|
|
283
388
|
return tokens[i] ?? null;
|
|
284
389
|
}
|
|
285
|
-
var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
|
|
286
390
|
var MAX_SHELL_DEPTH = 2;
|
|
391
|
+
function shellCommandString(tokens, from) {
|
|
392
|
+
for (let n = from + 1; n < tokens.length; n++) {
|
|
393
|
+
const token = tokens[n];
|
|
394
|
+
if (!token.startsWith("-") || token === "-") continue;
|
|
395
|
+
const m = /^-([a-zA-Z]*)c/.exec(token);
|
|
396
|
+
if (!m) continue;
|
|
397
|
+
const after = token.slice(m[0].length);
|
|
398
|
+
if (after && !/^[a-zA-Z]+$/.test(after)) return after.trim();
|
|
399
|
+
return tokens.slice(n + 1).join(" ") || null;
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
287
403
|
function shellPayload(segment) {
|
|
288
404
|
const tokens = tokenize(segment);
|
|
289
405
|
if (tokens.length < 2) return null;
|
|
290
406
|
const i = skipPrefixes(tokens);
|
|
291
407
|
if (i >= tokens.length) return null;
|
|
292
408
|
if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
|
|
293
|
-
if (!
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
409
|
+
if (!SHELL_NAMES.test(baseName(tokens[i]))) return null;
|
|
410
|
+
return shellCommandString(tokens, i);
|
|
411
|
+
}
|
|
412
|
+
var GROUP_OPEN = /^[({[]+/;
|
|
413
|
+
var GROUP_END = /[;)&,}\]]+$/;
|
|
414
|
+
function commandName(word) {
|
|
415
|
+
return baseName(word).replace(GROUP_OPEN, "").replace(GROUP_END, "");
|
|
416
|
+
}
|
|
417
|
+
function runsScript(stage) {
|
|
418
|
+
for (const command of segments(stage)) {
|
|
419
|
+
const tokens = tokenize(command);
|
|
420
|
+
let i = skipPrefixes(tokens);
|
|
421
|
+
if (i >= tokens.length) continue;
|
|
422
|
+
let name = commandName(tokens[i]);
|
|
423
|
+
if (!name) {
|
|
424
|
+
i++;
|
|
425
|
+
if (i >= tokens.length) continue;
|
|
426
|
+
name = commandName(tokens[i]);
|
|
427
|
+
}
|
|
428
|
+
if (SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name)) return true;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
function pipesScriptIntoShell(command) {
|
|
433
|
+
const stages = pipeStages(command);
|
|
434
|
+
for (let i = 0; i < stages.length; i++) {
|
|
435
|
+
if (!DOWNLOADERS.test(stages[i])) continue;
|
|
436
|
+
for (let j = i + 1; j < stages.length; j++) {
|
|
437
|
+
if (!runsScript(stages[j])) continue;
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
function isDestructiveRmFlag(token) {
|
|
444
|
+
if (/^--(recursive|force)$/i.test(token)) return true;
|
|
445
|
+
return /^-[a-zA-Z]*[rf]/i.test(token);
|
|
446
|
+
}
|
|
447
|
+
function isRecursiveDelete(text) {
|
|
448
|
+
const tokens = tokenize(text);
|
|
449
|
+
if (tokens.length < 2) return false;
|
|
450
|
+
if (baseName(tokens[0]).toLowerCase() !== "rm") return false;
|
|
451
|
+
let destructive = false;
|
|
452
|
+
let hasPath = false;
|
|
453
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
454
|
+
const token = tokens[i];
|
|
455
|
+
if (token === "--") {
|
|
456
|
+
hasPath = tokens.length > i + 1;
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
if (!token.startsWith("-")) {
|
|
460
|
+
hasPath = true;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (isDestructiveRmFlag(token)) destructive = true;
|
|
464
|
+
}
|
|
465
|
+
return destructive && hasPath;
|
|
466
|
+
}
|
|
467
|
+
var CHMOD_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--reference"]);
|
|
468
|
+
function chmodMode(text) {
|
|
469
|
+
const tokens = tokenize(text);
|
|
470
|
+
if (tokens.length < 2) return null;
|
|
471
|
+
if (baseName(tokens[0]).toLowerCase() !== "chmod") return null;
|
|
472
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
473
|
+
const token = tokens[i];
|
|
474
|
+
if (token === "--") continue;
|
|
475
|
+
if (token.startsWith("--")) {
|
|
476
|
+
if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (token.startsWith("-")) continue;
|
|
480
|
+
if (/^\d+$/.test(token)) return token;
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
function opensPermissions(text) {
|
|
486
|
+
const mode = chmodMode(text);
|
|
487
|
+
if (mode === null) return false;
|
|
488
|
+
return /^0*777$/.test(mode);
|
|
297
489
|
}
|
|
298
490
|
var ASK_GATED = [/^\s*git\s+push\b/];
|
|
299
491
|
var BASH_RULES = [
|
|
300
492
|
{
|
|
301
493
|
name: "secret exfiltration via shell",
|
|
302
494
|
scope: "command",
|
|
303
|
-
|
|
495
|
+
// The credential word has to be the whole name or the tail of it: the
|
|
496
|
+
// name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
|
|
497
|
+
// never fires after an underscore, so the boundary alone left every
|
|
498
|
+
// prefixed credential name unmatched.
|
|
499
|
+
//
|
|
500
|
+
// The name is matched in whatever case it is written: the rule used to
|
|
501
|
+
// match the upper case spelling only, so `token=$(curl ...)` was
|
|
502
|
+
// allowed, and a lower case name is the same name.
|
|
503
|
+
//
|
|
504
|
+
// What the rule does NOT cover is an assignment whose value is not
|
|
505
|
+
// fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
|
|
506
|
+
// send one.
|
|
507
|
+
test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
|
|
304
508
|
why: "assigns a fetched value into a credential-named variable"
|
|
305
509
|
},
|
|
306
510
|
{
|
|
@@ -312,7 +516,7 @@ var BASH_RULES = [
|
|
|
312
516
|
{
|
|
313
517
|
name: "pipe remote script into a shell",
|
|
314
518
|
scope: "command",
|
|
315
|
-
|
|
519
|
+
check: (text) => pipesScriptIntoShell(text),
|
|
316
520
|
why: "downloads and executes a script in one step"
|
|
317
521
|
},
|
|
318
522
|
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
@@ -321,13 +525,17 @@ var BASH_RULES = [
|
|
|
321
525
|
{
|
|
322
526
|
name: "recursive delete",
|
|
323
527
|
scope: "segment",
|
|
324
|
-
|
|
528
|
+
check: (text) => isRecursiveDelete(text),
|
|
325
529
|
why: "rm with a recursive or force flag"
|
|
326
530
|
},
|
|
327
531
|
{
|
|
328
532
|
name: "chmod 777",
|
|
329
533
|
scope: "segment",
|
|
330
|
-
|
|
534
|
+
// The mode is read as an OPERAND rather than as a literal, because the
|
|
535
|
+
// flags in front of it can be long. `chmod --recursive 777 /` is the
|
|
536
|
+
// same chmod as `chmod -R 777 /`, and a rule that only knew the short
|
|
537
|
+
// flag cluster let the long spelling through.
|
|
538
|
+
check: (text) => opensPermissions(text),
|
|
331
539
|
why: "opens permissions on everything"
|
|
332
540
|
},
|
|
333
541
|
{
|
|
@@ -380,7 +588,7 @@ function checkDenied(command, depth = 0) {
|
|
|
380
588
|
const parts = segments(command);
|
|
381
589
|
const normalized = parts.map((seg) => stripPrefixes(seg));
|
|
382
590
|
for (let i = 0; i < parts.length; i++) {
|
|
383
|
-
const pattern = deniedBy(normalized[i]);
|
|
591
|
+
const pattern = deniedBy(stripToolFlags(normalized[i]));
|
|
384
592
|
if (pattern) return { pattern, segment: parts[i] };
|
|
385
593
|
}
|
|
386
594
|
if (depth < MAX_SHELL_DEPTH) {
|
|
@@ -433,15 +641,18 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
|
|
|
433
641
|
}
|
|
434
642
|
|
|
435
643
|
// src/telemetry-store.ts
|
|
436
|
-
import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs";
|
|
644
|
+
import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs";
|
|
437
645
|
import { join } from "node:path";
|
|
438
646
|
var MAX_BYTES = 4 * 1024 * 1024;
|
|
439
647
|
function logPath(dir) {
|
|
440
648
|
return join(dir, "telemetry.jsonl");
|
|
441
649
|
}
|
|
442
|
-
function rotate(file) {
|
|
650
|
+
function rotate(file, maxBytes = MAX_BYTES) {
|
|
443
651
|
try {
|
|
444
|
-
if (existsSync(file) && statSync(file).size >
|
|
652
|
+
if (existsSync(file) && statSync(file).size > maxBytes) {
|
|
653
|
+
const oldest = `${file}.2`;
|
|
654
|
+
if (existsSync(oldest)) rmSync(oldest, { force: true });
|
|
655
|
+
if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest);
|
|
445
656
|
renameSync(file, `${file}.1`);
|
|
446
657
|
}
|
|
447
658
|
} catch {
|
|
@@ -475,19 +686,46 @@ function projectRoot(directory, worktree) {
|
|
|
475
686
|
// src/guard.ts
|
|
476
687
|
var MARKER = "[opencode-harness-kit] blocked";
|
|
477
688
|
var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "patch"]);
|
|
689
|
+
function hunkFollows(lines, from) {
|
|
690
|
+
for (let i = from; i < lines.length; i++) {
|
|
691
|
+
if (/^@@/.test(lines[i])) return true;
|
|
692
|
+
if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false;
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
478
696
|
function pathsInPatch(text) {
|
|
479
697
|
const out = [];
|
|
480
698
|
const push = (p) => {
|
|
481
|
-
const v = p.trim();
|
|
699
|
+
const v = p.split(" ")[0].trim();
|
|
482
700
|
if (!v) return;
|
|
483
701
|
if (v === "/dev/null") return;
|
|
484
702
|
if (!out.includes(v)) out.push(v);
|
|
485
703
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
704
|
+
const lines = text.split("\n");
|
|
705
|
+
let seenHunk = false;
|
|
706
|
+
for (let i = 0; i < lines.length; i++) {
|
|
707
|
+
const line = lines[i];
|
|
708
|
+
const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line);
|
|
709
|
+
if (marker) {
|
|
710
|
+
push(marker[1]);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (/^@@/.test(line)) {
|
|
714
|
+
seenHunk = true;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const minus = /^---\s+(?:a\/)?(.+)$/.exec(line);
|
|
718
|
+
const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null;
|
|
719
|
+
if (minus && next) {
|
|
720
|
+
if (hunkFollows(lines, i + 2)) {
|
|
721
|
+
push(minus[1]);
|
|
722
|
+
push(next[1]);
|
|
723
|
+
}
|
|
724
|
+
i++;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line);
|
|
728
|
+
if (plus && !seenHunk) push(plus[1]);
|
|
491
729
|
}
|
|
492
730
|
return out;
|
|
493
731
|
}
|
|
@@ -653,6 +891,11 @@ function loadProfile(id) {
|
|
|
653
891
|
const msg = err instanceof Error ? err.message : String(err);
|
|
654
892
|
throw new ProfileError(`cannot read ${path}: ${msg}`);
|
|
655
893
|
}
|
|
894
|
+
if (raw?.id && raw.id !== id) {
|
|
895
|
+
throw new ProfileError(
|
|
896
|
+
`${path}: profile id is "${raw.id}", which does not match the file name "${id}"`
|
|
897
|
+
);
|
|
898
|
+
}
|
|
656
899
|
validate(raw, path);
|
|
657
900
|
return normalize(raw);
|
|
658
901
|
}
|
|
@@ -817,6 +1060,10 @@ function resolveLaneChain(p, role) {
|
|
|
817
1060
|
return chain;
|
|
818
1061
|
}
|
|
819
1062
|
function resolveLaneModel(p, role, opts = {}) {
|
|
1063
|
+
const model = laneModelId(p, role, opts);
|
|
1064
|
+
return { model, levels: levelsOfModel(model) };
|
|
1065
|
+
}
|
|
1066
|
+
function laneModelId(p, role, opts = {}) {
|
|
820
1067
|
const unhealthy = opts.unhealthy ?? /* @__PURE__ */ new Set();
|
|
821
1068
|
const ceiling = (
|
|
822
1069
|
/** @type {Retention} */
|
|
@@ -825,13 +1072,12 @@ function resolveLaneModel(p, role, opts = {}) {
|
|
|
825
1072
|
for (const candidate of resolveLaneChain(p, role)) {
|
|
826
1073
|
if (unhealthy.has(candidate)) continue;
|
|
827
1074
|
if (!retentionAllowed(candidate, ceiling)) continue;
|
|
828
|
-
return
|
|
1075
|
+
return candidate;
|
|
829
1076
|
}
|
|
830
1077
|
if (p.model && !unhealthy.has(p.model) && retentionAllowed(p.model, ceiling)) {
|
|
831
|
-
return
|
|
1078
|
+
return p.model;
|
|
832
1079
|
}
|
|
833
|
-
|
|
834
|
-
return { model: fallback, levels: levelsOfModel(fallback) };
|
|
1080
|
+
return resolveLaneChain(p, role)[0] ?? p.model;
|
|
835
1081
|
}
|
|
836
1082
|
function levelsOfModel(model) {
|
|
837
1083
|
try {
|
|
@@ -864,48 +1110,66 @@ function resolveEffort(levels, requested) {
|
|
|
864
1110
|
if (!levels || levels.length === 0) return null;
|
|
865
1111
|
if (levels.includes(requested)) return requested;
|
|
866
1112
|
if (requested === "none") return null;
|
|
1113
|
+
const rungs = levels.filter(
|
|
1114
|
+
(level) => level !== "none" && CANON.indexOf(level) >= 0
|
|
1115
|
+
);
|
|
867
1116
|
const want = CANON.indexOf(requested);
|
|
868
1117
|
if (want < 0) {
|
|
869
|
-
return
|
|
1118
|
+
return rungs[rungs.length - 1] ?? null;
|
|
870
1119
|
}
|
|
871
1120
|
let best = null;
|
|
872
1121
|
let bestDist = Infinity;
|
|
873
|
-
for (const level of
|
|
1122
|
+
for (const level of rungs) {
|
|
874
1123
|
const have = CANON.indexOf(level);
|
|
875
|
-
if (have < 0) continue;
|
|
876
1124
|
const d = Math.abs(have - want);
|
|
877
1125
|
if (d < bestDist) {
|
|
878
1126
|
bestDist = d;
|
|
879
1127
|
best = level;
|
|
880
1128
|
}
|
|
881
1129
|
}
|
|
882
|
-
return best ??
|
|
1130
|
+
return best ?? rungs[rungs.length - 1] ?? null;
|
|
883
1131
|
}
|
|
884
|
-
var
|
|
1132
|
+
var DECLARABLE_RETENTION = ["zero-retention", "retained", "may-train"];
|
|
885
1133
|
function validate(p, path = "<memory>") {
|
|
886
1134
|
const where = p?.id ?? path;
|
|
887
1135
|
if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
|
|
888
1136
|
if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
|
|
889
1137
|
if (!p.model) throw new ProfileError(`${where}: missing "model"`);
|
|
890
|
-
if (
|
|
891
|
-
throw new ProfileError(
|
|
892
|
-
`${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
|
|
893
|
-
);
|
|
1138
|
+
if (p.retention === "unknown") {
|
|
1139
|
+
throw new ProfileError(`${where}: retention cannot be unknown`);
|
|
894
1140
|
}
|
|
895
|
-
if (
|
|
1141
|
+
if (!DECLARABLE_RETENTION.includes(p.retention)) {
|
|
896
1142
|
throw new ProfileError(
|
|
897
|
-
`${where}:
|
|
1143
|
+
`${where}: retention must be one of ${DECLARABLE_RETENTION.join(", ")}`
|
|
898
1144
|
);
|
|
899
1145
|
}
|
|
900
1146
|
if (p.maxRetention === "unknown") {
|
|
901
1147
|
throw new ProfileError(`${where}: maxRetention cannot be unknown`);
|
|
902
1148
|
}
|
|
903
|
-
if (
|
|
1149
|
+
if (p.maxRetention && !DECLARABLE_RETENTION.includes(p.maxRetention)) {
|
|
1150
|
+
throw new ProfileError(
|
|
1151
|
+
`${where}: maxRetention must be one of ${DECLARABLE_RETENTION.join(", ")}`
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
if (!Number.isInteger(p.limits?.context) || p.limits.context <= 0) {
|
|
904
1155
|
throw new ProfileError(`${where}: limits.context must be a positive integer`);
|
|
905
1156
|
}
|
|
906
|
-
if (!p.limits?.output || p.limits.output <= 0) {
|
|
1157
|
+
if (!Number.isInteger(p.limits?.output) || p.limits.output <= 0) {
|
|
907
1158
|
throw new ProfileError(`${where}: limits.output must be a positive integer`);
|
|
908
1159
|
}
|
|
1160
|
+
for (const level of p.effort?.levels ?? []) {
|
|
1161
|
+
if (!CANON.includes(level)) {
|
|
1162
|
+
throw new ProfileError(
|
|
1163
|
+
`${where}: effort.levels contains "${level}", which is not one of ${CANON.join(", ")}`
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const wanted = p.effort?.default;
|
|
1168
|
+
if (wanted !== void 0 && wanted !== null && !CANON.includes(wanted)) {
|
|
1169
|
+
throw new ProfileError(
|
|
1170
|
+
`${where}: effort.default is "${wanted}", which is not one of ${CANON.join(", ")}`
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
909
1173
|
const share = p.effort?.reserveShare;
|
|
910
1174
|
if (share !== void 0 && (share < 0 || share > 0.5)) {
|
|
911
1175
|
throw new ProfileError(
|
|
@@ -1004,6 +1268,11 @@ function validate(p, path = "<memory>") {
|
|
|
1004
1268
|
);
|
|
1005
1269
|
}
|
|
1006
1270
|
}
|
|
1271
|
+
if (!retentionFor(p.model)) {
|
|
1272
|
+
throw new ProfileError(
|
|
1273
|
+
`${where}: model ${p.model} has no retention.yml entry`
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1007
1276
|
if (p.utility) {
|
|
1008
1277
|
if (typeof p.utility.model !== "string" || !p.utility.model) {
|
|
1009
1278
|
throw new ProfileError(`${where}: utility.model must be a model id`);
|
|
@@ -1018,6 +1287,11 @@ function validate(p, path = "<memory>") {
|
|
|
1018
1287
|
`${where}: utility.model is ${p.utility.model}, which this profile does not resolve to (it has ${[...reachable].join(", ")})`
|
|
1019
1288
|
);
|
|
1020
1289
|
}
|
|
1290
|
+
if (!retentionFor(p.utility.model)) {
|
|
1291
|
+
throw new ProfileError(
|
|
1292
|
+
`${where}: utility.model ${p.utility.model} has no retention.yml entry`
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1021
1295
|
}
|
|
1022
1296
|
for (const id of p.fallback ?? []) {
|
|
1023
1297
|
if (!profileExists(id)) {
|
|
@@ -1026,6 +1300,12 @@ function validate(p, path = "<memory>") {
|
|
|
1026
1300
|
);
|
|
1027
1301
|
}
|
|
1028
1302
|
}
|
|
1303
|
+
const actual = worstRetention(laneModelsInPlay(p));
|
|
1304
|
+
if (retentionSeverity(p.retention) < retentionSeverity(actual.retention)) {
|
|
1305
|
+
throw new ProfileError(
|
|
1306
|
+
`${where}: retention is ${p.retention}, but ${actual.worst} is ${actual.retention} - a profile cannot be more private than the models it runs on`
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1029
1309
|
}
|
|
1030
1310
|
function profileExists(id) {
|
|
1031
1311
|
return profilePath(id) !== null;
|
|
@@ -1045,6 +1325,14 @@ function reservedTokens(p, opts = {}) {
|
|
|
1045
1325
|
function pruneThreshold(p, opts = {}) {
|
|
1046
1326
|
return inputCap(p, opts) - reservedTokens(p, opts);
|
|
1047
1327
|
}
|
|
1328
|
+
function laneModelsInPlay(p, opts = {}) {
|
|
1329
|
+
const out = [p.model];
|
|
1330
|
+
for (const role of Object.keys(p.roleModels ?? {})) {
|
|
1331
|
+
const model = laneModelId(p, role, opts);
|
|
1332
|
+
if (!out.includes(model)) out.push(model);
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
}
|
|
1048
1336
|
function roleSplitModels(p, opts = {}) {
|
|
1049
1337
|
return Object.entries(p.roleModels ?? {}).map(([role]) => {
|
|
1050
1338
|
const resolved = resolveLaneModel(p, role, opts);
|
|
@@ -1057,6 +1345,18 @@ function roleSplitModels(p, opts = {}) {
|
|
|
1057
1345
|
return { role, model: resolved.model, levels: resolved.levels, limits };
|
|
1058
1346
|
});
|
|
1059
1347
|
}
|
|
1348
|
+
function worstRetention(ids) {
|
|
1349
|
+
let worst = null;
|
|
1350
|
+
let retention = "zero-retention";
|
|
1351
|
+
for (const id of ids) {
|
|
1352
|
+
const declared = modelRetention(id);
|
|
1353
|
+
if (!worst || retentionSeverity(declared) > retentionSeverity(retention)) {
|
|
1354
|
+
worst = id;
|
|
1355
|
+
retention = declared;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return { retention, worst };
|
|
1359
|
+
}
|
|
1060
1360
|
function configDir() {
|
|
1061
1361
|
return process.env.OPENCODE_CONFIG_DIR ?? join4(homedir3(), ".config", "opencode");
|
|
1062
1362
|
}
|
|
@@ -1181,10 +1481,13 @@ function toolSignature(part) {
|
|
|
1181
1481
|
}
|
|
1182
1482
|
return `${tool} ${input}`;
|
|
1183
1483
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1484
|
+
function partSize(part) {
|
|
1485
|
+
try {
|
|
1486
|
+
const json = JSON.stringify(part);
|
|
1487
|
+
return typeof json === "string" ? json.length : 0;
|
|
1488
|
+
} catch {
|
|
1489
|
+
return 0;
|
|
1490
|
+
}
|
|
1188
1491
|
}
|
|
1189
1492
|
function stub(part) {
|
|
1190
1493
|
const tool = part?.tool ?? "tool";
|
|
@@ -1282,7 +1585,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1282
1585
|
if (!msg?.parts) continue;
|
|
1283
1586
|
const isUser = msg.info?.role === "user";
|
|
1284
1587
|
for (const part of msg.parts) {
|
|
1285
|
-
const size =
|
|
1588
|
+
const size = partSize(part);
|
|
1286
1589
|
stats.bytesBefore += size;
|
|
1287
1590
|
if (isUser && keepUsers) {
|
|
1288
1591
|
stats.bytesAfter += size;
|
|
@@ -1302,7 +1605,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1302
1605
|
break;
|
|
1303
1606
|
case "reasoning": {
|
|
1304
1607
|
part.text = REASONING_STUB;
|
|
1305
|
-
stats.bytesAfter +=
|
|
1608
|
+
stats.bytesAfter += partSize(part);
|
|
1306
1609
|
stats.stubbed++;
|
|
1307
1610
|
break;
|
|
1308
1611
|
}
|
|
@@ -1321,7 +1624,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1321
1624
|
time: state.time,
|
|
1322
1625
|
metadata: { ...state.metadata ?? {}, pruned: true, prunedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1323
1626
|
};
|
|
1324
|
-
stats.bytesAfter +=
|
|
1627
|
+
stats.bytesAfter += partSize(part);
|
|
1325
1628
|
stats.stubbed++;
|
|
1326
1629
|
break;
|
|
1327
1630
|
}
|
|
@@ -1336,7 +1639,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1336
1639
|
}
|
|
1337
1640
|
}
|
|
1338
1641
|
if (stats.stubbed > 0) {
|
|
1339
|
-
const saved =
|
|
1642
|
+
const saved = stats.bytesBefore - stats.bytesAfter;
|
|
1340
1643
|
await logPrune(project, profile.id, {
|
|
1341
1644
|
...stats,
|
|
1342
1645
|
saved,
|
|
@@ -1394,27 +1697,67 @@ function utilityModel(p) {
|
|
|
1394
1697
|
// src/health.js
|
|
1395
1698
|
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
1396
1699
|
import { join as join6 } from "node:path";
|
|
1700
|
+
var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
1701
|
+
function spentWindowMs() {
|
|
1702
|
+
const raw = process.env.HARNESS_SPENT_WINDOW_HOURS;
|
|
1703
|
+
if (!raw) return DEFAULT_WINDOW_MS;
|
|
1704
|
+
const hours = Number(raw);
|
|
1705
|
+
if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_WINDOW_MS;
|
|
1706
|
+
return hours * 60 * 60 * 1e3;
|
|
1707
|
+
}
|
|
1708
|
+
function isSpent(entry, now = Date.now()) {
|
|
1709
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
1710
|
+
const at = (
|
|
1711
|
+
/** @type {any} */
|
|
1712
|
+
entry.at
|
|
1713
|
+
);
|
|
1714
|
+
if (typeof at !== "string") return true;
|
|
1715
|
+
const when = Date.parse(at);
|
|
1716
|
+
if (Number.isNaN(when)) return true;
|
|
1717
|
+
return now - when <= spentWindowMs();
|
|
1718
|
+
}
|
|
1719
|
+
function liveUnhealthy(health, now = Date.now()) {
|
|
1720
|
+
const out = {};
|
|
1721
|
+
const source = health?.unhealthy;
|
|
1722
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return out;
|
|
1723
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
1724
|
+
if (isSpent(entry, now)) out[key] = entry;
|
|
1725
|
+
}
|
|
1726
|
+
return out;
|
|
1727
|
+
}
|
|
1397
1728
|
function healthPath(dir) {
|
|
1398
1729
|
return join6(dir, ".space", "health.json");
|
|
1399
1730
|
}
|
|
1400
1731
|
function readHealth(dir) {
|
|
1732
|
+
let parsed;
|
|
1401
1733
|
try {
|
|
1402
|
-
|
|
1734
|
+
parsed = JSON.parse(readFileSync3(healthPath(dir), "utf8"));
|
|
1403
1735
|
} catch {
|
|
1404
1736
|
return { unhealthy: {} };
|
|
1405
1737
|
}
|
|
1738
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1739
|
+
return { unhealthy: {} };
|
|
1740
|
+
}
|
|
1741
|
+
const unhealthy = parsed.unhealthy;
|
|
1742
|
+
if (!unhealthy || typeof unhealthy !== "object" || Array.isArray(unhealthy)) {
|
|
1743
|
+
return { ...parsed, unhealthy: {} };
|
|
1744
|
+
}
|
|
1745
|
+
return parsed;
|
|
1406
1746
|
}
|
|
1407
1747
|
function writeHealth(dir, health) {
|
|
1408
1748
|
try {
|
|
1409
1749
|
if (!existsSync5(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
|
|
1410
|
-
|
|
1750
|
+
const pruned = { ...health ?? {}, unhealthy: liveUnhealthy(health) };
|
|
1751
|
+
writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2));
|
|
1411
1752
|
} catch {
|
|
1412
1753
|
}
|
|
1413
1754
|
}
|
|
1755
|
+
function unhealthyRoutes(dir) {
|
|
1756
|
+
return liveUnhealthy(readHealth(dir));
|
|
1757
|
+
}
|
|
1414
1758
|
function unhealthyModels(dir) {
|
|
1415
|
-
const health = readHealth(dir);
|
|
1416
1759
|
const out = /* @__PURE__ */ new Set();
|
|
1417
|
-
for (const key of Object.keys(
|
|
1760
|
+
for (const key of Object.keys(unhealthyRoutes(dir))) {
|
|
1418
1761
|
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
|
|
1419
1762
|
}
|
|
1420
1763
|
return out;
|
|
@@ -1427,24 +1770,94 @@ var SPENT = [
|
|
|
1427
1770
|
/\b429\b/,
|
|
1428
1771
|
/quota (?:exceeded|exhausted)/i,
|
|
1429
1772
|
/overloaded_error/i,
|
|
1430
|
-
|
|
1773
|
+
// A withdrawn model, named in the error. The words have to sit next to
|
|
1774
|
+
// each other - "model minimax-m3.1-flash-free not found", "model_not_found",
|
|
1775
|
+
// "ModelNotFound" - because the text this runs against is the provider's
|
|
1776
|
+
// own error and that can be a whole response body. The old pattern ran
|
|
1777
|
+
// over the entire event, where a body that mentioned a model in one place
|
|
1778
|
+
// and "not found" in another read as a withdrawn model: a live route
|
|
1779
|
+
// written off by a coincidence.
|
|
1780
|
+
/\bmodel[\W_]*not[\W_]*found\b/i,
|
|
1781
|
+
/\bmodel\b[\w.:/-]*(?:\s+[\w.:/-]+){0,2}\s+not found\b/i,
|
|
1431
1782
|
/no available provider/i
|
|
1432
1783
|
];
|
|
1433
|
-
function
|
|
1784
|
+
function errorText(err) {
|
|
1785
|
+
if (err == null) return "";
|
|
1786
|
+
if (typeof err === "string") return err;
|
|
1787
|
+
if (typeof err !== "object") return String(err);
|
|
1788
|
+
const parts = [];
|
|
1789
|
+
try {
|
|
1790
|
+
const push = (v) => {
|
|
1791
|
+
if (typeof v === "string" && v) parts.push(v);
|
|
1792
|
+
else if (typeof v === "number" && Number.isFinite(v)) parts.push(String(v));
|
|
1793
|
+
else if (v && typeof v === "object") parts.push(safeStringify(v));
|
|
1794
|
+
};
|
|
1795
|
+
push(err.name);
|
|
1796
|
+
push(err.message);
|
|
1797
|
+
push(err.code);
|
|
1798
|
+
const data = err.data;
|
|
1799
|
+
if (data && typeof data === "object") {
|
|
1800
|
+
push(data.name);
|
|
1801
|
+
push(data.message);
|
|
1802
|
+
push(data.code);
|
|
1803
|
+
push(data.statusCode);
|
|
1804
|
+
push(data.responseBody);
|
|
1805
|
+
}
|
|
1806
|
+
} catch {
|
|
1807
|
+
}
|
|
1808
|
+
return parts.join("\n");
|
|
1809
|
+
}
|
|
1810
|
+
function classifyError(err) {
|
|
1811
|
+
const text = errorText(err);
|
|
1812
|
+
if (!text) return null;
|
|
1434
1813
|
for (const p of SPENT) if (p.test(text)) return p.source;
|
|
1435
1814
|
return null;
|
|
1436
1815
|
}
|
|
1816
|
+
function errorOf(e) {
|
|
1817
|
+
const props = e?.properties;
|
|
1818
|
+
if (!props || typeof props !== "object") return void 0;
|
|
1819
|
+
return props.info?.error ?? props.error;
|
|
1820
|
+
}
|
|
1821
|
+
function routeKey(provider, model) {
|
|
1822
|
+
const p = typeof provider === "string" && provider ? provider : "opencode";
|
|
1823
|
+
return `${p}/${model}`;
|
|
1824
|
+
}
|
|
1825
|
+
function routeOf(e) {
|
|
1826
|
+
const props = e?.properties;
|
|
1827
|
+
if (!props || typeof props !== "object") return void 0;
|
|
1828
|
+
const info = props.info ?? props;
|
|
1829
|
+
if (!info || typeof info !== "object") return void 0;
|
|
1830
|
+
const model = info.model;
|
|
1831
|
+
const id = model?.modelID ?? info.modelID ?? props.modelID ?? props.model?.modelID;
|
|
1832
|
+
if (typeof id !== "string" || !id) return void 0;
|
|
1833
|
+
const provider = model?.providerID ?? info.providerID ?? props.providerID;
|
|
1834
|
+
return routeKey(typeof provider === "string" ? provider : void 0, id);
|
|
1835
|
+
}
|
|
1836
|
+
function sessionIdOf(e) {
|
|
1837
|
+
const props = e?.properties;
|
|
1838
|
+
const id = props?.info?.sessionID ?? props?.sessionID;
|
|
1839
|
+
return typeof id === "string" && id ? id : void 0;
|
|
1840
|
+
}
|
|
1841
|
+
function routeOfConfig(cfg) {
|
|
1842
|
+
const model = cfg?.model ?? cfg?.harness?.sessionModel;
|
|
1843
|
+
if (typeof model !== "string" || !model) return void 0;
|
|
1844
|
+
return model.includes("/") ? model : `opencode/${model}`;
|
|
1845
|
+
}
|
|
1437
1846
|
var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` to check whether the profile still matches live metadata.";
|
|
1438
1847
|
var RouterPlugin = async ({ worktree, directory }) => {
|
|
1439
1848
|
const project = projectRoot(directory, worktree);
|
|
1440
1849
|
let failedSessions = 0;
|
|
1441
1850
|
let warned = false;
|
|
1851
|
+
const sessionRoutes = /* @__PURE__ */ new Map();
|
|
1852
|
+
let lastRoute;
|
|
1853
|
+
let configuredRoute;
|
|
1442
1854
|
return {
|
|
1443
1855
|
// Runs once, before the session does any work.
|
|
1444
1856
|
config: async (config) => {
|
|
1445
1857
|
try {
|
|
1446
1858
|
const cfg = config;
|
|
1447
1859
|
const health = readHealth(project);
|
|
1860
|
+
const routes = unhealthyRoutes(project);
|
|
1448
1861
|
const unhealthy = unhealthyModels(project);
|
|
1449
1862
|
const activeId = cfg.harness?.profile ?? profileIdFromConfig();
|
|
1450
1863
|
if (!activeId) return;
|
|
@@ -1455,8 +1868,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1455
1868
|
} catch {
|
|
1456
1869
|
return;
|
|
1457
1870
|
}
|
|
1458
|
-
const baseKey =
|
|
1459
|
-
if (
|
|
1871
|
+
const baseKey = routeKey(profile.provider, profile.model);
|
|
1872
|
+
if (routes[baseKey]) {
|
|
1460
1873
|
const queue = [id, ...profile.fallback ?? []];
|
|
1461
1874
|
for (const candidate of queue) {
|
|
1462
1875
|
let fallback;
|
|
@@ -1465,8 +1878,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1465
1878
|
} catch {
|
|
1466
1879
|
continue;
|
|
1467
1880
|
}
|
|
1468
|
-
const key =
|
|
1469
|
-
if (
|
|
1881
|
+
const key = routeKey(fallback.provider, fallback.model);
|
|
1882
|
+
if (routes[key]) continue;
|
|
1470
1883
|
const fleet = isFleet(profile);
|
|
1471
1884
|
applySessionModel(cfg, fallback, {
|
|
1472
1885
|
keepLanes: fleet,
|
|
@@ -1481,6 +1894,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1481
1894
|
break;
|
|
1482
1895
|
}
|
|
1483
1896
|
}
|
|
1897
|
+
configuredRoute = routeOfConfig(cfg) ?? routeKey(profile.provider, profile.model);
|
|
1484
1898
|
const roles = resolveRoles(profile, { unhealthy });
|
|
1485
1899
|
for (const [lane, level] of Object.entries(roles)) {
|
|
1486
1900
|
if (level === void 0) continue;
|
|
@@ -1488,7 +1902,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1488
1902
|
if (!agent) continue;
|
|
1489
1903
|
const resolved = resolveLaneModel(profile, lane, { unhealthy });
|
|
1490
1904
|
if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue;
|
|
1491
|
-
const wantModel =
|
|
1905
|
+
const wantModel = routeKey(profile.provider, resolved.model);
|
|
1492
1906
|
if (agent.model !== wantModel) {
|
|
1493
1907
|
agent.model = wantModel;
|
|
1494
1908
|
const requested = profile.roles?.[lane] ?? null;
|
|
@@ -1504,14 +1918,21 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1504
1918
|
const e = event;
|
|
1505
1919
|
const type = e?.type;
|
|
1506
1920
|
if (!type) return;
|
|
1507
|
-
const
|
|
1508
|
-
|
|
1921
|
+
const seen = routeOf(e);
|
|
1922
|
+
if (seen) {
|
|
1923
|
+
lastRoute = seen;
|
|
1924
|
+
const sessionID2 = sessionIdOf(e);
|
|
1925
|
+
if (sessionID2) sessionRoutes.set(sessionID2, seen);
|
|
1926
|
+
}
|
|
1927
|
+
const err = errorOf(e);
|
|
1928
|
+
if (!err) return;
|
|
1929
|
+
const reason = classifyError(err);
|
|
1509
1930
|
if (!reason) return;
|
|
1510
|
-
const
|
|
1511
|
-
const key =
|
|
1931
|
+
const sessionID = sessionIdOf(e);
|
|
1932
|
+
const key = seen ?? (sessionID ? sessionRoutes.get(sessionID) : void 0) ?? configuredRoute ?? lastRoute;
|
|
1512
1933
|
if (!key) return;
|
|
1513
1934
|
const health = readHealth(project);
|
|
1514
|
-
if (
|
|
1935
|
+
if (unhealthyRoutes(project)[key]) return;
|
|
1515
1936
|
health.unhealthy[key] = { at: (/* @__PURE__ */ new Date()).toISOString(), reason };
|
|
1516
1937
|
writeHealth(project, health);
|
|
1517
1938
|
failedSessions++;
|
|
@@ -1574,7 +1995,26 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
|
1574
1995
|
const started = Date.now();
|
|
1575
1996
|
const opened = /* @__PURE__ */ new Set();
|
|
1576
1997
|
const closed = /* @__PURE__ */ new Set();
|
|
1998
|
+
const requests = /* @__PURE__ */ new Map();
|
|
1999
|
+
const REQUEST_MEMORY = 500;
|
|
2000
|
+
const rememberRequest = (input) => {
|
|
2001
|
+
const id = input?.message?.id;
|
|
2002
|
+
if (typeof id !== "string") return;
|
|
2003
|
+
const model = input?.model;
|
|
2004
|
+
requests.set(id, {
|
|
2005
|
+
agent: typeof input?.agent === "string" ? input.agent : void 0,
|
|
2006
|
+
model: typeof model?.modelID === "string" ? model.modelID : void 0,
|
|
2007
|
+
provider: typeof model?.providerID === "string" ? model.providerID : void 0
|
|
2008
|
+
});
|
|
2009
|
+
if (requests.size > REQUEST_MEMORY) {
|
|
2010
|
+
const oldest = requests.keys().next();
|
|
2011
|
+
if (!oldest.done) requests.delete(oldest.value);
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
1577
2014
|
return {
|
|
2015
|
+
"chat.params": async (input) => {
|
|
2016
|
+
rememberRequest(input);
|
|
2017
|
+
},
|
|
1578
2018
|
event: async ({ event }) => {
|
|
1579
2019
|
const type = event?.type;
|
|
1580
2020
|
if (typeof type !== "string") return;
|
|
@@ -1583,7 +2023,7 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
|
1583
2023
|
return;
|
|
1584
2024
|
}
|
|
1585
2025
|
if (type === "message.updated") {
|
|
1586
|
-
const lane = laneOf(event);
|
|
2026
|
+
const lane = laneOf(event, requests);
|
|
1587
2027
|
if (!lane || !lane.id) return;
|
|
1588
2028
|
if (lane.ms !== void 0) {
|
|
1589
2029
|
if (closed.has(lane.id)) return;
|
|
@@ -1607,15 +2047,16 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
|
1607
2047
|
}
|
|
1608
2048
|
};
|
|
1609
2049
|
};
|
|
1610
|
-
function laneOf(event) {
|
|
2050
|
+
function laneOf(event, requests) {
|
|
1611
2051
|
const e = event;
|
|
1612
2052
|
const info = e?.properties?.info ?? e?.properties;
|
|
1613
2053
|
if (!info || typeof info !== "object") return null;
|
|
1614
2054
|
if (info.role && info.role !== "assistant") return null;
|
|
1615
2055
|
const id = typeof info.id === "string" ? info.id : void 0;
|
|
1616
|
-
const
|
|
1617
|
-
const
|
|
1618
|
-
const
|
|
2056
|
+
const parent = typeof info.parentID === "string" ? requests.get(info.parentID) : void 0;
|
|
2057
|
+
const agent = parent?.agent ?? (typeof info.agent === "string" ? info.agent : void 0);
|
|
2058
|
+
const modelId = parent?.model ?? info.model?.modelID ?? info.modelID;
|
|
2059
|
+
const providerId = parent?.provider ?? info.model?.providerID ?? info.providerID;
|
|
1619
2060
|
const time = info.time ?? {};
|
|
1620
2061
|
const created = typeof time.created === "number" ? time.created : void 0;
|
|
1621
2062
|
const completed = typeof time.completed === "number" ? time.completed : void 0;
|