opencode-harness-kit 0.2.1 → 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 (51) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/GLOBAL.md +50 -13
  3. package/MIGRATION.md +140 -0
  4. package/README.md +233 -116
  5. package/bin/oc-harness.mjs +474 -96
  6. package/dist/index.js +378 -155
  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 +12 -8
  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-review/SKILL.md +8 -0
  40. package/src/config.js +94 -69
  41. package/src/effort.js +59 -22
  42. package/src/index.ts +15 -2
  43. package/src/lane-usage.js +142 -0
  44. package/src/profile.js +502 -212
  45. package/src/router.ts +148 -39
  46. package/src/telemetry.ts +98 -0
  47. package/examples/muse-spark-1.3-contributor-free.json +0 -41
  48. package/profiles/deepseek-v4-flash-free.json +0 -41
  49. package/profiles/muse-split-free.json +0 -64
  50. package/profiles/space-bunny-free.json +0 -42
  51. 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
- );
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`);
409
417
  }
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`);
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
- );
422
- }
423
- if (!retentionFor(rm.model)) {
424
- throw new ProfileError(
425
- `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
426
- );
418
+ if (!lane.prompt) {
419
+ throw new ProfileError(`lanes.json: lane "${name}" has no prompt file`);
427
420
  }
428
421
  }
422
+ laneCache = lanes;
423
+ return lanes;
429
424
  }
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
- }
470
- return out;
471
- }
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,208 @@ 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");
566
691
  }
567
692
  function parseJsonc(text) {
568
693
  let out = "";
@@ -847,6 +972,14 @@ function writeHealth(worktree, h) {
847
972
  } catch {
848
973
  }
849
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
+ }
850
983
  var SPENT = [
851
984
  /FreeUsageLimitError/i,
852
985
  /rate[_ -]?limit/i,
@@ -860,7 +993,7 @@ function classify(text) {
860
993
  for (const p of SPENT) if (p.test(text)) return p.source;
861
994
  return null;
862
995
  }
863
- 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.";
864
997
  var RouterPlugin = async ({ worktree }) => {
865
998
  let failedSessions = 0;
866
999
  let warned = false;
@@ -870,27 +1003,57 @@ var RouterPlugin = async ({ worktree }) => {
870
1003
  try {
871
1004
  const cfg = config;
872
1005
  const health = readHealth(worktree);
1006
+ const unhealthy = unhealthyModels(worktree);
873
1007
  const activeId = cfg.harness?.profile ?? profileIdFromConfig();
874
- if (activeId) {
875
- const id = activeId;
876
- const mark = health.unhealthy[`opencode/${id}`];
877
- if (mark) {
878
- 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;
879
1022
  try {
880
- const queue = [id, ...loadProfile(id).fallback ?? []];
881
- for (const candidate of queue) {
882
- if (!health.unhealthy[`opencode/${candidate}`]) {
883
- chosen = candidate;
884
- break;
885
- }
886
- }
1023
+ fallback = loadProfile(candidate);
887
1024
  } catch {
1025
+ continue;
888
1026
  }
889
- if (chosen !== id) {
890
- applyProfileToConfig(cfg, chosen);
891
- health.active = chosen;
892
- writeHealth(worktree, health);
893
- }
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;
894
1057
  }
895
1058
  }
896
1059
  } catch {
@@ -928,28 +1091,37 @@ ${WARN_TEXT}`;
928
1091
  }
929
1092
  };
930
1093
  };
931
- function applyProfileToConfig(cfg, profileId) {
1094
+ function isFleet(profile) {
1095
+ return Object.keys(profile?.roleModels ?? {}).length > 0;
1096
+ }
1097
+ function applySessionModel(cfg, profile, opts = {}) {
932
1098
  try {
933
- const p = loadProfile(profileId);
934
- const model = `${p.provider}/${p.model}`;
1099
+ const model = `${profile.provider}/${profile.model}`;
935
1100
  cfg.model = model;
936
1101
  cfg.small_model = model;
937
- const variant = p.effort?.default;
938
- 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;
939
1104
  else delete cfg.variant;
940
- for (const [role, want] of Object.entries(p.roles ?? {})) {
941
- const agent = cfg.agent ?? (cfg.agent = {});
942
- const existing = agent[role] ?? (agent[role] = {});
943
- existing.model = model;
944
- const level = resolveEffort(p.effort?.levels ?? [], want);
945
- if (level) existing.variant = level;
946
- 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
+ }
947
1114
  }
948
1115
  if (cfg.compaction) {
949
1116
  cfg.compaction.prune = true;
950
- cfg.compaction.reserved = reservedTokens(p);
1117
+ cfg.compaction.reserved = opts.reserved ?? reservedTokens(profile);
951
1118
  }
952
- 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
+ };
953
1125
  } catch {
954
1126
  }
