opencode-harness-kit 0.2.0 → 0.3.3

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/GLOBAL.md +50 -13
  3. package/MIGRATION.md +140 -0
  4. package/README.md +276 -122
  5. package/bin/oc-harness.mjs +571 -135
  6. package/dist/index.js +453 -159
  7. package/docs/KNOWN-LIMITS.md +189 -4
  8. package/docs/RESEARCH.md +80 -5
  9. package/docs/ROUTING.md +254 -0
  10. package/lanes.json +136 -0
  11. package/lanes.schema.json +55 -0
  12. package/package.json +13 -11
  13. package/profiles/beam-free.json +51 -0
  14. package/profiles/big-pickle.json +45 -0
  15. package/profiles/deepseek-v4.1-flash-free.json +49 -0
  16. package/profiles/fleet-all.json +139 -0
  17. package/profiles/fleet-safe.json +139 -0
  18. package/profiles/glm-5.3-flash-free.json +49 -0
  19. package/profiles/index.json +120 -37
  20. package/profiles/longcat-2.5-preview-free.json +14 -10
  21. package/profiles/mimo-v2.6-flash-free.json +44 -0
  22. package/profiles/minimax-m3.1-flash-free.json +51 -0
  23. package/profiles/nemotron-3-ultra-free.json +44 -0
  24. package/profiles/nemotron-3.5-lightning-free.json +44 -0
  25. package/profiles/nemotron-3.5-super-vl-free.json +52 -0
  26. package/profiles/schema.json +30 -11
  27. package/{agents → prompts}/build.md +2 -35
  28. package/prompts/doc.md +42 -0
  29. package/{agents → prompts}/locate.md +1 -16
  30. package/prompts/longform.md +34 -0
  31. package/{agents → prompts}/plan.md +2 -25
  32. package/{agents → prompts}/reviewer.md +2 -22
  33. package/prompts/schema.md +30 -0
  34. package/prompts/sweep.md +29 -0
  35. package/{agents → prompts}/verify.md +6 -29
  36. package/prompts/vision.md +33 -0
  37. package/retention.yml +75 -57
  38. package/skills/space-build/SKILL.md +16 -0
  39. package/skills/space-finish/SKILL.md +0 -10
  40. package/skills/space-review/SKILL.md +8 -0
  41. package/src/config.js +94 -69
  42. package/src/driver/run.mjs +237 -0
  43. package/src/effort.js +59 -22
  44. package/src/index.ts +15 -2
  45. package/src/lane-usage.js +142 -0
  46. package/src/profile.js +615 -221
  47. package/src/router.ts +148 -39
  48. package/src/tasks.js +248 -0
  49. package/src/telemetry.ts +98 -0
  50. package/docs/demo/demo.gif +0 -0
  51. package/docs/demo/demo.mp4 +0 -0
  52. package/docs/demo/render.py +0 -146
  53. package/examples/muse-spark-1.3-contributor-free.json +0 -41
  54. package/profiles/deepseek-v4-flash-free.json +0 -41
  55. package/profiles/muse-split-free.json +0 -64
  56. package/profiles/space-bunny-free.json +0 -42
  57. package/profiles/x-preview-f-free.json +0 -41
package/dist/index.js CHANGED
@@ -332,11 +332,34 @@ import { readFileSync, existsSync, readdirSync } from "node:fs";
332
332
  import { dirname, join, resolve } from "node:path";
333
333
  import { fileURLToPath } from "node:url";
334
334
  var HERE = dirname(fileURLToPath(import.meta.url));
335
- var ROOT = resolve(HERE, "..");
335
+ function findRoot(start) {
336
+ let dir = start;
337
+ for (let i = 0; i < 6; i++) {
338
+ if (existsSync(join(dir, "profiles"))) return dir;
339
+ const parent = dirname(dir);
340
+ if (parent === dir) break;
341
+ dir = parent;
342
+ }
343
+ return resolve(start, "..");
344
+ }
345
+ var ROOT = findRoot(resolve(HERE, ".."));
336
346
  var ProfileError = class extends Error {
337
347
  };
348
+ var LANE_NAMES = [
349
+ "build",
350
+ "plan",
351
+ "reviewer",
352
+ "locate",
353
+ "verify",
354
+ "vision",
355
+ "doc",
356
+ "schema",
357
+ "sweep",
358
+ "longform"
359
+ ];
360
+ var RETENTION_SEVERITY = ["zero-retention", "retained", "unknown", "may-train"];
338
361
  function profileDirs() {
339
- return [join(ROOT, "profiles"), join(ROOT, "examples")];
362
+ return [join(ROOT, "profiles")];
340
363
  }
