opencode-harness-kit 0.6.3 → 0.7.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/CHANGELOG.md +303 -10
- package/README.md +244 -14
- package/bin/oc-harness.mjs +234 -28
- package/dist/budget.d.ts +247 -0
- package/dist/config.d.ts +34 -1
- package/dist/effort.d.ts +12 -1
- package/dist/guard.d.ts +5 -0
- package/dist/health.d.ts +12 -1
- package/dist/index.js +556 -149
- package/dist/profile.d.ts +2 -1
- package/dist/prune.d.ts +22 -0
- package/dist/rules.d.ts +6 -2
- package/docs/EXIT-CODES.md +18 -4
- package/docs/REVIEW-VERDICT.md +73 -0
- package/eval/RESULTS.md +130 -7
- package/eval/ab.mjs +591 -68
- package/eval/compare.mjs +267 -0
- package/eval/verify.mjs +9 -0
- package/lanes.json +18 -9
- package/package.json +6 -2
- package/profiles/index.json +1 -12
- package/prompts/reviewer.md +24 -0
- package/retention.yml +0 -8
- package/src/budget.js +418 -0
- package/src/config.js +172 -6
- package/src/driver/run.mjs +780 -42
- package/src/effort.js +17 -3
- package/src/guard.ts +304 -1
- package/src/health.js +13 -1
- package/src/index.ts +23 -12
- package/src/profile.js +1 -1
- package/src/prune.ts +21 -1
- package/src/rules.js +145 -0
- package/src/tasks.js +31 -0
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { mkdirSync as
|
|
3
|
-
import { join as
|
|
2
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3
|
+
import { join as join11 } from "node:path";
|
|
4
4
|
|
|
5
5
|
// src/guard.ts
|
|
6
|
-
import { homedir as
|
|
7
|
-
import { join as
|
|
6
|
+
import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
|
|
7
|
+
import { join as join5, resolve as resolve2 } from "node:path";
|
|
8
8
|
|
|
9
9
|
// src/rules.js
|
|
10
10
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -234,12 +234,12 @@ function pipeStages(command) {
|
|
|
234
234
|
return split(command, "pipe");
|
|
235
235
|
}
|
|
236
236
|
function tokenize(segment) {
|
|
237
|
-
const
|
|
237
|
+
const tokens2 = [];
|
|
238
238
|
let cur = "";
|
|
239
239
|
let quote = null;
|
|
240
240
|
let started = false;
|
|
241
241
|
const flush = () => {
|
|
242
|
-
if (cur || started)
|
|
242
|
+
if (cur || started) tokens2.push(cur);
|
|
243
243
|
cur = "";
|
|
244
244
|
started = false;
|
|
245
245
|
};
|
|
@@ -274,7 +274,7 @@ function tokenize(segment) {
|
|
|
274
274
|
cur += ch;
|
|
275
275
|
}
|
|
276
276
|
flush();
|
|
277
|
-
return
|
|
277
|
+
return tokens2;
|
|
278
278
|
}
|
|
279
279
|
var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
280
280
|
var WRAPPERS = {
|
|
@@ -404,66 +404,66 @@ function baseName(word) {
|
|
|
404
404
|
const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"));
|
|
405
405
|
return slash === -1 ? word : word.slice(slash + 1);
|
|
406
406
|
}
|
|
407
|
-
function skipWrapperArgs(
|
|
407
|
+
function skipWrapperArgs(tokens2, i, name) {
|
|
408
408
|
const valueFlags = WRAPPER_TABLE[name] ?? WRAPPER_VALUE_FLAGS;
|
|
409
|
-
while (i <
|
|
410
|
-
const flag =
|
|
409
|
+
while (i < tokens2.length && tokens2[i].startsWith("-") && tokens2[i] !== "-") {
|
|
410
|
+
const flag = tokens2[i];
|
|
411
411
|
i++;
|
|
412
|
-
if (valueFlags.has(flag) && !flag.includes("=") && i <
|
|
412
|
+
if (valueFlags.has(flag) && !flag.includes("=") && i < tokens2.length) i++;
|
|
413
413
|
}
|
|
414
|
-
if (name === "timeout" && i <
|
|
415
|
-
if (name === "chroot" && i <
|
|
414
|
+
if (name === "timeout" && i < tokens2.length && DURATION.test(tokens2[i])) i++;
|
|
415
|
+
if (name === "chroot" && i < tokens2.length && !tokens2[i].startsWith("-")) i++;
|
|
416
416
|
return i;
|
|
417
417
|
}
|
|
418
|
-
function skipPrefixes(
|
|
418
|
+
function skipPrefixes(tokens2) {
|
|
419
419
|
let i = 0;
|
|
420
420
|
for (; ; ) {
|
|
421
|
-
const word =
|
|
422
|
-
if (word === void 0) return
|
|
421
|
+
const word = tokens2[i];
|
|
422
|
+
if (word === void 0) return tokens2.length;
|
|
423
423
|
if (ENV_ASSIGN.test(word)) {
|
|
424
424
|
i++;
|
|
425
425
|
continue;
|
|
426
426
|
}
|
|
427
427
|
const name = baseName(word);
|
|
428
428
|
if (name in WRAPPER_TABLE) {
|
|
429
|
-
i = skipWrapperArgs(
|
|
429
|
+
i = skipWrapperArgs(tokens2, i + 1, name);
|
|
430
430
|
continue;
|
|
431
431
|
}
|
|
432
432
|
return i;
|
|
433
433
|
}
|
|
434
434
|
}
|
|
435
435
|
function stripPrefixes(segment) {
|
|
436
|
-
const
|
|
437
|
-
const i = skipPrefixes(
|
|
438
|
-
if (i >=
|
|
439
|
-
const rest =
|
|
436
|
+
const tokens2 = tokenize(segment);
|
|
437
|
+
const i = skipPrefixes(tokens2);
|
|
438
|
+
if (i >= tokens2.length) return "";
|
|
439
|
+
const rest = tokens2.slice(i);
|
|
440
440
|
rest[0] = baseName(rest[0]);
|
|
441
441
|
return rest.join(" ");
|
|
442
442
|
}
|
|
443
443
|
function stripToolFlags(command) {
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
446
|
-
const flags = TOOL_VALUE_FLAGS[baseName(
|
|
444
|
+
const tokens2 = tokenize(command);
|
|
445
|
+
if (!tokens2.length) return command;
|
|
446
|
+
const flags = TOOL_VALUE_FLAGS[baseName(tokens2[0]).toLowerCase()];
|
|
447
447
|
if (!flags) return command;
|
|
448
448
|
const out = [];
|
|
449
|
-
for (let i = 0; i <
|
|
450
|
-
const token =
|
|
449
|
+
for (let i = 0; i < tokens2.length; i++) {
|
|
450
|
+
const token = tokens2[i];
|
|
451
451
|
if (!flags.has(token.split("=")[0])) {
|
|
452
452
|
out.push(token);
|
|
453
453
|
continue;
|
|
454
454
|
}
|
|
455
|
-
if (!token.includes("=") && i + 1 <
|
|
455
|
+
if (!token.includes("=") && i + 1 < tokens2.length) i++;
|
|
456
456
|
}
|
|
457
457
|
return out.join(" ");
|
|
458
458
|
}
|
|
459
459
|
function gitSubcommand(segment) {
|
|
460
|
-
const
|
|
461
|
-
let i = skipPrefixes(
|
|
462
|
-
if (i >=
|
|
463
|
-
if (!/(^|[\\/])git(\.exe)?$/i.test(
|
|
460
|
+
const tokens2 = tokenize(segment);
|
|
461
|
+
let i = skipPrefixes(tokens2);
|
|
462
|
+
if (i >= tokens2.length) return null;
|
|
463
|
+
if (!/(^|[\\/])git(\.exe)?$/i.test(tokens2[i])) return null;
|
|
464
464
|
i++;
|
|
465
|
-
while (i <
|
|
466
|
-
const flag =
|
|
465
|
+
while (i < tokens2.length) {
|
|
466
|
+
const flag = tokens2[i];
|
|
467
467
|
if (flag.length > 2 && !flag.startsWith("--") && (flag.startsWith("-C") || flag.startsWith("-c"))) {
|
|
468
468
|
i++;
|
|
469
469
|
continue;
|
|
@@ -478,32 +478,32 @@ function gitSubcommand(segment) {
|
|
|
478
478
|
}
|
|
479
479
|
break;
|
|
480
480
|
}
|
|
481
|
-
return
|
|
481
|
+
return tokens2[i] ?? null;
|
|
482
482
|
}
|
|
483
483
|
var MAX_SHELL_DEPTH = 2;
|
|
484
|
-
function shellCommandString(
|
|
485
|
-
for (let n = from + 1; n <
|
|
486
|
-
const token =
|
|
484
|
+
function shellCommandString(tokens2, from) {
|
|
485
|
+
for (let n = from + 1; n < tokens2.length; n++) {
|
|
486
|
+
const token = tokens2[n];
|
|
487
487
|
if (!token.startsWith("-") || token === "-") continue;
|
|
488
488
|
const m = /^-([a-zA-Z]*)c/.exec(token);
|
|
489
489
|
if (!m) continue;
|
|
490
490
|
const after = token.slice(m[0].length);
|
|
491
491
|
if (after && !/^[a-zA-Z]+$/.test(after)) return after.trim();
|
|
492
|
-
return
|
|
492
|
+
return tokens2.slice(n + 1).join(" ") || null;
|
|
493
493
|
}
|
|
494
494
|
return null;
|
|
495
495
|
}
|
|
496
|
-
function envSplitPayload(
|
|
497
|
-
for (let i = 0; i <
|
|
498
|
-
const word =
|
|
496
|
+
function envSplitPayload(tokens2) {
|
|
497
|
+
for (let i = 0; i < tokens2.length; i++) {
|
|
498
|
+
const word = tokens2[i];
|
|
499
499
|
if (word === void 0) return null;
|
|
500
500
|
if (ENV_ASSIGN.test(word)) continue;
|
|
501
501
|
const name = baseName(word);
|
|
502
502
|
if (name === "env") {
|
|
503
|
-
for (let j = i + 1; j <
|
|
504
|
-
const token =
|
|
503
|
+
for (let j = i + 1; j < tokens2.length; j++) {
|
|
504
|
+
const token = tokens2[j];
|
|
505
505
|
if (token === "-S" || token === "--split-string") {
|
|
506
|
-
return
|
|
506
|
+
return tokens2[j + 1] ?? null;
|
|
507
507
|
}
|
|
508
508
|
if (token.startsWith("--split-string=")) {
|
|
509
509
|
return token.slice("--split-string=".length) || null;
|
|
@@ -517,20 +517,20 @@ function envSplitPayload(tokens) {
|
|
|
517
517
|
return null;
|
|
518
518
|
}
|
|
519
519
|
if (!(name in WRAPPER_TABLE)) return null;
|
|
520
|
-
i = skipWrapperArgs(
|
|
520
|
+
i = skipWrapperArgs(tokens2, i + 1, name) - 1;
|
|
521
521
|
}
|
|
522
522
|
return null;
|
|
523
523
|
}
|
|
524
524
|
function shellPayload(segment) {
|
|
525
|
-
const
|
|
526
|
-
if (
|
|
527
|
-
const split2 = envSplitPayload(
|
|
525
|
+
const tokens2 = tokenize(segment);
|
|
526
|
+
if (tokens2.length < 2) return null;
|
|
527
|
+
const split2 = envSplitPayload(tokens2);
|
|
528
528
|
if (split2) return split2;
|
|
529
|
-
const i = skipPrefixes(
|
|
530
|
-
if (i >=
|
|
531
|
-
if (
|
|
532
|
-
if (!SHELL_NAMES.test(baseName(
|
|
533
|
-
return shellCommandString(
|
|
529
|
+
const i = skipPrefixes(tokens2);
|
|
530
|
+
if (i >= tokens2.length) return null;
|
|
531
|
+
if (tokens2[i] === "eval") return tokens2.slice(i + 1).join(" ") || null;
|
|
532
|
+
if (!SHELL_NAMES.test(baseName(tokens2[i]))) return null;
|
|
533
|
+
return shellCommandString(tokens2, i);
|
|
534
534
|
}
|
|
535
535
|
var GROUP_OPEN = /^[({[]+/;
|
|
536
536
|
var GROUP_END = /[;)&,}\]]+$/;
|
|
@@ -539,14 +539,14 @@ function commandName(word) {
|
|
|
539
539
|
}
|
|
540
540
|
function runsScript(stage) {
|
|
541
541
|
for (const command of segments(stage)) {
|
|
542
|
-
const
|
|
543
|
-
let i = skipPrefixes(
|
|
544
|
-
if (i >=
|
|
545
|
-
let name = commandName(
|
|
542
|
+
const tokens2 = tokenize(command);
|
|
543
|
+
let i = skipPrefixes(tokens2);
|
|
544
|
+
if (i >= tokens2.length) continue;
|
|
545
|
+
let name = commandName(tokens2[i]);
|
|
546
546
|
if (!name) {
|
|
547
547
|
i++;
|
|
548
|
-
if (i >=
|
|
549
|
-
name = commandName(
|
|
548
|
+
if (i >= tokens2.length) continue;
|
|
549
|
+
name = commandName(tokens2[i]);
|
|
550
550
|
}
|
|
551
551
|
if (SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name)) return true;
|
|
552
552
|
}
|
|
@@ -568,15 +568,15 @@ function isDestructiveRmFlag(token) {
|
|
|
568
568
|
return /^-[a-zA-Z]*[rf]/i.test(token);
|
|
569
569
|
}
|
|
570
570
|
function isRecursiveDelete(text) {
|
|
571
|
-
const
|
|
572
|
-
if (
|
|
573
|
-
if (baseName(
|
|
571
|
+
const tokens2 = tokenize(text);
|
|
572
|
+
if (tokens2.length < 2) return false;
|
|
573
|
+
if (baseName(tokens2[0]).toLowerCase() !== "rm") return false;
|
|
574
574
|
let destructive = false;
|
|
575
575
|
let hasPath = false;
|
|
576
|
-
for (let i = 1; i <
|
|
577
|
-
const token =
|
|
576
|
+
for (let i = 1; i < tokens2.length; i++) {
|
|
577
|
+
const token = tokens2[i];
|
|
578
578
|
if (token === "--") {
|
|
579
|
-
hasPath =
|
|
579
|
+
hasPath = tokens2.length > i + 1;
|
|
580
580
|
break;
|
|
581
581
|
}
|
|
582
582
|
if (!token.startsWith("-")) {
|
|
@@ -589,11 +589,11 @@ function isRecursiveDelete(text) {
|
|
|
589
589
|
}
|
|
590
590
|
var CHMOD_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--reference"]);
|
|
591
591
|
function chmodMode(text) {
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
594
|
-
if (baseName(
|
|
595
|
-
for (let i = 1; i <
|
|
596
|
-
const token =
|
|
592
|
+
const tokens2 = tokenize(text);
|
|
593
|
+
if (tokens2.length < 2) return null;
|
|
594
|
+
if (baseName(tokens2[0]).toLowerCase() !== "chmod") return null;
|
|
595
|
+
for (let i = 1; i < tokens2.length; i++) {
|
|
596
|
+
const token = tokens2[i];
|
|
597
597
|
if (token === "--") continue;
|
|
598
598
|
if (token.startsWith("--")) {
|
|
599
599
|
if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++;
|
|
@@ -612,16 +612,16 @@ function opensPermissions(text) {
|
|
|
612
612
|
}
|
|
613
613
|
var ASK_GATED = [/^\s*git\s+push\b/];
|
|
614
614
|
function downloadTarget(segment) {
|
|
615
|
-
const
|
|
616
|
-
const i = skipPrefixes(
|
|
617
|
-
if (i >=
|
|
618
|
-
const name = baseName(
|
|
615
|
+
const tokens2 = tokenize(segment);
|
|
616
|
+
const i = skipPrefixes(tokens2);
|
|
617
|
+
if (i >= tokens2.length) return null;
|
|
618
|
+
const name = baseName(tokens2[i]).toLowerCase();
|
|
619
619
|
if (name !== "curl" && name !== "wget") return null;
|
|
620
|
-
for (let j = i + 1; j <
|
|
621
|
-
const token =
|
|
620
|
+
for (let j = i + 1; j < tokens2.length; j++) {
|
|
621
|
+
const token = tokens2[j];
|
|
622
622
|
if (token === "--") break;
|
|
623
623
|
if (token === "-o" || token === "--output" || token === "-O" || token === "--output-document") {
|
|
624
|
-
const value =
|
|
624
|
+
const value = tokens2[j + 1];
|
|
625
625
|
return value && !value.startsWith("-") ? value : null;
|
|
626
626
|
}
|
|
627
627
|
if (token.startsWith("--output=") || token.startsWith("--output-document=")) {
|
|
@@ -639,15 +639,15 @@ function sameFile(a, b) {
|
|
|
639
639
|
return false;
|
|
640
640
|
}
|
|
641
641
|
function runsFile(segment, file) {
|
|
642
|
-
const
|
|
643
|
-
const i = skipPrefixes(
|
|
644
|
-
if (i >=
|
|
645
|
-
if (sameFile(
|
|
646
|
-
const name = baseName(
|
|
642
|
+
const tokens2 = tokenize(segment);
|
|
643
|
+
const i = skipPrefixes(tokens2);
|
|
644
|
+
if (i >= tokens2.length) return false;
|
|
645
|
+
if (sameFile(tokens2[i], file)) return true;
|
|
646
|
+
const name = baseName(tokens2[i]);
|
|
647
647
|
const runner = SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name) || name === "source" || name === ".";
|
|
648
648
|
if (!runner) return false;
|
|
649
|
-
for (let j = i + 1; j <
|
|
650
|
-
if (sameFile(
|
|
649
|
+
for (let j = i + 1; j < tokens2.length; j++) {
|
|
650
|
+
if (sameFile(tokens2[j], file)) return true;
|
|
651
651
|
}
|
|
652
652
|
return false;
|
|
653
653
|
}
|
|
@@ -738,8 +738,81 @@ var BASH_RULES = [
|
|
|
738
738
|
return false;
|
|
739
739
|
},
|
|
740
740
|
why: "destroys work that only exists locally"
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
name: "destructive python via python -c",
|
|
744
|
+
scope: "segment",
|
|
745
|
+
// The permission list allows `python3 -c*` unprompted, because a python
|
|
746
|
+
// one-liner is the ordinary way to read a sqlite file or count tokens -
|
|
747
|
+
// and the shells rules read NONE of what it runs. `python3 -c "import
|
|
748
|
+
// shutil; shutil.rmtree('/')"` is `rm -rf /` in a script the guard
|
|
749
|
+
// never looked at.
|
|
750
|
+
//
|
|
751
|
+
// Only recursive tree surgery is caught, and only when the target is not
|
|
752
|
+
// obviously scratch: an argument whose name is some spelling of tmp or
|
|
753
|
+
// temp is a script cleaning its own scratch directory, which the kit
|
|
754
|
+
// itself tells agents to use, and blocking that would break the
|
|
755
|
+
// workflow the harness sets up. A variable, an absolute path, a quoted
|
|
756
|
+
// literal that is not a temp name - all block, because "not obviously
|
|
757
|
+
// scratch" is the honest statement about what a caller can know.
|
|
758
|
+
check: (text) => embeddedPythonDestroys(text),
|
|
759
|
+
why: "recursive deletion by script, hidden from the shell rules"
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
name: "destructive javascript via node -e",
|
|
763
|
+
scope: "segment",
|
|
764
|
+
// Same hole, and the list allows `node -e*` unprompted the same way:
|
|
765
|
+
// `node -e "require('fs').rmSync('/x',{recursive:true,force:true})"` is
|
|
766
|
+
// `rm -rf` the shell rules never saw. Same scratch carve-out, for the
|
|
767
|
+
// same reason and with the same limit.
|
|
768
|
+
check: (text) => embeddedNodeDestroys(text),
|
|
769
|
+
why: "recursive deletion by script, hidden from the shell rules"
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
name: "find with -delete or -exec rm",
|
|
773
|
+
scope: "segment",
|
|
774
|
+
// The list allows `find*` unprompted, and a find that deletes is a walk
|
|
775
|
+
// that deletes everything it passes: `find . -delete` is `rm -rf .` with
|
|
776
|
+
// extra steps, and `-exec rm` is the same with the filter made visible.
|
|
777
|
+
// The segment's first word is the command, so `man find` - a page the
|
|
778
|
+
// agent reads, not runs - is an ordinary read and stays one here too.
|
|
779
|
+
check: (text) => findDestroys(text),
|
|
780
|
+
why: "a walk that deletes everything it passes"
|
|
741
781
|
}
|
|
742
782
|
];
|
|
783
|
+
function evalPrograms(text) {
|
|
784
|
+
const out = [];
|
|
785
|
+
const re = /(?:^|[;&|\n]|\s)(?:python3?|node)\s+(?:-(?:[\w.]+)\s+)*(?:-c|--eval|-e)\s*/g;
|
|
786
|
+
for (const m of text.matchAll(re)) {
|
|
787
|
+
const code = text.slice(m.index + m[0].length);
|
|
788
|
+
if (code.trim()) out.push(code);
|
|
789
|
+
}
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
function scratchish(arg) {
|
|
793
|
+
return /tmp|temp|scratch/i.test(arg) || /\btmpdir\b/.test(arg);
|
|
794
|
+
}
|
|
795
|
+
function embeddedPythonDestroys(text) {
|
|
796
|
+
for (const code of evalPrograms(text)) {
|
|
797
|
+
const call = /shutil\s*\.\s*rmtree\s*\(\s*([^,)]*)/.exec(code);
|
|
798
|
+
if (!call) continue;
|
|
799
|
+
if (!call[1] || !scratchish(call[1])) return true;
|
|
800
|
+
}
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
function embeddedNodeDestroys(text) {
|
|
804
|
+
for (const code of evalPrograms(text)) {
|
|
805
|
+
for (const m of code.matchAll(/(?:rmSync|rmdirSync)\s*\(\s*([^,)]*)/g)) {
|
|
806
|
+
if (!/recursive\s*:\s*true/.test(code)) continue;
|
|
807
|
+
if (!m[1] || !scratchish(m[1])) return true;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
function findDestroys(text) {
|
|
813
|
+
if (!/^find(?:\s|$)/.test(text)) return false;
|
|
814
|
+
return /(?:^|\s)--?delete\b/.test(text) || /(?:^|\s)-exec(?:dir)?\s+(?:env\s+\S+\s+)*(rm|sh)\b/.test(text);
|
|
815
|
+
}
|
|
743
816
|
var WRITE_RULES = [
|
|
744
817
|
{
|
|
745
818
|
// `unless` exists because the safe variants are the same shape as the
|
|
@@ -829,27 +902,183 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
|
|
|
829
902
|
return null;
|
|
830
903
|
}
|
|
831
904
|
|
|
905
|
+
// src/budget.js
|
|
906
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
907
|
+
import { homedir as homedir2 } from "node:os";
|
|
908
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
909
|
+
var DEFAULT_WARN_TOKENS = 2e6;
|
|
910
|
+
var DEFAULT_STOP_TOKENS = 5e6;
|
|
911
|
+
var DEFAULT_TURN_STOP_TOKENS = 3e5;
|
|
912
|
+
var DEFAULT_WINDOW_MINUTES = 60;
|
|
913
|
+
var DEFAULT_WINDOW_WARN_TOKENS = 4e6;
|
|
914
|
+
var DEFAULT_WINDOW_STOP_TOKENS = 8e6;
|
|
915
|
+
function count(raw, fallback) {
|
|
916
|
+
if (raw === void 0 || raw === null || raw === "") return fallback;
|
|
917
|
+
const n = Number(raw);
|
|
918
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
919
|
+
return Math.floor(n);
|
|
920
|
+
}
|
|
921
|
+
function requestTokens(tokens2) {
|
|
922
|
+
const n = (raw) => {
|
|
923
|
+
const x = Number(raw);
|
|
924
|
+
return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
|
|
925
|
+
};
|
|
926
|
+
return n(tokens2?.input) + n(tokens2?.cache?.read) + n(tokens2?.cache?.write);
|
|
927
|
+
}
|
|
928
|
+
function budgetLimits(env = process.env) {
|
|
929
|
+
const warnAt = count(env?.OC_HARNESS_BUDGET_WARN, DEFAULT_WARN_TOKENS);
|
|
930
|
+
const stopAt = count(env?.OC_HARNESS_BUDGET_STOP, DEFAULT_STOP_TOKENS);
|
|
931
|
+
const turnStopAt = count(env?.OC_HARNESS_TURN_STOP, DEFAULT_TURN_STOP_TOKENS);
|
|
932
|
+
return {
|
|
933
|
+
warnAt: stopAt > 0 && warnAt > stopAt ? stopAt : warnAt,
|
|
934
|
+
stopAt,
|
|
935
|
+
turnStopAt
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function createBudget({ warnAt = DEFAULT_WARN_TOKENS, stopAt = DEFAULT_STOP_TOKENS } = {}) {
|
|
939
|
+
let total = 0;
|
|
940
|
+
let warned = false;
|
|
941
|
+
let stopped = false;
|
|
942
|
+
return {
|
|
943
|
+
/**
|
|
944
|
+
* @param {any} input tokens this request cost
|
|
945
|
+
* @returns {{ warn: boolean, stop: boolean }}
|
|
946
|
+
*/
|
|
947
|
+
add(input) {
|
|
948
|
+
const n = Number(input);
|
|
949
|
+
if (!Number.isFinite(n) || n <= 0) return { warn: false, stop: false };
|
|
950
|
+
total += Math.floor(n);
|
|
951
|
+
let warn = false;
|
|
952
|
+
let stop = false;
|
|
953
|
+
if (!warned && warnAt > 0 && total >= warnAt) {
|
|
954
|
+
warned = true;
|
|
955
|
+
warn = true;
|
|
956
|
+
}
|
|
957
|
+
if (!stopped && stopAt > 0 && total >= stopAt) {
|
|
958
|
+
stopped = true;
|
|
959
|
+
stop = true;
|
|
960
|
+
}
|
|
961
|
+
return { warn, stop };
|
|
962
|
+
},
|
|
963
|
+
get total() {
|
|
964
|
+
return total;
|
|
965
|
+
},
|
|
966
|
+
get warned() {
|
|
967
|
+
return warned;
|
|
968
|
+
},
|
|
969
|
+
get stopped() {
|
|
970
|
+
return stopped;
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
function tokens(n) {
|
|
975
|
+
return Math.floor(n).toLocaleString("en-US");
|
|
976
|
+
}
|
|
977
|
+
var KEEP_MS = 6 * 60 * 60 * 1e3;
|
|
978
|
+
function ledgerPath(env = process.env) {
|
|
979
|
+
const override = env?.OC_HARNESS_BUDGET_FILE;
|
|
980
|
+
if (override) return override;
|
|
981
|
+
const state = env?.XDG_STATE_HOME || join2(env?.HOME || homedir2(), ".local", "state");
|
|
982
|
+
return join2(state, "opencode-harness-kit", "budget.json");
|
|
983
|
+
}
|
|
984
|
+
function readSpend(path) {
|
|
985
|
+
let raw;
|
|
986
|
+
try {
|
|
987
|
+
if (!existsSync(path)) return [];
|
|
988
|
+
raw = readFileSync(path, "utf8");
|
|
989
|
+
} catch {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
let parsed;
|
|
993
|
+
try {
|
|
994
|
+
parsed = JSON.parse(raw);
|
|
995
|
+
} catch {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
if (!Array.isArray(parsed)) return null;
|
|
999
|
+
return parsed.filter(
|
|
1000
|
+
(e) => e && Number.isFinite(e.t) && Number.isFinite(e.n) && (e.n > 0 || e.stop === true && e.n === 0)
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
function pruneSpend(entries, now) {
|
|
1004
|
+
const floor = now - KEEP_MS;
|
|
1005
|
+
return entries.filter((e) => e.t >= floor).sort((a, b) => a.t - b.t);
|
|
1006
|
+
}
|
|
1007
|
+
function repairSpend(path) {
|
|
1008
|
+
try {
|
|
1009
|
+
if (existsSync(path)) renameSync(path, `${path}.corrupt`);
|
|
1010
|
+
writeSpend(path, []);
|
|
1011
|
+
return true;
|
|
1012
|
+
} catch {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function writeSpend(path, entries) {
|
|
1017
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1018
|
+
mkdirSync(dirname2(path), { recursive: true });
|
|
1019
|
+
writeFileSync(tmp, JSON.stringify(entries));
|
|
1020
|
+
renameSync(tmp, path);
|
|
1021
|
+
}
|
|
1022
|
+
function recordSpend(path, n, now = Date.now()) {
|
|
1023
|
+
const previous = readSpend(path);
|
|
1024
|
+
if (previous === null) repairSpend(path);
|
|
1025
|
+
const entries = pruneSpend(previous ?? [], now);
|
|
1026
|
+
if (Number.isFinite(n) && n > 0) entries.push({ t: now, n: Math.floor(n) });
|
|
1027
|
+
writeSpend(path, entries);
|
|
1028
|
+
return entries;
|
|
1029
|
+
}
|
|
1030
|
+
function recordStop(path, now = Date.now()) {
|
|
1031
|
+
const previous = readSpend(path);
|
|
1032
|
+
if (previous === null) repairSpend(path);
|
|
1033
|
+
const entries = pruneSpend(previous ?? [], now);
|
|
1034
|
+
entries.push({ t: now, n: 0, stop: true });
|
|
1035
|
+
writeSpend(path, entries);
|
|
1036
|
+
return entries;
|
|
1037
|
+
}
|
|
1038
|
+
function stopRecorded(entries, now) {
|
|
1039
|
+
const floor = now - KEEP_MS;
|
|
1040
|
+
return entries.some((e) => e.stop === true && e.t >= floor);
|
|
1041
|
+
}
|
|
1042
|
+
function windowSpend(entries, now, windowMs) {
|
|
1043
|
+
const floor = now - windowMs;
|
|
1044
|
+
let total = 0;
|
|
1045
|
+
for (const e of entries) if (e.t >= floor) total += e.n;
|
|
1046
|
+
return total;
|
|
1047
|
+
}
|
|
1048
|
+
function windowLimits(env = process.env) {
|
|
1049
|
+
const rawMinutes = Number(env?.OC_HARNESS_BUDGET_WINDOW_MINUTES);
|
|
1050
|
+
const minutes = Number.isFinite(rawMinutes) && rawMinutes > 0 ? Math.floor(rawMinutes) : DEFAULT_WINDOW_MINUTES;
|
|
1051
|
+
const warnAt = count(env?.OC_HARNESS_BUDGET_WINDOW_WARN, DEFAULT_WINDOW_WARN_TOKENS);
|
|
1052
|
+
const stopAt = count(env?.OC_HARNESS_BUDGET_WINDOW_STOP, DEFAULT_WINDOW_STOP_TOKENS);
|
|
1053
|
+
return {
|
|
1054
|
+
windowMs: minutes * 6e4,
|
|
1055
|
+
warnAt: stopAt > 0 && warnAt > stopAt ? stopAt : warnAt,
|
|
1056
|
+
stopAt,
|
|
1057
|
+
sticky: env?.OC_HARNESS_BUDGET_WINDOW_STICKY !== "0"
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
832
1061
|
// src/telemetry-store.ts
|
|
833
|
-
import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs";
|
|
834
|
-
import { join as
|
|
1062
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync, statSync, renameSync as renameSync2 } from "node:fs";
|
|
1063
|
+
import { join as join3 } from "node:path";
|
|
835
1064
|
var MAX_BYTES = 4 * 1024 * 1024;
|
|
836
1065
|
function logPath(dir) {
|
|
837
|
-
return
|
|
1066
|
+
return join3(dir, "telemetry.jsonl");
|
|
838
1067
|
}
|
|
839
1068
|
function rotate(file, maxBytes = MAX_BYTES) {
|
|
840
1069
|
try {
|
|
841
|
-
if (
|
|
1070
|
+
if (existsSync2(file) && statSync(file).size > maxBytes) {
|
|
842
1071
|
const oldest = `${file}.2`;
|
|
843
|
-
if (
|
|
844
|
-
if (
|
|
845
|
-
|
|
1072
|
+
if (existsSync2(oldest)) rmSync(oldest, { force: true });
|
|
1073
|
+
if (existsSync2(`${file}.1`)) renameSync2(`${file}.1`, oldest);
|
|
1074
|
+
renameSync2(file, `${file}.1`);
|
|
846
1075
|
}
|
|
847
1076
|
} catch {
|
|
848
1077
|
}
|
|
849
1078
|
}
|
|
850
1079
|
function write(dir, record) {
|
|
851
1080
|
try {
|
|
852
|
-
if (!
|
|
1081
|
+
if (!existsSync2(dir)) mkdirSync2(dir, { recursive: true });
|
|
853
1082
|
const file = logPath(dir);
|
|
854
1083
|
rotate(file);
|
|
855
1084
|
appendFileSync(file, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...record }) + "\n");
|
|
@@ -858,14 +1087,14 @@ function write(dir, record) {
|
|
|
858
1087
|
}
|
|
859
1088
|
|
|
860
1089
|
// src/project.js
|
|
861
|
-
import { existsSync as
|
|
862
|
-
import { dirname as
|
|
1090
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1091
|
+
import { dirname as dirname3, join as join4, resolve } from "node:path";
|
|
863
1092
|
function projectRoot(directory, worktree) {
|
|
864
1093
|
const start = resolve(directory || worktree || ".");
|
|
865
1094
|
let dir = start;
|
|
866
1095
|
for (let i = 0; i < 64; i++) {
|
|
867
|
-
if (
|
|
868
|
-
const parent =
|
|
1096
|
+
if (existsSync3(join4(dir, ".git"))) return dir;
|
|
1097
|
+
const parent = dirname3(dir);
|
|
869
1098
|
if (parent === dir) break;
|
|
870
1099
|
dir = parent;
|
|
871
1100
|
}
|
|
@@ -882,6 +1111,13 @@ function hunkFollows(lines, from) {
|
|
|
882
1111
|
}
|
|
883
1112
|
return false;
|
|
884
1113
|
}
|
|
1114
|
+
function hunkLineCount(header) {
|
|
1115
|
+
const m = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(header);
|
|
1116
|
+
if (!m) return 0;
|
|
1117
|
+
const oldCount = m[2] === void 0 ? 1 : Number(m[2]);
|
|
1118
|
+
const newCount = m[4] === void 0 ? 1 : Number(m[4]);
|
|
1119
|
+
return oldCount + newCount;
|
|
1120
|
+
}
|
|
885
1121
|
function pathsInPatch(text) {
|
|
886
1122
|
const out = [];
|
|
887
1123
|
const push = (p) => {
|
|
@@ -892,15 +1128,34 @@ function pathsInPatch(text) {
|
|
|
892
1128
|
};
|
|
893
1129
|
const lines = text.split("\n");
|
|
894
1130
|
let seenHunk = false;
|
|
1131
|
+
let hunkRemaining = 0;
|
|
1132
|
+
let applyPatch = false;
|
|
895
1133
|
for (let i = 0; i < lines.length; i++) {
|
|
896
1134
|
const line = lines[i];
|
|
897
1135
|
const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line);
|
|
898
1136
|
if (marker) {
|
|
1137
|
+
applyPatch = true;
|
|
899
1138
|
push(marker[1]);
|
|
900
1139
|
continue;
|
|
901
1140
|
}
|
|
1141
|
+
const move = /^\*\*\*\s+Move to:\s*(.+)$/.exec(line);
|
|
1142
|
+
if (move) {
|
|
1143
|
+
push(move[1]);
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (applyPatch) continue;
|
|
1147
|
+
if (hunkRemaining > 0) {
|
|
1148
|
+
const first = line[0];
|
|
1149
|
+
if (first === " " || first === "-" || first === "+") {
|
|
1150
|
+
hunkRemaining--;
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
if (first === "\\") continue;
|
|
1154
|
+
hunkRemaining = 0;
|
|
1155
|
+
}
|
|
902
1156
|
if (/^@@/.test(line)) {
|
|
903
1157
|
seenHunk = true;
|
|
1158
|
+
hunkRemaining = hunkLineCount(line);
|
|
904
1159
|
continue;
|
|
905
1160
|
}
|
|
906
1161
|
const minus = /^---\s+(?:a\/)?(.+)$/.exec(line);
|
|
@@ -946,20 +1201,162 @@ function truncate(s, n = 200) {
|
|
|
946
1201
|
const one = s.replace(/\s+/g, " ").trim();
|
|
947
1202
|
return one.length > n ? `${one.slice(0, n)}...` : one;
|
|
948
1203
|
}
|
|
949
|
-
var GuardPlugin = async ({ worktree, directory }) => {
|
|
1204
|
+
var GuardPlugin = async ({ worktree, directory, client }) => {
|
|
950
1205
|
const project = projectRoot(directory, worktree);
|
|
951
|
-
const telemetryDir =
|
|
1206
|
+
const telemetryDir = join5(project, ".space");
|
|
952
1207
|
const refuse = (tool, rule, why, detail) => {
|
|
953
1208
|
write(telemetryDir, { kind: "guard", tool, rule });
|
|
954
1209
|
deny(why, detail);
|
|
955
1210
|
};
|
|
1211
|
+
const limits = budgetLimits();
|
|
1212
|
+
const budgets = /* @__PURE__ */ new Map();
|
|
1213
|
+
const counted = /* @__PURE__ */ new Set();
|
|
1214
|
+
const turnStops = /* @__PURE__ */ new Map();
|
|
1215
|
+
const window = windowLimits();
|
|
1216
|
+
const ledger = ledgerPath();
|
|
1217
|
+
let windowWarned = false;
|
|
1218
|
+
let windowSpent = false;
|
|
1219
|
+
let windowWhy = "";
|
|
1220
|
+
const sumWhy = () => `all sessions have used ${tokens(window.stopAt)} context tokens in the last ${Math.round(window.windowMs / 6e4)} min. Wait for it to roll, or raise OC_HARNESS_BUDGET_WINDOW_STOP.`;
|
|
1221
|
+
const budgetFor = (sessionID) => {
|
|
1222
|
+
let b = budgets.get(sessionID);
|
|
1223
|
+
if (!b) {
|
|
1224
|
+
b = createBudget(limits);
|
|
1225
|
+
budgets.set(sessionID, b);
|
|
1226
|
+
}
|
|
1227
|
+
return b;
|
|
1228
|
+
};
|
|
1229
|
+
const sharedWindowSpent = () => {
|
|
1230
|
+
if (windowSpent) return windowWhy;
|
|
1231
|
+
if (window.stopAt <= 0) return null;
|
|
1232
|
+
const entries = readSpend(ledger);
|
|
1233
|
+
if (entries === null) {
|
|
1234
|
+
if (repairSpend(ledger)) {
|
|
1235
|
+
write(telemetryDir, { kind: "budget repair", tool: "ledger", rule: "corrupt ledger moved to .corrupt" });
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
windowSpent = true;
|
|
1239
|
+
windowWhy = `the ledger at ${ledger} exists but cannot be read or repaired, so the spend cannot be counted. It is treated as spent rather than as zero: delete that file to start the window over, or raise OC_HARNESS_BUDGET_WINDOW_STOP.`;
|
|
1240
|
+
return windowWhy;
|
|
1241
|
+
}
|
|
1242
|
+
const now = Date.now();
|
|
1243
|
+
if (window.sticky && stopRecorded(entries, now)) {
|
|
1244
|
+
windowSpent = true;
|
|
1245
|
+
windowWhy = `a stop was recorded in this ledger and holds until its entries age out (six hours). The provider's window does not reopen on the ${Math.round(window.windowMs / 6e4)}-min timer, so this one does not either. Raise OC_HARNESS_BUDGET_WINDOW_STOP, or set OC_HARNESS_BUDGET_WINDOW_STICKY=0 to let the sum alone decide.`;
|
|
1246
|
+
return windowWhy;
|
|
1247
|
+
}
|
|
1248
|
+
if (windowSpend(entries, now, window.windowMs) >= window.stopAt) {
|
|
1249
|
+
windowSpent = true;
|
|
1250
|
+
windowWhy = sumWhy();
|
|
1251
|
+
try {
|
|
1252
|
+
recordStop(ledger);
|
|
1253
|
+
} catch {
|
|
1254
|
+
}
|
|
1255
|
+
return windowWhy;
|
|
1256
|
+
}
|
|
1257
|
+
return null;
|
|
1258
|
+
};
|
|
1259
|
+
const toast = async (message, variant) => {
|
|
1260
|
+
try {
|
|
1261
|
+
await client?.tui?.showToast({ body: { message, variant, duration: 12e3 } });
|
|
1262
|
+
} catch {
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
956
1265
|
return {
|
|
1266
|
+
// The tokens are reported on the `step-finish` part, once per request.
|
|
1267
|
+
// `message.updated` was the other candidate, but its token count grows
|
|
1268
|
+
// while the request streams and would have to be diffed per message; the
|
|
1269
|
+
// step part is already the final cost of that request.
|
|
1270
|
+
//
|
|
1271
|
+
// The cost is the whole context, not `tokens.input`: with prompt caching
|
|
1272
|
+
// the uncached part is a few hundred tokens against tens of thousands of
|
|
1273
|
+
// `cache.read`, so counting `input` alone made every threshold here about
|
|
1274
|
+
// five to forty times too high. See `requestTokens` in ./budget.js.
|
|
1275
|
+
event: async ({ event }) => {
|
|
1276
|
+
if (event?.type !== "message.part.updated") return;
|
|
1277
|
+
const part = event.properties?.part;
|
|
1278
|
+
if (!part || part.type !== "step-finish" || !part.sessionID) return;
|
|
1279
|
+
if (part.id) {
|
|
1280
|
+
if (counted.has(part.id)) return;
|
|
1281
|
+
counted.add(part.id);
|
|
1282
|
+
}
|
|
1283
|
+
const b = budgetFor(part.sessionID);
|
|
1284
|
+
const spent = requestTokens(part.tokens);
|
|
1285
|
+
const crossed = b.add(spent);
|
|
1286
|
+
let recent = 0;
|
|
1287
|
+
if (spent > 0) {
|
|
1288
|
+
try {
|
|
1289
|
+
recent = windowSpend(recordSpend(ledger, spent), Date.now(), window.windowMs);
|
|
1290
|
+
} catch {
|
|
1291
|
+
}
|
|
1292
|
+
if (!windowWarned && window.warnAt > 0 && recent >= window.warnAt) {
|
|
1293
|
+
windowWarned = true;
|
|
1294
|
+
await toast(
|
|
1295
|
+
`All sessions have used ${tokens(recent)} context tokens in the last ${Math.round(window.windowMs / 6e4)} min (warn at ${tokens(window.warnAt)}). The provider's window is shared, so a new session does not reset it.`,
|
|
1296
|
+
"warning"
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
if (!windowSpent && window.stopAt > 0 && recent >= window.stopAt) {
|
|
1300
|
+
windowSpent = true;
|
|
1301
|
+
windowWhy = sumWhy();
|
|
1302
|
+
try {
|
|
1303
|
+
recordStop(ledger);
|
|
1304
|
+
} catch {
|
|
1305
|
+
}
|
|
1306
|
+
await toast(
|
|
1307
|
+
`The shared window hit ${tokens(window.stopAt)} context tokens in ${Math.round(window.windowMs / 6e4)} min: tool calls are now blocked in every session. Wait for it to roll, or raise OC_HARNESS_BUDGET_WINDOW_STOP.`,
|
|
1308
|
+
"error"
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
if (limits.turnStopAt > 0 && spent >= limits.turnStopAt && !turnStops.has(part.sessionID)) {
|
|
1313
|
+
turnStops.set(part.sessionID, spent);
|
|
1314
|
+
await toast(
|
|
1315
|
+
`One request in this session re-sent ${tokens(spent)} context tokens (turn stop ${tokens(limits.turnStopAt)}). Tool calls in this session are now blocked: stop, do not retry the same step. Start a new session with a smaller task.`,
|
|
1316
|
+
"error"
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
if (crossed.warn) {
|
|
1320
|
+
await toast(
|
|
1321
|
+
`This session has used ${tokens(b.total)} context tokens (warn at ${tokens(limits.warnAt)}). Start a new session before the provider's window runs out.`,
|
|
1322
|
+
"warning"
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
if (crossed.stop) {
|
|
1326
|
+
await toast(
|
|
1327
|
+
`This session hit its ${tokens(limits.stopAt)}-token budget: tool calls are now blocked. Start a new session, or raise OC_HARNESS_BUDGET_STOP.`,
|
|
1328
|
+
"error"
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
957
1332
|
"tool.execute.before": async (input, output) => {
|
|
958
1333
|
try {
|
|
1334
|
+
const budget = input.sessionID ? budgets.get(input.sessionID) : void 0;
|
|
1335
|
+
if (budget?.stopped) {
|
|
1336
|
+
refuse(
|
|
1337
|
+
input.tool,
|
|
1338
|
+
"session token budget",
|
|
1339
|
+
"the session hit its token budget",
|
|
1340
|
+
`it has used ${tokens(budget.total)} context tokens, and the stop threshold is ${tokens(limits.stopAt)}. Start a new session, or raise OC_HARNESS_BUDGET_STOP.`
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
const spentReason = sharedWindowSpent();
|
|
1344
|
+
if (spentReason) {
|
|
1345
|
+
refuse(input.tool, "shared token window", "the shared token window is spent", spentReason);
|
|
1346
|
+
}
|
|
1347
|
+
const turnSpend = input.sessionID ? turnStops.get(input.sessionID) : void 0;
|
|
1348
|
+
if (turnSpend !== void 0) {
|
|
1349
|
+
refuse(
|
|
1350
|
+
input.tool,
|
|
1351
|
+
"single request budget",
|
|
1352
|
+
"a single request in this session re-sent too much context",
|
|
1353
|
+
`it re-sent ${tokens(turnSpend)} tokens against a turn stop of ${tokens(limits.turnStopAt)}. Stop: do not retry the same step, because every retry re-sends the same prompt. Start a new session with a smaller task, or raise OC_HARNESS_TURN_STOP.`
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
959
1356
|
if (input.tool === "bash") {
|
|
960
1357
|
const raw = output.args?.workdir;
|
|
961
1358
|
if (typeof raw === "string" && raw.trim()) {
|
|
962
|
-
const target = raw.startsWith("~") ?
|
|
1359
|
+
const target = raw.startsWith("~") ? join5(homedir3(), raw.slice(1)) : resolve2(project, raw);
|
|
963
1360
|
if (!insideRoot(target, project) && !insideRoot(target, tmpdir2())) {
|
|
964
1361
|
refuse(
|
|
965
1362
|
input.tool,
|
|
@@ -1021,16 +1418,16 @@ var GuardPlugin = async ({ worktree, directory }) => {
|
|
|
1021
1418
|
};
|
|
1022
1419
|
|
|
1023
1420
|
// src/profile.js
|
|
1024
|
-
import { readFileSync, existsSync as
|
|
1025
|
-
import { dirname as
|
|
1026
|
-
import { homedir as
|
|
1421
|
+
import { readFileSync as readFileSync2, existsSync as existsSync4, readdirSync } from "node:fs";
|
|
1422
|
+
import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
|
|
1423
|
+
import { homedir as homedir4 } from "node:os";
|
|
1027
1424
|
import { fileURLToPath } from "node:url";
|
|
1028
|
-
var HERE =
|
|
1425
|
+
var HERE = dirname4(fileURLToPath(import.meta.url));
|
|
1029
1426
|
function findRoot(start) {
|
|
1030
1427
|
let dir = start;
|
|
1031
1428
|
for (let i = 0; i < 12; i++) {
|
|
1032
|
-
if (
|
|
1033
|
-
const parent =
|
|
1429
|
+
if (existsSync4(join6(dir, "profiles"))) return dir;
|
|
1430
|
+
const parent = dirname4(dir);
|
|
1034
1431
|
if (parent === dir) break;
|
|
1035
1432
|
dir = parent;
|
|
1036
1433
|
}
|
|
@@ -1059,12 +1456,12 @@ function retentionSeverity(level) {
|
|
|
1059
1456
|
);
|
|
1060
1457
|
}
|
|
1061
1458
|
function profileDirs() {
|
|
1062
|
-
return [process.env.OPENCODE_HARNESS_PROFILES ??
|
|
1459
|
+
return [process.env.OPENCODE_HARNESS_PROFILES ?? join6(ROOT, "profiles")];
|
|
1063
1460
|
}
|
|
1064
1461
|
function profilePath(id) {
|
|
1065
1462
|
for (const dir of profileDirs()) {
|
|
1066
|
-
const full =
|
|
1067
|
-
if (
|
|
1463
|
+
const full = join6(dir, `${id}.json`);
|
|
1464
|
+
if (existsSync4(full)) return full;
|
|
1068
1465
|
}
|
|
1069
1466
|
return null;
|
|
1070
1467
|
}
|
|
@@ -1075,7 +1472,7 @@ function loadProfile(id) {
|
|
|
1075
1472
|
}
|
|
1076
1473
|
let raw;
|
|
1077
1474
|
try {
|
|
1078
|
-
raw = JSON.parse(
|
|
1475
|
+
raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
1079
1476
|
} catch (err) {
|
|
1080
1477
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1081
1478
|
throw new ProfileError(`cannot read ${path}: ${msg}`);
|
|
@@ -1114,11 +1511,11 @@ function normalize(p) {
|
|
|
1114
1511
|
var laneCache = null;
|
|
1115
1512
|
function loadLanes() {
|
|
1116
1513
|
if (laneCache) return laneCache;
|
|
1117
|
-
const file =
|
|
1118
|
-
if (!
|
|
1514
|
+
const file = join6(ROOT, "lanes.json");
|
|
1515
|
+
if (!existsSync4(file)) {
|
|
1119
1516
|
throw new ProfileError(`lane table missing: ${file}`);
|
|
1120
1517
|
}
|
|
1121
|
-
const parsed = JSON.parse(
|
|
1518
|
+
const parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
1122
1519
|
const lanes = parsed.lanes ?? {};
|
|
1123
1520
|
for (const [name, lane] of Object.entries(lanes)) {
|
|
1124
1521
|
if (!lane.description) {
|
|
@@ -1137,8 +1534,8 @@ function laneMeta(name) {
|
|
|
1137
1534
|
var retentionCache = null;
|
|
1138
1535
|
function loadRetention() {
|
|
1139
1536
|
if (retentionCache) return retentionCache;
|
|
1140
|
-
const file =
|
|
1141
|
-
if (!
|
|
1537
|
+
const file = join6(ROOT, "retention.yml");
|
|
1538
|
+
if (!existsSync4(file)) {
|
|
1142
1539
|
retentionCache = { models: {}, defaults: {} };
|
|
1143
1540
|
return retentionCache;
|
|
1144
1541
|
}
|
|
@@ -1150,7 +1547,7 @@ function loadRetention() {
|
|
|
1150
1547
|
/** @type {Record<string, string>} */
|
|
1151
1548
|
{}
|
|
1152
1549
|
);
|
|
1153
|
-
const lines =
|
|
1550
|
+
const lines = readFileSync2(file, "utf8").split("\n");
|
|
1154
1551
|
let current = null;
|
|
1155
1552
|
let inDefaults = false;
|
|
1156
1553
|
let i = 0;
|
|
@@ -1557,10 +1954,10 @@ function worstRetention(ids) {
|
|
|
1557
1954
|
return { retention, worst };
|
|
1558
1955
|
}
|
|
1559
1956
|
function configDir() {
|
|
1560
|
-
return process.env.OPENCODE_CONFIG_DIR ??
|
|
1957
|
+
return process.env.OPENCODE_CONFIG_DIR ?? join6(homedir4(), ".config", "opencode");
|
|
1561
1958
|
}
|
|
1562
1959
|
function configFilePath() {
|
|
1563
|
-
return
|
|
1960
|
+
return join6(configDir(), "opencode.jsonc");
|
|
1564
1961
|
}
|
|
1565
1962
|
function parseJsonc(text) {
|
|
1566
1963
|
let out = "";
|
|
@@ -1635,9 +2032,9 @@ function profileIdFromConfig() {
|
|
|
1635
2032
|
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
|
|
1636
2033
|
if (fromEnv) return fromEnv.trim() || null;
|
|
1637
2034
|
const file = configFilePath();
|
|
1638
|
-
if (!
|
|
2035
|
+
if (!existsSync4(file)) return null;
|
|
1639
2036
|
try {
|
|
1640
|
-
const parsed = parseJsonc(
|
|
2037
|
+
const parsed = parseJsonc(readFileSync2(file, "utf8"));
|
|
1641
2038
|
const harness = parsed?.harness;
|
|
1642
2039
|
const id = harness && typeof harness === "object" ? (
|
|
1643
2040
|
/** @type {{profile?: unknown}} */
|
|
@@ -1650,17 +2047,17 @@ function profileIdFromConfig() {
|
|
|
1650
2047
|
}
|
|
1651
2048
|
|
|
1652
2049
|
// src/prune.ts
|
|
1653
|
-
import { readFileSync as
|
|
1654
|
-
import { join as
|
|
2050
|
+
import { readFileSync as readFileSync3, existsSync as existsSync5 } from "node:fs";
|
|
2051
|
+
import { join as join7 } from "node:path";
|
|
1655
2052
|
function activeProfileId(worktree) {
|
|
1656
2053
|
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
|
|
1657
2054
|
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
|
|
1658
|
-
const candidates = [
|
|
2055
|
+
const candidates = [join7(worktree, ".space", "active")];
|
|
1659
2056
|
for (const f of candidates) {
|
|
1660
2057
|
if (!f) continue;
|
|
1661
2058
|
try {
|
|
1662
|
-
if (
|
|
1663
|
-
const v =
|
|
2059
|
+
if (existsSync5(f)) {
|
|
2060
|
+
const v = readFileSync3(f, "utf8").trim();
|
|
1664
2061
|
if (v) return v;
|
|
1665
2062
|
}
|
|
1666
2063
|
} catch {
|
|
@@ -1720,7 +2117,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1720
2117
|
};
|
|
1721
2118
|
let cached = null;
|
|
1722
2119
|
let reportedFailure = false;
|
|
1723
|
-
const
|
|
2120
|
+
const resolve5 = () => {
|
|
1724
2121
|
if (cached) return cached;
|
|
1725
2122
|
let profile = null;
|
|
1726
2123
|
try {
|
|
@@ -1766,7 +2163,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1766
2163
|
return {
|
|
1767
2164
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
1768
2165
|
try {
|
|
1769
|
-
const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, distinctBudget, threshold } =
|
|
2166
|
+
const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, distinctBudget, threshold } = resolve5();
|
|
1770
2167
|
if (!profile) return;
|
|
1771
2168
|
const messages = output.messages;
|
|
1772
2169
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
@@ -1891,13 +2288,14 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1891
2288
|
}
|
|
1892
2289
|
};
|
|
1893
2290
|
};
|
|
1894
|
-
async function logPrune(worktree, profileId, stats) {
|
|
1895
|
-
const dir =
|
|
2291
|
+
async function logPrune(worktree, profileId, stats, maxBytes = MAX_BYTES) {
|
|
2292
|
+
const dir = join7(worktree, ".space");
|
|
1896
2293
|
try {
|
|
1897
2294
|
const { mkdir, appendFile } = await import("node:fs/promises");
|
|
1898
2295
|
await mkdir(dir, { recursive: true });
|
|
2296
|
+
rotate(join7(dir, "prune.jsonl"), maxBytes);
|
|
1899
2297
|
await appendFile(
|
|
1900
|
-
|
|
2298
|
+
join7(dir, "prune.jsonl"),
|
|
1901
2299
|
JSON.stringify({
|
|
1902
2300
|
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1903
2301
|
profile: profileId,
|
|
@@ -1925,6 +2323,9 @@ function safeStringify(value) {
|
|
|
1925
2323
|
}
|
|
1926
2324
|
|
|
1927
2325
|
// src/config.js
|
|
2326
|
+
import { join as join8, resolve as resolve4 } from "node:path";
|
|
2327
|
+
import { homedir as homedir5 } from "node:os";
|
|
2328
|
+
var DEFAULT_CONFIG_DIR = join8(homedir5(), ".config/opencode");
|
|
1928
2329
|
function utilityModel(p) {
|
|
1929
2330
|
const u = p?.utility;
|
|
1930
2331
|
if (!u || typeof u.model !== "string" || !u.model) return null;
|
|
@@ -1932,8 +2333,8 @@ function utilityModel(p) {
|
|
|
1932
2333
|
}
|
|
1933
2334
|
|
|
1934
2335
|
// src/health.js
|
|
1935
|
-
import { existsSync as
|
|
1936
|
-
import { join as
|
|
2336
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync as renameSync3, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2337
|
+
import { join as join9 } from "node:path";
|
|
1937
2338
|
var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
1938
2339
|
var DEFAULT_WITHDRAWN_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1939
2340
|
function spentWindowMs() {
|
|
@@ -1975,12 +2376,12 @@ function liveUnhealthy(health, now = Date.now()) {
|
|
|
1975
2376
|
return out;
|
|
1976
2377
|
}
|
|
1977
2378
|
function healthPath(dir) {
|
|
1978
|
-
return
|
|
2379
|
+
return join9(dir, ".space", "health.json");
|
|
1979
2380
|
}
|
|
1980
2381
|
function readHealth(dir) {
|
|
1981
2382
|
let parsed;
|
|
1982
2383
|
try {
|
|
1983
|
-
parsed = JSON.parse(
|
|
2384
|
+
parsed = JSON.parse(readFileSync4(healthPath(dir), "utf8"));
|
|
1984
2385
|
} catch {
|
|
1985
2386
|
return { unhealthy: {} };
|
|
1986
2387
|
}
|
|
@@ -1995,14 +2396,14 @@ function readHealth(dir) {
|
|
|
1995
2396
|
}
|
|
1996
2397
|
function writeHealth(dir, health) {
|
|
1997
2398
|
try {
|
|
1998
|
-
const spaceDir =
|
|
1999
|
-
if (!
|
|
2399
|
+
const spaceDir = join9(dir, ".space");
|
|
2400
|
+
if (!existsSync6(spaceDir)) mkdirSync3(spaceDir, { recursive: true });
|
|
2000
2401
|
const pruned = { ...health ?? {}, unhealthy: liveUnhealthy(health) };
|
|
2001
2402
|
const file = healthPath(dir);
|
|
2002
2403
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
2003
2404
|
try {
|
|
2004
|
-
|
|
2005
|
-
|
|
2405
|
+
writeFileSync2(tmp, JSON.stringify(pruned, null, 2));
|
|
2406
|
+
renameSync3(tmp, file);
|
|
2006
2407
|
} catch (err) {
|
|
2007
2408
|
try {
|
|
2008
2409
|
unlinkSync(tmp);
|
|
@@ -2016,9 +2417,10 @@ function writeHealth(dir, health) {
|
|
|
2016
2417
|
function unhealthyRoutes(dir) {
|
|
2017
2418
|
return liveUnhealthy(readHealth(dir));
|
|
2018
2419
|
}
|
|
2019
|
-
function unhealthyModels(dir) {
|
|
2420
|
+
function unhealthyModels(dir, provider) {
|
|
2020
2421
|
const out = /* @__PURE__ */ new Set();
|
|
2021
2422
|
for (const key of Object.keys(unhealthyRoutes(dir))) {
|
|
2423
|
+
if (provider && key.includes("/") && !key.startsWith(`${provider}/`)) continue;
|
|
2022
2424
|
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
|
|
2023
2425
|
}
|
|
2024
2426
|
return out;
|
|
@@ -2262,9 +2664,9 @@ function applySessionModel(cfg, profile, opts = {}) {
|
|
|
2262
2664
|
}
|
|
2263
2665
|
|
|
2264
2666
|
// src/telemetry.ts
|
|
2265
|
-
import { join as
|
|
2667
|
+
import { join as join10 } from "node:path";
|
|
2266
2668
|
var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
2267
|
-
const dir =
|
|
2669
|
+
const dir = join10(projectRoot(directory, worktree), ".space");
|
|
2268
2670
|
const started = Date.now();
|
|
2269
2671
|
const opened = /* @__PURE__ */ new Set();
|
|
2270
2672
|
const closed = /* @__PURE__ */ new Set();
|
|
@@ -2335,9 +2737,9 @@ function laneOf(event, requests) {
|
|
|
2335
2737
|
const completed = typeof time.completed === "number" ? time.completed : void 0;
|
|
2336
2738
|
const ms = created !== void 0 && completed !== void 0 ? completed - created : void 0;
|
|
2337
2739
|
if (!id || !agent) return null;
|
|
2338
|
-
const
|
|
2339
|
-
const ctx = typeof
|
|
2340
|
-
const reasoning = typeof
|
|
2740
|
+
const tokens2 = info.tokens ?? {};
|
|
2741
|
+
const ctx = typeof tokens2.input === "number" ? tokens2.input + (typeof tokens2.cache?.read === "number" ? tokens2.cache.read : 0) : void 0;
|
|
2742
|
+
const reasoning = typeof tokens2.reasoning === "number" ? tokens2.reasoning : void 0;
|
|
2341
2743
|
return {
|
|
2342
2744
|
id,
|
|
2343
2745
|
agent,
|
|
@@ -2355,8 +2757,10 @@ function truncate2(s, n = 300) {
|
|
|
2355
2757
|
// src/effort.js
|
|
2356
2758
|
function planEffort(profileId, opts = {}) {
|
|
2357
2759
|
const p = loadProfile(profileId);
|
|
2760
|
+
const unhealthy = opts.unhealthy ?? (opts.dir ? unhealthyModels(opts.dir, p.provider) : void 0);
|
|
2761
|
+
const scoped = unhealthy === void 0 ? opts : { ...opts, unhealthy };
|
|
2358
2762
|
const levels = p.effort?.levels ?? [];
|
|
2359
|
-
const roles = resolveRoles(p,
|
|
2763
|
+
const roles = resolveRoles(p, scoped);
|
|
2360
2764
|
const notes = [];
|
|
2361
2765
|
if (levels.length === 0) {
|
|
2362
2766
|
notes.push(
|
|
@@ -2378,7 +2782,7 @@ function planEffort(profileId, opts = {}) {
|
|
|
2378
2782
|
}
|
|
2379
2783
|
const laneModels = {};
|
|
2380
2784
|
for (const role of Object.keys(p.roles ?? {})) {
|
|
2381
|
-
laneModels[role] = resolveLaneModel(p, role,
|
|
2785
|
+
laneModels[role] = resolveLaneModel(p, role, scoped).model;
|
|
2382
2786
|
}
|
|
2383
2787
|
return {
|
|
2384
2788
|
profileId,
|
|
@@ -2442,12 +2846,15 @@ var HarnessPlugin = async (ctx, options) => {
|
|
|
2442
2846
|
const project = projectRoot(ctx.directory, ctx.worktree);
|
|
2443
2847
|
try {
|
|
2444
2848
|
const sessionModel = typeof config?.harness?.sessionModel === "string" ? config.harness.sessionModel : void 0;
|
|
2445
|
-
applyEffort(config, planEffort(id, {
|
|
2849
|
+
applyEffort(config, planEffort(id, { dir: project }), {
|
|
2446
2850
|
sessionModel
|
|
2447
2851
|
});
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2852
|
+
} catch {
|
|
2853
|
+
}
|
|
2854
|
+
try {
|
|
2855
|
+
const dir = join11(project, ".space");
|
|
2856
|
+
mkdirSync4(dir, { recursive: true });
|
|
2857
|
+
writeFileSync3(join11(dir, "active"), `${id}
|
|
2451
2858
|
`);
|
|
2452
2859
|
} catch {
|
|
2453
2860
|
}
|