955
1127
  }
@@ -981,12 +1153,28 @@ function write(dir, record) {
981
1153
  var TelemetryPlugin = async ({ worktree }) => {
982
1154
  const dir = join4(worktree, ".space");
983
1155
  const started = Date.now();
1156
+ const opened = /* @__PURE__ */ new Set();
1157
+ const closed = /* @__PURE__ */ new Set();
984
1158
  return {
985
1159
  event: async ({ event }) => {
986
1160
  const type = event?.type;
987
1161
  if (typeof type !== "string") return;
988
1162
  if (/error|rate|limit|quota/i.test(type)) {
989
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 });
990
1178
  }
991
1179
  },
992
1180
  "tool.execute.after": async (input, output) => {
@@ -1005,6 +1193,28 @@ var TelemetryPlugin = async ({ worktree }) => {
1005
1193
  }
1006
1194
  };
1007
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
+ }
1008
1218
  function extractRule(output) {
1009
1219
  const m = output.match(/blocked:\s*([^\n]+)/);
1010
1220
  return m ? m[1].trim() : "unknown";
@@ -1021,28 +1231,32 @@ function safeStringify(value) {
1021
1231
  }
1022
1232
 
1023
1233
  // src/effort.js
1024
- var MANAGED_ROLES = ["plan", "build", "reviewer", "locate", "verify"];
1025
- function planEffort(profileId) {
1234
+ function planEffort(profileId, opts = {}) {
1026
1235
  const p = loadProfile(profileId);
1027
1236
  const levels = p.effort?.levels ?? [];
1028
- const roles = resolveRoles(p);
1237
+ const roles = resolveRoles(p, opts);
1029
1238
  const notes = [];
1030
1239
  if (levels.length === 0) {
1031
1240
  notes.push(
1032
- `${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.`
1033
1242
  );
1034
1243
  }
1035
1244
  for (const [role, want] of Object.entries(p.roles ?? {})) {
1036
1245
  const got = roles[role];
1037
1246
  if (want && got && want !== got) {
1038
- 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}.`);
1039
1248
  }
1040
1249
  }
1250
+ const laneModels = {};
1251
+ for (const role of Object.keys(p.roles ?? {})) {
1252
+ laneModels[role] = resolveLaneModel(p, role, opts).model;
1253
+ }
1041
1254
  return {
1042
1255
  profileId,
1043
1256
  model: `${p.provider}/${p.model}`,
1044
1257
  defaultVariant: resolveDefaultVariant(p),
1045
1258
  roles,
1259
+ laneModels,
1046
1260
  notes
1047
1261
  };
1048
1262
  }
@@ -1051,18 +1265,24 @@ function applyEffort(config, result, opts = {}) {
1051
1265
  /** @type {Record<string, any>} */
1052
1266
  config
1053
1267
  );
1054
- cfg.model = result.model;
1268
+ const sessionModel = typeof opts.sessionModel === "string" && opts.sessionModel ? opts.sessionModel : result.model;
1269
+ cfg.model = sessionModel;
1055
1270
  cfg.agent = cfg.agent ?? {};
1056
- for (const role of MANAGED_ROLES) {
1057
- const level = result.roles[role];
1271
+ for (const [role, level] of Object.entries(result.roles)) {
1058
1272
  if (level === void 0) continue;
1059
1273
  const existing = cfg.agent[role] ?? {};
1060
- 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
+ }
1061
1277
  if (level === null) delete existing.variant;
1062
1278
  else existing.variant = level;
1063
1279
  cfg.agent[role] = existing;
1064
1280
  }
1065
- 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
+ };
1066
1286
  return config;
1067
1287
  }
1068
1288
 
@@ -1092,7 +1312,10 @@ var HarnessPlugin = async (ctx, options) => {
1092
1312
  const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig();
1093
1313
  if (!id) return;
1094
1314
  try {
1095
- 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
+ });
1096
1319
  const dir = join5(ctx.worktree, ".space");
1097
1320
  mkdirSync3(dir, { recursive: true });
1098
1321
  writeFileSync2(join5(dir, "active"), `${id}