341
364
  function profilePath(id) {
342
365
  for (const dir of profileDirs()) {
@@ -369,6 +392,7 @@ function normalize(p) {
369
392
  default: p.effort?.default ?? null,
370
393
  reserveShare: p.effort?.reserveShare ?? 0.12
371
394
  },
395
+ maxRetention: p.maxRetention ?? p.retention,
372
396
  prune: {
373
397
  enabled: p.prune?.enabled ?? false,
374
398
  keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
@@ -378,115 +402,28 @@ function normalize(p) {
378
402
  fallback: p.fallback ?? []
379
403
  };
380
404
  }
381
- var VALID_RETENTION = ["zero-retention", "may-train", "unknown"];
382
- function validate(p, path = "<memory>") {
383
- const where = p?.id ?? path;
384
- if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
385
- if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
386
- if (!p.model) throw new ProfileError(`${where}: missing "model"`);
387
- if (!VALID_RETENTION.includes(p.retention)) {
388
- throw new ProfileError(
389
- `${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
390
- );
391
- }
392
- if (!p.limits?.context || p.limits.context <= 0) {
393
- throw new ProfileError(`${where}: limits.context must be a positive integer`);
405
+ var laneCache = null;
406
+ function loadLanes() {
407
+ if (laneCache) return laneCache;
408
+ const file = join(ROOT, "lanes.json");
409
+ if (!existsSync(file)) {
410
+ throw new ProfileError(`lane table missing: ${file}`);
394
411
  }
395
- if (!p.limits?.output || p.limits.output <= 0) {
396
- throw new ProfileError(`${where}: limits.output must be a positive integer`);
397
- }
398
- const share = p.effort?.reserveShare;
399
- if (share !== void 0 && (share < 0 || share > 0.5)) {
400
- throw new ProfileError(
401
- `${where}: effort.reserveShare must be between 0 and 0.5`
402
- );
403
- }
404
- for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
405
- if (!SPLITTABLE_ROLES.includes(role)) {
406
- throw new ProfileError(
407
- `${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`
408
- );
409
- }
410
- if (!(role in (p.roles ?? {}))) {
411
- throw new ProfileError(
412
- `${where}: roleModels.${role} has no matching entry in roles, so it would never apply`
413
- );
414
- }
415
- if (!rm?.model || typeof rm.model !== "string") {
416
- throw new ProfileError(`${where}: roleModels.${role} needs a model`);
412
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
413
+ const lanes = parsed.lanes ?? {};
414
+ for (const [name, lane] of Object.entries(lanes)) {
415
+ if (!lane.description) {
416
+ throw new ProfileError(`lanes.json: lane "${name}" has no description`);
417
417
  }
418
- if (rm.levels !== void 0 && !Array.isArray(rm.levels)) {
419
- throw new ProfileError(
420
- `${where}: roleModels.${role}.levels must be an array`
421
- );
418
+ if (!lane.prompt) {
419
+ throw new ProfileError(`lanes.json: lane "${name}" has no prompt file`);
422
420
  }
423
- if (!retentionFor(rm.model)) {
424
- throw new ProfileError(
425
- `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
426
- );
427
- }
428
- }
429
- }
430
- var CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
431
- function resolveEffort(levels, requested) {
432
- if (!requested) return null;
433
- if (!levels || levels.length === 0) return null;
434
- if (levels.includes(requested)) return requested;
435
- const want = CANON.indexOf(requested);
436
- if (want < 0) {
437
- return levels[levels.length - 1] ?? null;
438
- }
439
- let best = null;
440
- let bestDist = Infinity;
441
- for (const level of levels) {
442
- const have = CANON.indexOf(level);
443
- if (have < 0) continue;
444
- const d = Math.abs(have - want);
445
- if (d < bestDist) {
446
- bestDist = d;
447
- best = level;
448
- }
449
- }
450
- return best ?? levels[levels.length - 1] ?? null;
451
- }
452
- function resolveRoleModel(p, role) {
453
- const rm = p.roleModels?.[role];
454
- if (!rm) return null;
455
- return { model: rm.model, levels: rm.levels ?? [] };
456
- }
457
- function levelsForRole(p, role) {
458
- const rm = resolveRoleModel(p, role);
459
- if (rm) return rm.levels;
460
- return p.effort?.levels ?? [];
461
- }
462
- function resolveRoles(p) {
463
- const out = (
464
- /** @type {Record<string, string | null>} */
465
- {}
466
- );
467
- for (const [role, want] of Object.entries(p.roles ?? {})) {
468
- out[role] = resolveEffort(levelsForRole(p, role), want);
469
421
  }
470
- return out;
422
+ laneCache = lanes;
423
+ return lanes;
471
424
  }
472
- function resolveDefaultVariant(p) {
473
- return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null);
474
- }
475
- var SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"];
476
- function inputCap(p) {
477
- let cap = p.limits.input ?? p.limits.context;
478
- for (const s of roleSplitModels(p)) {
479
- const other = s.limits?.input ?? s.limits?.context;
480
- if (typeof other === "number" && other > cap) cap = other;
481
- }
482
- return cap;
483
- }
484
- function reservedTokens(p) {
485
- const share = p.effort?.reserveShare ?? 0.12;
486
- return Math.max(8e3, Math.round(inputCap(p) * share));
487
- }
488
- function pruneThreshold(p) {
489
- return inputCap(p) - reservedTokens(p);
425
+ function laneMeta(name) {
426
+ return loadLanes()[name] ?? null;
490
427
  }
491
428
  function loadRetention() {
492
429
  const file = join(ROOT, "retention.yml");
@@ -549,20 +486,277 @@ function retentionFor(id) {
549
486
  const table = loadRetention();
550
487
  return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`];
551
488
  }
552
- function roleSplitModels(p) {
553
- return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
489
+ function modelRetention(model) {
490
+ const declared = retentionFor(model)?.retention ?? "unknown";
491
+ return (
492
+ /** @type {Retention} */
493
+ ["zero-retention", "retained", "may-train", "unknown"].includes(declared) ? declared : "unknown"
494
+ );
495
+ }
496
+ function retentionAllowed(model, ceiling) {
497
+ const level = modelRetention(model);
498
+ if (level === "unknown") return false;
499
+ const max = RETENTION_SEVERITY.indexOf(ceiling);
500
+ const have = RETENTION_SEVERITY.indexOf(level);
501
+ if (max < 0) return false;
502
+ return have <= max;
503
+ }
504
+ function resolveLaneChain(p, role) {
505
+ const rm = p.roleModels?.[role];
506
+ if (!rm) return [p.model];
507
+ const chain = rm.chain?.length ? rm.chain : [rm.model];
508
+ return chain;
509
+ }
510
+ function resolveLaneModel(p, role, opts = {}) {
511
+ const unhealthy = opts.unhealthy ?? /* @__PURE__ */ new Set();
512
+ const ceiling = (
513
+ /** @type {Retention} */
514
+ p.maxRetention ?? p.retention
515
+ );
516
+ for (const candidate of resolveLaneChain(p, role)) {
517
+ if (unhealthy.has(candidate)) continue;
518
+ if (!retentionAllowed(candidate, ceiling)) continue;
519
+ return { model: candidate, levels: levelsOfModel(candidate) };
520
+ }
521
+ const fallback = resolveLaneChain(p, role)[0] ?? p.model;
522
+ return { model: fallback, levels: levelsOfModel(fallback) };
523
+ }
524
+ function levelsOfModel(model) {
525
+ try {
526
+ return loadProfile(model).effort?.levels ?? [];
527
+ } catch {
528
+ return [];
529
+ }
530
+ }
531
+ function levelsForRole(p, role, opts) {
532
+ const rm = p.roleModels?.[role];
533
+ if (rm) return resolveLaneModel(p, role, opts).levels;
534
+ return p.effort?.levels ?? [];
535
+ }
536
+ function resolveRoles(p, opts = {}) {
537
+ const out = (
538
+ /** @type {Record<string, string | null>} */
539
+ {}
540
+ );
541
+ for (const [role, want] of Object.entries(p.roles ?? {})) {
542
+ out[role] = resolveEffort(levelsForRole(p, role, opts), want);
543
+ }
544
+ return out;
545
+ }
546
+ function resolveDefaultVariant(p) {
547
+ return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null);
548
+ }
549
+ var CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
550
+ function resolveEffort(levels, requested) {
551
+ if (!requested) return null;
552
+ if (!levels || levels.length === 0) return null;
553
+ if (levels.includes(requested)) return requested;
554
+ const want = CANON.indexOf(requested);
555
+ if (want < 0) {
556
+ return levels[levels.length - 1] ?? null;
557
+ }
558
+ let best = null;
559
+ let bestDist = Infinity;
560
+ for (const level of levels) {
561
+ const have = CANON.indexOf(level);
562
+ if (have < 0) continue;
563
+ const d = Math.abs(have - want);
564
+ if (d < bestDist) {
565
+ bestDist = d;
566
+ best = level;
567
+ }
568
+ }
569
+ return best ?? levels[levels.length - 1] ?? null;
570
+ }
571
+ var VALID_RETENTION = ["zero-retention", "retained", "may-train", "unknown"];
572
+ function validate(p, path = "<memory>") {
573
+ const where = p?.id ?? path;
574
+ if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
575
+ if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
576
+ if (!p.model) throw new ProfileError(`${where}: missing "model"`);
577
+ if (!VALID_RETENTION.includes(p.retention)) {
578
+ throw new ProfileError(
579
+ `${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
580
+ );
581
+ }
582
+ if (p.maxRetention && !VALID_RETENTION.includes(p.maxRetention)) {
583
+ throw new ProfileError(
584
+ `${where}: maxRetention must be one of ${VALID_RETENTION.join(", ")}`
585
+ );
586
+ }
587
+ if (p.maxRetention === "unknown") {
588
+ throw new ProfileError(`${where}: maxRetention cannot be unknown`);
589
+ }
590
+ if (!p.limits?.context || p.limits.context <= 0) {
591
+ throw new ProfileError(`${where}: limits.context must be a positive integer`);
592
+ }
593
+ if (!p.limits?.output || p.limits.output <= 0) {
594
+ throw new ProfileError(`${where}: limits.output must be a positive integer`);
595
+ }
596
+ const share = p.effort?.reserveShare;
597
+ if (share !== void 0 && (share < 0 || share > 0.5)) {
598
+ throw new ProfileError(
599
+ `${where}: effort.reserveShare must be between 0 and 0.5`
600
+ );
601
+ }
602
+ for (const role of Object.keys(p.roles ?? {})) {
603
+ if (!LANE_NAMES.includes(role)) {
604
+ throw new ProfileError(
605
+ `${where}: roles.${role} is not one of ${LANE_NAMES.join(", ")}`
606
+ );
607
+ }
608
+ if (!laneMeta(role)) {
609
+ throw new ProfileError(
610
+ `${where}: roles.${role} has no lane definition in lanes.json`
611
+ );
612
+ }
613
+ }
614
+ for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
615
+ if (!LANE_NAMES.includes(role)) {
616
+ throw new ProfileError(
617
+ `${where}: roleModels.${role} is not one of ${LANE_NAMES.join(", ")}`
618
+ );
619
+ }
620
+ if (!(role in (p.roles ?? {}))) {
621
+ throw new ProfileError(
622
+ `${where}: roleModels.${role} has no matching entry in roles, so it would never apply`
623
+ );
624
+ }
625
+ if (!rm?.model || typeof rm.model !== "string") {
626
+ throw new ProfileError(`${where}: roleModels.${role} needs a model`);
627
+ }
628
+ if (rm.levels !== void 0 && !Array.isArray(rm.levels)) {
629
+ throw new ProfileError(
630
+ `${where}: roleModels.${role}.levels must be an array`
631
+ );
632
+ }
633
+ if (rm.chain !== void 0) {
634
+ if (!Array.isArray(rm.chain) || rm.chain.length === 0) {
635
+ throw new ProfileError(
636
+ `${where}: roleModels.${role}.chain must be a non-empty array`
637
+ );
638
+ }
639
+ if (rm.chain[0] !== rm.model) {
640
+ throw new ProfileError(
641
+ `${where}: roleModels.${role}.chain must start with its model (${rm.chain[0]} vs ${rm.model})`
642
+ );
643
+ }
644
+ for (const rung of rm.chain) {
645
+ if (!retentionFor(rung)) {
646
+ throw new ProfileError(
647
+ `${where}: roleModels.${role}.chain names ${rung}, which has no retention.yml entry`
648
+ );
649
+ }
650
+ }
651
+ }
652
+ if (!retentionFor(rm.model)) {
653
+ throw new ProfileError(
654
+ `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
655
+ );
656
+ }
657
+ }
658
+ }
659
+ function inputCap(p, opts = {}) {
660
+ let cap = p.limits.input ?? p.limits.context;
661
+ for (const s of roleSplitModels(p, opts)) {
662
+ const other = s.limits?.input ?? s.limits?.context;
663
+ if (typeof other === "number" && other < cap) cap = other;
664
+ }
665
+ return cap;
666
+ }
667
+ function reservedTokens(p, opts = {}) {
668
+ const share = p.effort?.reserveShare ?? 0.12;
669
+ return Math.max(8e3, Math.round(inputCap(p, opts) * share));
670
+ }
671
+ function pruneThreshold(p, opts = {}) {
672
+ return inputCap(p, opts) - reservedTokens(p, opts);
673
+ }
674
+ function roleSplitModels(p, opts = {}) {
675
+ return Object.entries(p.roleModels ?? {}).map(([role]) => {
676
+ const resolved = resolveLaneModel(p, role, opts);
554
677
  let limits = null;
555
678
  try {
556
- limits = loadProfile(rm.model)?.limits ?? null;
679
+ limits = loadProfile(resolved.model)?.limits ?? null;
557
680
  } catch {
558
681
  limits = null;
559
682
  }
560
- return { role, model: rm.model, levels: rm.levels ?? [], limits };
683
+ return { role, model: resolved.model, levels: resolved.levels, limits };
561
684
  });
562
685
  }
686
+ function configDir() {
687
+ return process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
688
+ }
563
689
  function configFilePath() {
564
- const dir = process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
565
- return join(dir, "opencode.jsonc");
690
+ return join(configDir(), "opencode.jsonc");
691
+ }
692
+ function parseJsonc(text) {
693
+ let out = "";
694
+ let i = 0;
695
+ let inString = false;
696
+ let escaped = false;
697
+ let pendingComma = false;
698
+ const n = text.length;
699
+ while (i < n) {
700
+ const ch = text[i];
701
+ if (inString) {
702
+ out += ch;
703
+ if (escaped) escaped = false;
704
+ else if (ch === "\\") escaped = true;
705
+ else if (ch === '"') inString = false;
706
+ i++;
707
+ continue;
708
+ }
709
+ if (ch === '"') {
710
+ if (pendingComma) {
711
+ out += ",";
712
+ pendingComma = false;
713
+ }
714
+ out += ch;
715
+ inString = true;
716
+ i++;
717
+ continue;
718
+ }
719
+ if (ch === "/" && text[i + 1] === "/") {
720
+ while (i < n && text[i] !== "\n") i++;
721
+ continue;
722
+ }
723
+ if (ch === "/" && text[i + 1] === "*") {
724
+ i += 2;
725
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
726
+ i += 2;
727
+ continue;
728
+ }
729
+ if (ch === ",") {
730
+ if (pendingComma) return null;
731
+ pendingComma = true;
732
+ i++;
733
+ continue;
734
+ }
735
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
736
+ out += ch;
737
+ i++;
738
+ continue;
739
+ }
740
+ if (ch === "}" || ch === "]") {
741
+ pendingComma = false;
742
+ out += ch;
743
+ i++;
744
+ continue;
745
+ }
746
+ if (pendingComma) {
747
+ out += ",";
748
+ pendingComma = false;
749
+ }
750
+ out += ch;
751
+ i++;
752
+ }
753
+ try {
754
+ const parsed = JSON.parse(out);
755
+ return parsed && typeof parsed === "object" ? parsed : null;
756
+ } catch {
757
+ if (!out.trim()) return {};
758
+ return null;
759
+ }
566
760
  }
567
761
  function profileIdFromConfig() {
568
762
  const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
@@ -570,10 +764,12 @@ function profileIdFromConfig() {
570
764
  const file = configFilePath();
571
765
  if (!existsSync(file)) return null;
572
766
  try {
573
- const text = readFileSync(file, "utf8");
574
- const json = text.slice(text.indexOf("{"));
575
- const parsed = JSON.parse(json);
576
- const id = parsed?.harness?.profile;
767
+ const parsed = parseJsonc(readFileSync(file, "utf8"));
768
+ const harness = parsed?.harness;
769
+ const id = harness && typeof harness === "object" ? (
770
+ /** @type {{profile?: unknown}} */
771
+ harness.profile
772
+ ) : null;
577
773
  return typeof id === "string" && id ? id : null;
578
774
  } catch {
579
775
  return null;
@@ -776,6 +972,14 @@ function writeHealth(worktree, h) {
776
972
  } catch {
777
973
  }
778
974
  }
975
+ function unhealthyModels(worktree) {
976
+ const health = readHealth(worktree);
977
+ const out = /* @__PURE__ */ new Set();
978
+ for (const key of Object.keys(health.unhealthy ?? {})) {
979
+ out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
980
+ }
981
+ return out;
982
+ }
779
983
  var SPENT = [
780
984
  /FreeUsageLimitError/i,
781
985
  /rate[_ -]?limit/i,
@@ -789,7 +993,7 @@ function classify(text) {
789
993
  for (const p of SPENT) if (p.test(text)) return p.source;
790
994
  return null;
791
995
  }
792
- var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the fallback queue, or `oc-harness doctor` to check whether the profile still matches live metadata.";
996
+ var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` to check whether the profile still matches live metadata.";
793
997
  var RouterPlugin = async ({ worktree }) => {
794
998
  let failedSessions = 0;
795
999
  let warned = false;
@@ -799,27 +1003,57 @@ var RouterPlugin = async ({ worktree }) => {
799
1003
  try {
800
1004
  const cfg = config;
801
1005
  const health = readHealth(worktree);
1006
+ const unhealthy = unhealthyModels(worktree);
802
1007
  const activeId = cfg.harness?.profile ?? profileIdFromConfig();
803
- if (activeId) {
804
- const id = activeId;
805
- const mark = health.unhealthy[`opencode/${id}`];
806
- if (mark) {
807
- let chosen = id;
1008
+ if (!activeId) return;
1009
+ const id = activeId;
1010
+ let profile;
1011
+ try {
1012
+ profile = loadProfile(id);
1013
+ } catch {
1014
+ return;
1015
+ }
1016
+ const baseKey = `opencode/${profile.model}`;
1017
+ if (health.unhealthy?.[baseKey]) {
1018
+ const queue = [id, ...profile.fallback ?? []];
1019
+ for (const candidate of queue) {
1020
+ if (health.unhealthy?.[`opencode/${candidate}`]) continue;
1021
+ let fallback;
808
1022
  try {
809
- const queue = [id, ...loadProfile(id).fallback ?? []];
810
- for (const candidate of queue) {
811
- if (!health.unhealthy[`opencode/${candidate}`]) {
812
- chosen = candidate;
813
- break;
814
- }
815
- }
1023
+ fallback = loadProfile(candidate);
816
1024
  } catch {
1025
+ continue;
817
1026
  }
818
- if (chosen !== id) {
819
- applyProfileToConfig(cfg, chosen);
820
- health.active = chosen;
821
- writeHealth(worktree, health);
822
- }
1027
+ const key = `opencode/${fallback.model}`;
1028
+ if (health.unhealthy?.[key]) continue;
1029
+ const fleet = isFleet(profile);
1030
+ applySessionModel(cfg, fallback, {
1031
+ keepLanes: fleet,
1032
+ // The lanes did not change, so the numbers derived from them did
1033
+ // not change either.
1034
+ profileId: fleet ? profile.id : fallback.id,
1035
+ reserved: fleet ? reservedTokens(profile) : void 0,
1036
+ threshold: fleet ? pruneThreshold(profile) : void 0
1037
+ });
1038
+ health.active = candidate;
1039
+ writeHealth(worktree, health);
1040
+ break;
1041
+ }
1042
+ }
1043
+ const roles = resolveRoles(profile, { unhealthy });
1044
+ for (const [lane, level] of Object.entries(roles)) {
1045
+ if (level === void 0) continue;
1046
+ const agent = cfg.agent?.[lane];
1047
+ if (!agent) continue;
1048
+ const resolved = resolveLaneModel(profile, lane, { unhealthy });
1049
+ if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue;
1050
+ const wantModel = `opencode/${resolved.model}`;
1051
+ if (agent.model !== wantModel) {
1052
+ agent.model = wantModel;
1053
+ const requested = profile.roles?.[lane] ?? null;
1054
+ const next = resolveEffort(resolved.levels, requested);
1055
+ if (next === null) delete agent.variant;
1056
+ else agent.variant = next;
823
1057
  }
824
1058
  }
825
1059
  } catch {
@@ -857,28 +1091,37 @@ ${WARN_TEXT}`;
857
1091
  }
858
1092
  };
859
1093
  };
860
- function applyProfileToConfig(cfg, profileId) {
1094
+ function isFleet(profile) {
1095
+ return Object.keys(profile?.roleModels ?? {}).length > 0;
1096
+ }
1097
+ function applySessionModel(cfg, profile, opts = {}) {
861
1098
  try {
862
- const p = loadProfile(profileId);
863
- const model = `${p.provider}/${p.model}`;
1099
+ const model = `${profile.provider}/${profile.model}`;
864
1100
  cfg.model = model;
865
1101
  cfg.small_model = model;
866
- const variant = p.effort?.default;
867
- if (p.effort?.levels?.length && variant) cfg.variant = variant;
1102
+ const variant = profile.effort?.default;
1103
+ if (profile.effort?.levels?.length && variant) cfg.variant = variant;
868
1104
  else delete cfg.variant;
869
- for (const [role, want] of Object.entries(p.roles ?? {})) {
870
- const agent = cfg.agent ?? (cfg.agent = {});
871
- const existing = agent[role] ?? (agent[role] = {});
872
- existing.model = model;
873
- const level = resolveEffort(p.effort?.levels ?? [], want);
874
- if (level) existing.variant = level;
875
- else delete existing.variant;
1105
+ if (!opts.keepLanes) {
1106
+ for (const [lane, want] of Object.entries(profile.roles ?? {})) {
1107
+ const agent = cfg.agent ?? (cfg.agent = {});
1108
+ const existing = agent[lane] ?? (agent[lane] = {});
1109
+ existing.model = model;
1110
+ const level = resolveEffort(profile.effort?.levels ?? [], want);
1111
+ if (level) existing.variant = level;
1112
+ else delete existing.variant;
1113
+ }
876
1114
  }
877
1115
  if (cfg.compaction) {
878
1116
  cfg.compaction.prune = true;
879
- cfg.compaction.reserved = reservedTokens(p);
1117
+ cfg.compaction.reserved = opts.reserved ?? reservedTokens(profile);
880
1118
  }
881
- cfg.harness = { ...cfg.harness ?? {}, profile: profileId, threshold: pruneThreshold(p) };
1119
+ cfg.harness = {
1120
+ ...cfg.harness ?? {},
1121
+ profile: opts.profileId ?? profile.id,
1122
+ threshold: opts.threshold ?? pruneThreshold(profile),
1123
+ sessionModel: model
1124
+ };
882
1125
  } catch {
883
1126
  }
884
1127
  }
@@ -910,12 +1153,28 @@ function write(dir, record) {
910
1153
  var TelemetryPlugin = async ({ worktree }) => {
911
1154
  const dir = join4(worktree, ".space");
912
1155
  const started = Date.now();
1156
+ const opened = /* @__PURE__ */ new Set();
1157
+ const closed = /* @__PURE__ */ new Set();
913
1158
  return {
914
1159
  event: async ({ event }) => {
915
1160
  const type = event?.type;
916
1161
  if (typeof type !== "string") return;
917
1162
  if (/error|rate|limit|quota/i.test(type)) {
918
1163
  write(dir, { kind: "provider-event", type, detail: truncate2(safeStringify(event)) });
1164
+ return;
1165
+ }
1166
+ if (type === "message.updated") {
1167
+ const lane = laneOf(event);
1168
+ if (!lane || !lane.id) return;
1169
+ if (lane.ms !== void 0) {
1170
+ if (closed.has(lane.id)) return;
1171
+ closed.add(lane.id);
1172
+ write(dir, { kind: "lane", ...lane });
1173
+ return;
1174
+ }
1175
+ if (opened.has(lane.id)) return;
1176
+ opened.add(lane.id);
1177
+ write(dir, { kind: "lane", ...lane });
919
1178
  }
920
1179
  },
921
1180
  "tool.execute.after": async (input, output) => {
@@ -934,6 +1193,28 @@ var TelemetryPlugin = async ({ worktree }) => {
934
1193
  }
935
1194
  };
936
1195
  };
1196
+ function laneOf(event) {
1197
+ const e = event;
1198
+ const info = e?.properties?.info ?? e?.properties;
1199
+ if (!info || typeof info !== "object") return null;
1200
+ if (info.role && info.role !== "assistant") return null;
1201
+ const id = typeof info.id === "string" ? info.id : void 0;
1202
+ const agent = typeof info.agent === "string" ? info.agent : void 0;
1203
+ const modelId = info.model?.modelID ?? info.modelID;
1204
+ const providerId = info.model?.providerID ?? info.providerID;
1205
+ const time = info.time ?? {};
1206
+ const created = typeof time.created === "number" ? time.created : void 0;
1207
+ const completed = typeof time.completed === "number" ? time.completed : void 0;
1208
+ const ms = created !== void 0 && completed !== void 0 ? completed - created : void 0;
1209
+ if (!id || !agent) return null;
1210
+ return {
1211
+ id,
1212
+ agent,
1213
+ model: typeof modelId === "string" ? modelId : void 0,
1214
+ provider: typeof providerId === "string" ? providerId : void 0,
1215
+ ms: typeof ms === "number" && ms >= 0 ? ms : void 0
1216
+ };
1217
+ }
937
1218
  function extractRule(output) {
938
1219
  const m = output.match(/blocked:\s*([^\n]+)/);
939
1220
  return m ? m[1].trim() : "unknown";
@@ -950,28 +1231,32 @@ function safeStringify(value) {
950
1231
  }
951
1232
 
952
1233
  // src/effort.js
953
- var MANAGED_ROLES = ["plan", "build", "reviewer", "locate", "verify"];
954
- function planEffort(profileId) {
1234
+ function planEffort(profileId, opts = {}) {
955
1235
  const p = loadProfile(profileId);
956
1236
  const levels = p.effort?.levels ?? [];
957
- const roles = resolveRoles(p);
1237
+ const roles = resolveRoles(p, opts);
958
1238
  const notes = [];
959
1239
  if (levels.length === 0) {
960
1240
  notes.push(
961
- `${p.model} publishes no tunable reasoning effort; variant will be omitted for every role.`
1241
+ `${p.model} publishes no tunable reasoning effort; variant will be omitted for every lane.`
962
1242
  );
963
1243
  }
964
1244
  for (const [role, want] of Object.entries(p.roles ?? {})) {
965
1245
  const got = roles[role];
966
1246
  if (want && got && want !== got) {
967
- notes.push(`role ${role} asked for ${want}; ${p.model} tops out at ${got}.`);
1247
+ notes.push(`lane ${role} asked for ${want}; its model tops out at ${got}.`);
968
1248
  }
969
1249
  }
1250
+ const laneModels = {};
1251
+ for (const role of Object.keys(p.roles ?? {})) {
1252
+ laneModels[role] = resolveLaneModel(p, role, opts).model;
1253
+ }
970
1254
  return {
971
1255
  profileId,
972
1256
  model: `${p.provider}/${p.model}`,
973
1257
  defaultVariant: resolveDefaultVariant(p),
974
1258
  roles,
1259
+ laneModels,
975
1260
  notes
976
1261
  };
977
1262
  }
@@ -980,18 +1265,24 @@ function applyEffort(config, result, opts = {}) {
980
1265
  /** @type {Record<string, any>} */
981
1266
  config
982
1267
  );
983
- cfg.model = result.model;
1268
+ const sessionModel = typeof opts.sessionModel === "string" && opts.sessionModel ? opts.sessionModel : result.model;
1269
+ cfg.model = sessionModel;
984
1270
  cfg.agent = cfg.agent ?? {};
985
- for (const role of MANAGED_ROLES) {
986
- const level = result.roles[role];
1271
+ for (const [role, level] of Object.entries(result.roles)) {
987
1272
  if (level === void 0) continue;
988
1273
  const existing = cfg.agent[role] ?? {};
989
- if (!existing.model) existing.model = result.model;
1274
+ if (!existing.model) {
1275
+ existing.model = result.laneModels?.[role] ? `${result.model.split("/")[0]}/${result.laneModels[role]}` : result.model;
1276
+ }
990
1277
  if (level === null) delete existing.variant;
991
1278
  else existing.variant = level;
992
1279
  cfg.agent[role] = existing;
993
1280
  }
994
- cfg.harness = opts.fallbackProfileId ? { profile: result.profileId, fallback: opts.fallbackProfileId } : { profile: result.profileId };
1281
+ cfg.harness = {
1282
+ ...cfg.harness ?? {},
1283
+ profile: result.profileId,
1284
+ ...opts.fallbackProfileId ? { fallback: opts.fallbackProfileId } : {}
1285
+ };
995
1286
  return config;
996
1287
  }
997
1288
 
@@ -1021,7 +1312,10 @@ var HarnessPlugin = async (ctx, options) => {
1021
1312
  const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig();
1022
1313
  if (!id) return;
1023
1314
  try {
1024
- applyEffort(config, planEffort(id));
1315
+ const sessionModel = typeof config?.harness?.sessionModel === "string" ? config.harness.sessionModel : void 0;
1316
+ applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(ctx.worktree) }), {
1317
+ sessionModel
1318
+ });
1025
1319
  const dir = join5(ctx.worktree, ".space");
1026
1320
  mkdirSync3(dir, { recursive: true });
1027
1321
  writeFileSync2(join5(dir, "active"), `${id}