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/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
3
- import { join as join9 } from "node:path";
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 homedir2, tmpdir as tmpdir2 } from "node:os";
7
- import { join as join4, resolve as resolve2 } from "node:path";
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 tokens = [];
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) tokens.push(cur);
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 tokens;
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(tokens, i, name) {
407
+ function skipWrapperArgs(tokens2, i, name) {
408
408
  const valueFlags = WRAPPER_TABLE[name] ?? WRAPPER_VALUE_FLAGS;
409
- while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
410
- const flag = tokens[i];
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 < tokens.length) i++;
412
+ if (valueFlags.has(flag) && !flag.includes("=") && i < tokens2.length) i++;
413
413
  }
414
- if (name === "timeout" && i < tokens.length && DURATION.test(tokens[i])) i++;
415
- if (name === "chroot" && i < tokens.length && !tokens[i].startsWith("-")) 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(tokens) {
418
+ function skipPrefixes(tokens2) {
419
419
  let i = 0;
420
420
  for (; ; ) {
421
- const word = tokens[i];
422
- if (word === void 0) return tokens.length;
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(tokens, i + 1, name);
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 tokens = tokenize(segment);
437
- const i = skipPrefixes(tokens);
438
- if (i >= tokens.length) return "";
439
- const rest = tokens.slice(i);
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 tokens = tokenize(command);
445
- if (!tokens.length) return command;
446
- const flags = TOOL_VALUE_FLAGS[baseName(tokens[0]).toLowerCase()];
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 < tokens.length; i++) {
450
- const token = tokens[i];
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 < tokens.length) i++;
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 tokens = tokenize(segment);
461
- let i = skipPrefixes(tokens);
462
- if (i >= tokens.length) return null;
463
- if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null;
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 < tokens.length) {
466
- const flag = tokens[i];
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 tokens[i] ?? null;
481
+ return tokens2[i] ?? null;
482
482
  }
483
483
  var MAX_SHELL_DEPTH = 2;
484
- function shellCommandString(tokens, from) {
485
- for (let n = from + 1; n < tokens.length; n++) {
486
- const token = tokens[n];
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 tokens.slice(n + 1).join(" ") || null;
492
+ return tokens2.slice(n + 1).join(" ") || null;
493
493
  }
494
494
  return null;
495
495
  }
496
- function envSplitPayload(tokens) {
497
- for (let i = 0; i < tokens.length; i++) {
498
- const word = tokens[i];
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 < tokens.length; j++) {
504
- const token = tokens[j];
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 tokens[j + 1] ?? null;
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(tokens, i + 1, name) - 1;
520
+ i = skipWrapperArgs(tokens2, i + 1, name) - 1;
521
521
  }
522
522
  return null;
523
523
  }
524
524
  function shellPayload(segment) {
525
- const tokens = tokenize(segment);
526
- if (tokens.length < 2) return null;
527
- const split2 = envSplitPayload(tokens);
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(tokens);
530
- if (i >= tokens.length) return null;
531
- if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
532
- if (!SHELL_NAMES.test(baseName(tokens[i]))) return null;
533
- return shellCommandString(tokens, i);
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 tokens = tokenize(command);
543
- let i = skipPrefixes(tokens);
544
- if (i >= tokens.length) continue;
545
- let name = commandName(tokens[i]);
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 >= tokens.length) continue;
549
- name = commandName(tokens[i]);
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 tokens = tokenize(text);
572
- if (tokens.length < 2) return false;
573
- if (baseName(tokens[0]).toLowerCase() !== "rm") return false;
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 < tokens.length; i++) {
577
- const token = tokens[i];
576
+ for (let i = 1; i < tokens2.length; i++) {
577
+ const token = tokens2[i];
578
578
  if (token === "--") {
579
- hasPath = tokens.length > i + 1;
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 tokens = tokenize(text);
593
- if (tokens.length < 2) return null;
594
- if (baseName(tokens[0]).toLowerCase() !== "chmod") return null;
595
- for (let i = 1; i < tokens.length; i++) {
596
- const token = tokens[i];
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 tokens = tokenize(segment);
616
- const i = skipPrefixes(tokens);
617
- if (i >= tokens.length) return null;
618
- const name = baseName(tokens[i]).toLowerCase();
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 < tokens.length; j++) {
621
- const token = tokens[j];
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 = tokens[j + 1];
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 tokens = tokenize(segment);
643
- const i = skipPrefixes(tokens);
644
- if (i >= tokens.length) return false;
645
- if (sameFile(tokens[i], file)) return true;
646
- const name = baseName(tokens[i]);
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 < tokens.length; j++) {
650
- if (sameFile(tokens[j], file)) return true;
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 join2 } from "node:path";
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 join2(dir, "telemetry.jsonl");
1066
+ return join3(dir, "telemetry.jsonl");
838
1067
  }
839
1068
  function rotate(file, maxBytes = MAX_BYTES) {
840
1069
  try {
841
- if (existsSync(file) && statSync(file).size > maxBytes) {
1070
+ if (existsSync2(file) && statSync(file).size > maxBytes) {
842
1071
  const oldest = `${file}.2`;
843
- if (existsSync(oldest)) rmSync(oldest, { force: true });
844
- if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest);
845
- renameSync(file, `${file}.1`);
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 (!existsSync(dir)) mkdirSync(dir, { recursive: true });
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 existsSync2 } from "node:fs";
862
- import { dirname as dirname2, join as join3, resolve } from "node:path";
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 (existsSync2(join3(dir, ".git"))) return dir;
868
- const parent = dirname2(dir);
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 = join4(project, ".space");
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("~") ? join4(homedir2(), raw.slice(1)) : resolve2(project, raw);
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 existsSync3, readdirSync } from "node:fs";
1025
- import { dirname as dirname3, join as join5, resolve as resolve3 } from "node:path";
1026
- import { homedir as homedir3 } from "node:os";
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 = dirname3(fileURLToPath(import.meta.url));
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 (existsSync3(join5(dir, "profiles"))) return dir;
1033
- const parent = dirname3(dir);
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 ?? join5(ROOT, "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 = join5(dir, `${id}.json`);
1067
- if (existsSync3(full)) return full;
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(readFileSync(path, "utf8"));
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 = join5(ROOT, "lanes.json");
1118
- if (!existsSync3(file)) {
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(readFileSync(file, "utf8"));
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 = join5(ROOT, "retention.yml");
1141
- if (!existsSync3(file)) {
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 = readFileSync(file, "utf8").split("\n");
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 ?? join5(homedir3(), ".config", "opencode");
1957
+ return process.env.OPENCODE_CONFIG_DIR ?? join6(homedir4(), ".config", "opencode");
1561
1958
  }
1562
1959
  function configFilePath() {
1563
- return join5(configDir(), "opencode.jsonc");
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 (!existsSync3(file)) return null;
2035
+ if (!existsSync4(file)) return null;
1639
2036
  try {
1640
- const parsed = parseJsonc(readFileSync(file, "utf8"));
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 readFileSync2, existsSync as existsSync4 } from "node:fs";
1654
- import { join as join6 } from "node:path";
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 = [join6(worktree, ".space", "active")];
2055
+ const candidates = [join7(worktree, ".space", "active")];
1659
2056
  for (const f of candidates) {
1660
2057
  if (!f) continue;
1661
2058
  try {
1662
- if (existsSync4(f)) {
1663
- const v = readFileSync2(f, "utf8").trim();
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 resolve4 = () => {
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 } = resolve4();
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 = join6(worktree, ".space");
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
- join6(dir, "prune.jsonl"),
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 existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, unlinkSync, writeFileSync } from "node:fs";
1936
- import { join as join7 } from "node:path";
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 join7(dir, ".space", "health.json");
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(readFileSync3(healthPath(dir), "utf8"));
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 = join7(dir, ".space");
1999
- if (!existsSync5(spaceDir)) mkdirSync2(spaceDir, { recursive: true });
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
- writeFileSync(tmp, JSON.stringify(pruned, null, 2));
2005
- renameSync2(tmp, file);
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 join8 } from "node:path";
2667
+ import { join as join10 } from "node:path";
2266
2668
  var TelemetryPlugin = async ({ worktree, directory }) => {
2267
- const dir = join8(projectRoot(directory, worktree), ".space");
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 tokens = info.tokens ?? {};
2339
- const ctx = typeof tokens.input === "number" ? tokens.input + (typeof tokens.cache?.read === "number" ? tokens.cache.read : 0) : void 0;
2340
- const reasoning = typeof tokens.reasoning === "number" ? tokens.reasoning : void 0;
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, opts);
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, opts).model;
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, { unhealthy: unhealthyModels(project) }), {
2849
+ applyEffort(config, planEffort(id, { dir: project }), {
2446
2850
  sessionModel
2447
2851
  });
2448
- const dir = join9(project, ".space");
2449
- mkdirSync3(dir, { recursive: true });
2450
- writeFileSync2(join9(dir, "active"), `${id}
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
  }