agent-bios 0.2.0 → 0.4.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.
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import pathlib
12
12
  import shutil
13
+ import subprocess
13
14
  import sys
14
15
  import textwrap
15
16
  import tomllib
@@ -18,6 +19,9 @@ from typing import Any, NoReturn
18
19
 
19
20
 
20
21
  TIER_ORDER = ("frontier", "helm", "workhorse", "sweep")
22
+ # Tiers projected as spawnable subagents. HELM is the main role, never a
23
+ # spawnable worker — registering it invited helm-as-subagent misuse.
24
+ SPAWNABLE_TIERS = ("frontier", "workhorse", "sweep")
21
25
  EFFORT_ORDER = ("low", "medium", "high", "xhigh", "max", "ultra")
22
26
  HOST_EFFORTS = {
23
27
  "codex": {"low", "medium", "high", "xhigh", "max", "ultra"},
@@ -40,6 +44,17 @@ EFFORT_DESCRIPTIONS = {
40
44
  }
41
45
  CUSTOM_PRESET = "__custom__"
42
46
  OTHER_MODEL = "__other_model__"
47
+ # Root-menu grouping for presets: "builder" (tunable, includes Custom), "general"
48
+ # (plain CLI presets like Vanilla), "distill" (opens the Session Distill hub).
49
+ # A preset with a missing/unknown mode defaults to "builder" so presets written
50
+ # before this field existed (including saved/local user presets) keep working.
51
+ GENERAL_MODE = "general"
52
+ DEFAULT_PRESET_MODE = "builder"
53
+ # The mode value that opens the Session Distill hub instead of a preset
54
+ # submenu; doubles as the option value on the root Mode menu so selecting it
55
+ # needs no translation.
56
+ DISTILL_MODE = "distill"
57
+ PRESET_MODES = (GENERAL_MODE, DEFAULT_PRESET_MODE, DISTILL_MODE)
43
58
  # User-saved presets live beside the deployed config, in a file the installer
44
59
  # neither deploys nor verifies, so they survive `agent-bios install`.
45
60
  USER_PRESETS_NAME = "presets.local.toml"
@@ -58,8 +73,9 @@ class MenuOption:
58
73
  description: str
59
74
  enabled: bool = True
60
75
  unavailable_reason: str = ""
61
- CODEX_POLICIES = {"bypass", "workspace-write", "read-only"}
62
- CLAUDE_POLICIES = {"acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan"}
76
+ STANDARD_POLICY = "standard"
77
+ CODEX_POLICIES = {"bypass", "workspace-write", "read-only", STANDARD_POLICY}
78
+ CLAUDE_POLICIES = {"acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan", STANDARD_POLICY}
63
79
  REVIEW_SETUPS = {
64
80
  "none": {
65
81
  "label": "None",
@@ -569,7 +585,7 @@ def _build_app_class():
569
585
 
570
586
  app_css = """
571
587
  Screen { background: $surface; }
572
- #al-title { color: $accent; text-style: bold; padding: 0 1; }
588
+ #al-title { background: $primary; color: black; text-style: bold; padding: 0 1; }
573
589
  #al-setup {
574
590
  border: round $primary; border-title-color: $primary;
575
591
  border-title-style: bold; padding: 0 1; height: auto;
@@ -578,6 +594,10 @@ def _build_app_class():
578
594
  border: round $secondary; border-title-color: $secondary;
579
595
  border-title-style: bold; padding: 0 1; height: 5;
580
596
  }
597
+ #al-corpus-title { background: $warning; color: black; text-style: bold; padding: 0 1; }
598
+ #al-corpus {
599
+ border: round $warning; padding: 0 1; height: auto;
600
+ }
581
601
  #al-hdr { color: $text-muted; text-style: bold; padding: 0 1; }
582
602
  OptionList { height: 1fr; border: none; padding: 0 1; }
583
603
  #al-footer { color: $text-muted; dock: bottom; padding: 0 1; background: $panel; }
@@ -596,7 +616,9 @@ def _build_app_class():
596
616
  Binding("ctrl+c", "cancel", "cancel", priority=True),
597
617
  ]
598
618
 
599
- def __init__(self, title, options, default, allow_back, plan, preview=None):
619
+ def __init__(
620
+ self, title, options, default, allow_back, plan, preview=None, corpus=None
621
+ ):
600
622
  super().__init__()
601
623
  self._title = title
602
624
  self._options = options
@@ -604,10 +626,14 @@ def _build_app_class():
604
626
  self._allow_back = allow_back
605
627
  self._plan = plan
606
628
  self._preview = preview
629
+ self._corpus = corpus
607
630
 
608
631
  def compose(self):
609
632
  yield Static(self._title, id="al-title")
610
633
  yield setup_panel(self._plan)
634
+ if self._corpus:
635
+ yield Static("Corpus status", id="al-corpus-title")
636
+ yield Static("\n".join(self._corpus), id="al-corpus")
611
637
  detail = Static("", id="al-detail")
612
638
  detail.border_title = "About highlighted option"
613
639
  yield detail
@@ -775,10 +801,13 @@ class TextualUI:
775
801
  default: str,
776
802
  allow_back: bool,
777
803
  preview=None,
804
+ corpus_lines: list[str] | None = None,
778
805
  ) -> str:
779
806
  result = self.app.call_from_thread(
780
807
  self.app.push_screen_wait,
781
- self._menu_screen(title, options, default, allow_back, self.plan, preview),
808
+ self._menu_screen(
809
+ title, options, default, allow_back, self.plan, preview, corpus_lines
810
+ ),
782
811
  )
783
812
  if result == _UI_BACK:
784
813
  raise BackRequested
@@ -814,13 +843,23 @@ def run_textual_flow(
814
843
 
815
844
 
816
845
  def choose_lines(
817
- title: str, options: list[MenuOption], default: str, allow_back: bool
846
+ title: str,
847
+ options: list[MenuOption],
848
+ default: str,
849
+ allow_back: bool,
850
+ corpus_lines: list[str] | None = None,
818
851
  ) -> str:
819
852
  print(f"\n{title}")
853
+ if corpus_lines:
854
+ print(" -- Corpus status --")
855
+ for line in corpus_lines:
856
+ print(f" {line}")
857
+ print(" --")
820
858
  for index, option in enumerate(options, 1):
821
859
  marker = "" if option.enabled else " [unavailable]"
822
860
  selected = " *" if option.value == default and option.enabled else ""
823
- print(f" {index}. {option.label}{marker}{selected} - {option.description}")
861
+ label = option.label
862
+ print(f" {index}. {label}{marker}{selected} - {option.description}")
824
863
  if not option.enabled:
825
864
  print(f" Unavailable: {option.unavailable_reason}")
826
865
  while True:
@@ -854,12 +893,13 @@ def choose(
854
893
  ui: TextualUI | None = None,
855
894
  allow_back: bool = False,
856
895
  preview=None,
896
+ corpus_lines: list[str] | None = None,
857
897
  ) -> str:
858
898
  if not any(option.enabled for option in options):
859
899
  raise LaunchError(f"no available options for {title}")
860
900
  if ui is not None:
861
- return ui.choose(title, options, default, allow_back, preview)
862
- return choose_lines(title, options, default, allow_back)
901
+ return ui.choose(title, options, default, allow_back, preview, corpus_lines)
902
+ return choose_lines(title, options, default, allow_back, corpus_lines)
863
903
 
864
904
 
865
905
  def read_input(prompt: str) -> str:
@@ -950,6 +990,17 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
950
990
  label = preset.get("label", preset_name)
951
991
  if not isinstance(label, str) or not label:
952
992
  raise LaunchError(f"invalid label in preset {preset_name}")
993
+ mode = preset.get("mode")
994
+ if not isinstance(mode, str) or mode not in PRESET_MODES:
995
+ # Missing/unknown mode (older or user/local presets) defaults to builder
996
+ # rather than failing closed, so existing presets keep working unchanged.
997
+ mode = DEFAULT_PRESET_MODE
998
+ mission = preset.get("mission")
999
+ if mission is not None and (not isinstance(mission, str) or not mission):
1000
+ raise LaunchError(f"presets.{preset_name}.mission must be a non-empty string")
1001
+ trigger = preset.get("trigger")
1002
+ if trigger is not None and (not isinstance(trigger, str) or not trigger):
1003
+ raise LaunchError(f"presets.{preset_name}.trigger must be a non-empty string")
953
1004
  review_family = preset.get("review_family", "cross")
954
1005
  review_host = REVIEW_HOST[host]
955
1006
  opposite = config.get("hosts", {}).get(review_host)
@@ -973,6 +1024,7 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
973
1024
  "preset": preset_name,
974
1025
  "label": label,
975
1026
  "description": preset.get("description", f"Launch the {label} preset."),
1027
+ "mode": mode,
976
1028
  "main_tier": main_tier,
977
1029
  "frontier_effort": frontier_effort,
978
1030
  "review_setup": review_setup,
@@ -988,6 +1040,8 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
988
1040
  "review_backend": review_backend,
989
1041
  "agent_templates": copy.deepcopy(config["hosts"][host].get("agent_templates")),
990
1042
  "capabilities": copy.deepcopy(config.get("capabilities", {})),
1043
+ "mission": mission,
1044
+ "trigger": trigger,
991
1045
  }
992
1046
 
993
1047
 
@@ -1173,6 +1227,11 @@ def customize(
1173
1227
  "Read-only sandbox",
1174
1228
  "Allow inspection while blocking file writes through the Codex sandbox.",
1175
1229
  ),
1230
+ MenuOption(
1231
+ STANDARD_POLICY,
1232
+ "Standard",
1233
+ "Pass no policy flag; use Codex's own default approval and sandbox behavior.",
1234
+ ),
1176
1235
  ]
1177
1236
  policy_field = "codex_execution_policy"
1178
1237
  policy_title = "Codex execution policy"
@@ -1208,6 +1267,11 @@ def customize(
1208
1267
  "Plan mode",
1209
1268
  "Start Claude in planning mode without direct implementation.",
1210
1269
  ),
1270
+ MenuOption(
1271
+ STANDARD_POLICY,
1272
+ "Standard",
1273
+ "Pass no policy flag; use Claude's own default permission prompts.",
1274
+ ),
1211
1275
  ]
1212
1276
  policy_field = "claude_permission_mode"
1213
1277
  policy_title = "Claude permission mode (not an OS sandbox)"
@@ -1386,6 +1450,336 @@ def customize(
1386
1450
  raise KeyboardInterrupt
1387
1451
 
1388
1452
 
1453
+ DISTILL_PRESET = "session-distill"
1454
+ CORPUS_STATUS_PATH = pathlib.Path(
1455
+ os.environ.get(
1456
+ "AGENT_BIOS_CORPUS_STATUS",
1457
+ str(pathlib.Path.home() / ".local/share/agent-bios/corpus-status.json"),
1458
+ )
1459
+ )
1460
+
1461
+
1462
+ def load_corpus_status() -> dict[str, Any] | None:
1463
+ try:
1464
+ return json.loads(CORPUS_STATUS_PATH.read_text())
1465
+ except (OSError, ValueError):
1466
+ return None
1467
+
1468
+
1469
+ def corpus_summary_lines(status: dict[str, Any] | None) -> list[str]:
1470
+ """Panel body for the Session Distill area: which corpus content is
1471
+ live, through which mechanisms, and whether the corpus is rolled back."""
1472
+ if status is None:
1473
+ return ["corpus status not projected yet — run: agent-bios install"]
1474
+ current = status.get("current_version", "?")
1475
+ latest = status.get("latest_version", "?")
1476
+ head = f"Applied version {current}"
1477
+ if status.get("rolled_back_to"):
1478
+ head += f" (ROLLED BACK; latest is {latest})"
1479
+ layers = status.get("summary", {}).get("placed_by_layer", {})
1480
+ order = ("global", "guide", "hook", "enforcement", "gate")
1481
+ layer_text = " · ".join(
1482
+ f"{name} {layers[name]}" for name in order if layers.get(name)
1483
+ ) or "none"
1484
+ by_status = status.get("summary", {}).get("by_status", {})
1485
+ return [
1486
+ head,
1487
+ f"Mechanisms {layer_text}",
1488
+ f"Ledger placed {by_status.get('placed', 0)} · "
1489
+ f"incubating {by_status.get('incubating', 0) + by_status.get('incubating-G', 0)} · "
1490
+ f"versions {len(status.get('versions', []))}",
1491
+ ]
1492
+
1493
+
1494
+ def _corpus_info(ui: TextualUI | None, title: str, lines: list[str]) -> None:
1495
+ """Info screen in both UIs: options carry the content; only exit is back."""
1496
+ options = [MenuOption("back", "Back", "Return to Session Distill.")]
1497
+ try:
1498
+ choose(title, options, "back", ui, allow_back=True, corpus_lines=lines)
1499
+ except BackRequested:
1500
+ pass
1501
+
1502
+
1503
+ def _corpus_rollback(status: dict[str, Any], version: str, ui: TextualUI | None) -> None:
1504
+ confirm = choose(
1505
+ f"Roll back corpus to {version}?",
1506
+ [
1507
+ MenuOption(
1508
+ "cancel", "Cancel", "Keep the currently deployed corpus content."
1509
+ ),
1510
+ MenuOption(
1511
+ "rollback",
1512
+ f"Roll back to {version}",
1513
+ "Re-deploys globals/guides/hooks as of that corpus version. "
1514
+ "System deployment (launcher, wrappers) stays current. Roll "
1515
+ "forward again by selecting the latest version.",
1516
+ ),
1517
+ ],
1518
+ "cancel",
1519
+ ui,
1520
+ allow_back=True,
1521
+ )
1522
+ if confirm != "rollback":
1523
+ return
1524
+ script = pathlib.Path(status["repo"]) / "scripts/session-distill/corpus-state.py"
1525
+ result = subprocess.run(
1526
+ [sys.executable, str(script), "rollback", "--version", version],
1527
+ capture_output=True,
1528
+ text=True,
1529
+ )
1530
+ output = (result.stdout + result.stderr).strip().splitlines()
1531
+ tail = output[-1] if output else ""
1532
+ verdict = "Rollback complete." if result.returncode == 0 else "Rollback FAILED."
1533
+ _corpus_info(ui, verdict, [tail] if tail else [])
1534
+
1535
+
1536
+ def _corpus_versions(ui: TextualUI | None) -> None:
1537
+ while True:
1538
+ status = load_corpus_status()
1539
+ if status is None:
1540
+ _corpus_info(ui, "Versions & rollback", corpus_summary_lines(None))
1541
+ return
1542
+ current = status.get("current_version")
1543
+ options = []
1544
+ for v in reversed(status.get("versions", [])):
1545
+ name = v["version"]
1546
+ label = f"{name} (current)" if name == current else name
1547
+ options.append(
1548
+ MenuOption(
1549
+ name,
1550
+ label,
1551
+ f"closed {v.get('closed', '?')} · commit {v.get('commit', '')[:12]} · "
1552
+ f"{v.get('summary', '')}",
1553
+ )
1554
+ )
1555
+ options.append(MenuOption("back", "Back", "Return to Session Distill."))
1556
+ try:
1557
+ selected = choose(
1558
+ "Versions & rollback",
1559
+ options,
1560
+ options[0].value,
1561
+ ui,
1562
+ allow_back=True,
1563
+ corpus_lines=corpus_summary_lines(status),
1564
+ )
1565
+ except BackRequested:
1566
+ return
1567
+ if selected == "back":
1568
+ return
1569
+ if selected == current:
1570
+ _corpus_info(
1571
+ ui,
1572
+ f"{selected} is the currently applied version",
1573
+ ["Select a different version to roll back or forward."],
1574
+ )
1575
+ continue
1576
+ _corpus_rollback(status, selected, ui)
1577
+
1578
+
1579
+ def _corpus_packages(ui: TextualUI | None) -> None:
1580
+ """v1: the corpus ships as a single core package; the list shape is ready
1581
+ for the domain-packaging backlog to populate with real packages."""
1582
+ status = load_corpus_status()
1583
+ if status is None:
1584
+ _corpus_info(ui, "Corpus packages", corpus_summary_lines(None))
1585
+ return
1586
+ layers = status.get("summary", {}).get("placed_by_layer", {})
1587
+ options = [
1588
+ MenuOption(
1589
+ "core",
1590
+ f"core corpus @ {status.get('current_version', '?')}",
1591
+ "All corpus domains in one package until domain packaging "
1592
+ f"lands (backlog). Layers: {json.dumps(layers, separators=(', ', ' '))}. "
1593
+ "Per-domain selection, versions, and rollback will appear here.",
1594
+ ),
1595
+ MenuOption("back", "Back", "Return to Session Distill."),
1596
+ ]
1597
+ try:
1598
+ choose(
1599
+ "Corpus packages",
1600
+ options,
1601
+ "back",
1602
+ ui,
1603
+ allow_back=True,
1604
+ corpus_lines=corpus_summary_lines(status),
1605
+ )
1606
+ except BackRequested:
1607
+ pass
1608
+
1609
+
1610
+ def distill_hub(config: dict[str, Any], ui: TextualUI | None) -> str:
1611
+ """Session Distill area: status, packages, versions/rollback, session start.
1612
+
1613
+ Returns "start" to launch the session-distill preset, "back" otherwise.
1614
+ """
1615
+ while True:
1616
+ status = load_corpus_status()
1617
+ options = [
1618
+ MenuOption(
1619
+ "start",
1620
+ "Start a session distill run",
1621
+ "Launch with the Session distill preset: the mission line "
1622
+ "directs the session to the workflow guide and the SSOT.",
1623
+ enabled=DISTILL_PRESET in config["presets"],
1624
+ unavailable_reason=f"preset {DISTILL_PRESET!r} not configured",
1625
+ ),
1626
+ MenuOption(
1627
+ "packages",
1628
+ "Corpus packages",
1629
+ "What corpus content is applied, as installable packages "
1630
+ "(v1: single core corpus).",
1631
+ ),
1632
+ MenuOption(
1633
+ "versions",
1634
+ "Versions & rollback",
1635
+ "Corpus content versions (distinct from system deployment); "
1636
+ "roll the corpus back or forward.",
1637
+ ),
1638
+ MenuOption("back", "Back", "Return to the launch menu."),
1639
+ ]
1640
+ try:
1641
+ selected = choose(
1642
+ "Session Distill",
1643
+ options,
1644
+ "start",
1645
+ ui,
1646
+ allow_back=True,
1647
+ corpus_lines=corpus_summary_lines(status),
1648
+ )
1649
+ except BackRequested:
1650
+ return "back"
1651
+ if selected == "back":
1652
+ return "back"
1653
+ if selected == "start":
1654
+ return "start"
1655
+ if selected == "packages":
1656
+ _corpus_packages(ui)
1657
+ elif selected == "versions":
1658
+ _corpus_versions(ui)
1659
+
1660
+
1661
+ def preset_mode(data: dict[str, Any]) -> str:
1662
+ """A preset's root-menu group. Missing/unknown falls back to builder — the
1663
+ same default build_plan applies — so listing presets for a menu never
1664
+ raises on a not-yet-migrated user preset."""
1665
+ mode = data.get("mode")
1666
+ return mode if isinstance(mode, str) and mode in PRESET_MODES else DEFAULT_PRESET_MODE
1667
+
1668
+
1669
+ def mode_default_preset(presets: dict[str, Any], mode: str) -> str | None:
1670
+ """First configured preset in a mode; builder prefers 'balanced' so the
1671
+ long-standing everyday default stays the highlighted choice. None means
1672
+ the mode has no presets configured (only reachable via a stripped-down
1673
+ user config; Custom still covers builder in that case)."""
1674
+ names = [name for name, data in presets.items() if preset_mode(data) == mode]
1675
+ if mode == DEFAULT_PRESET_MODE and "balanced" in names:
1676
+ return "balanced"
1677
+ return names[0] if names else None
1678
+
1679
+
1680
+ def pick_mode_and_preset(
1681
+ config: dict[str, Any],
1682
+ host: str,
1683
+ ui: TextualUI | None,
1684
+ resume_mode: str | None,
1685
+ ) -> tuple[str, bool, str]:
1686
+ """Root menu: a 3-way mode picker (General user / Builder / Session
1687
+ distill), then that mode's preset submenu (Builder also lists Custom).
1688
+
1689
+ Esc in the submenu returns to the mode picker; Esc at the mode picker
1690
+ cancels the launcher, matching the picker's prior root Esc/q semantics.
1691
+ Returns (preset_name, custom_requested, mode) — the mode is handed back so
1692
+ a later Esc out of the Custom hub resumes this same submenu instead of
1693
+ dropping all the way back to the top mode picker.
1694
+ """
1695
+ presets = config["presets"]
1696
+ mode = resume_mode
1697
+ while True:
1698
+ if mode is None:
1699
+ mode_options = [
1700
+ MenuOption(
1701
+ GENERAL_MODE,
1702
+ "General user",
1703
+ "Plain presets with nothing launch-specific applied, like "
1704
+ "Vanilla: no tier bindings or launch contract.",
1705
+ ),
1706
+ MenuOption(
1707
+ DEFAULT_PRESET_MODE,
1708
+ "Builder",
1709
+ "Tune tiers, review routes, and permissions across fixed "
1710
+ "presets; includes Custom.",
1711
+ ),
1712
+ MenuOption(
1713
+ DISTILL_MODE,
1714
+ "Session distill",
1715
+ "Enter the dedicated session-distill hub: status, "
1716
+ "packages, versions, and session start.",
1717
+ ),
1718
+ ]
1719
+
1720
+ def preview_mode(value: str) -> dict[str, Any] | None:
1721
+ target = (
1722
+ DISTILL_PRESET
1723
+ if value == DISTILL_MODE
1724
+ else mode_default_preset(presets, value)
1725
+ )
1726
+ return build_plan(config, host, target) if target else None
1727
+
1728
+ initial = preview_mode(DEFAULT_PRESET_MODE)
1729
+ if ui is not None and initial is not None:
1730
+ ui.set_plan(initial)
1731
+ mode = choose(
1732
+ "Mode",
1733
+ mode_options,
1734
+ DEFAULT_PRESET_MODE,
1735
+ ui,
1736
+ preview=preview_mode,
1737
+ corpus_lines=corpus_summary_lines(load_corpus_status()),
1738
+ )
1739
+ if mode == DISTILL_MODE:
1740
+ if distill_hub(config, ui) != "start":
1741
+ mode = None
1742
+ continue
1743
+ if DISTILL_PRESET not in presets:
1744
+ raise LaunchError(f"preset {DISTILL_PRESET!r} missing from config")
1745
+ return DISTILL_PRESET, False, mode
1746
+ options = [
1747
+ MenuOption(
1748
+ name,
1749
+ data["label"],
1750
+ data.get("description", f"Launch the {data['label']} preset."),
1751
+ )
1752
+ for name, data in presets.items()
1753
+ if preset_mode(data) == mode
1754
+ ]
1755
+ if mode == DEFAULT_PRESET_MODE:
1756
+ options.append(
1757
+ MenuOption(
1758
+ CUSTOM_PRESET,
1759
+ "Custom",
1760
+ "Open a settings hub for tiers, review setup, policy, and final confirmation.",
1761
+ )
1762
+ )
1763
+ default = mode_default_preset(presets, mode) or CUSTOM_PRESET
1764
+ if ui is not None and default != CUSTOM_PRESET:
1765
+ ui.set_plan(build_plan(config, host, default))
1766
+
1767
+ def preview_preset(value: str) -> dict[str, Any]:
1768
+ target = default if value == CUSTOM_PRESET else value
1769
+ return build_plan(config, host, target)
1770
+
1771
+ try:
1772
+ selected = choose(
1773
+ "Preset", options, default, ui, allow_back=True, preview=preview_preset
1774
+ )
1775
+ except BackRequested:
1776
+ mode = None
1777
+ continue
1778
+ if selected == CUSTOM_PRESET:
1779
+ return default, True, mode
1780
+ return selected, False, mode
1781
+
1782
+
1389
1783
  def select_plan(
1390
1784
  config: dict[str, Any],
1391
1785
  host: str,
@@ -1397,42 +1791,14 @@ def select_plan(
1397
1791
  presets = config["presets"]
1398
1792
  explicit_preset = preset_name
1399
1793
  show_picker = explicit_preset is None
1794
+ resume_mode: str | None = None
1400
1795
  while True:
1401
1796
  selected_name = explicit_preset
1402
1797
  selected_custom = custom_requested
1403
1798
  if show_picker:
1404
- options = [
1405
- MenuOption(
1406
- name,
1407
- data["label"],
1408
- data.get("description", f"Launch the {data['label']} preset."),
1409
- )
1410
- for name, data in presets.items()
1411
- ]
1412
- options.append(
1413
- MenuOption(
1414
- CUSTOM_PRESET,
1415
- "Custom",
1416
- "Open a settings hub for tiers, review setup, policy, and final confirmation.",
1417
- )
1799
+ selected_name, selected_custom, resume_mode = pick_mode_and_preset(
1800
+ config, host, ui, resume_mode
1418
1801
  )
1419
- default = "balanced" if "balanced" in presets else next(iter(presets))
1420
- if ui is not None:
1421
- ui.set_plan(build_plan(config, host, default))
1422
- selected = choose(
1423
- "Preset",
1424
- options,
1425
- default,
1426
- ui,
1427
- preview=lambda value: build_plan(
1428
- config, host, default if value == CUSTOM_PRESET else value
1429
- ),
1430
- )
1431
- if selected == CUSTOM_PRESET:
1432
- selected_name = default
1433
- selected_custom = True
1434
- else:
1435
- selected_name = selected
1436
1802
  if selected_name not in presets:
1437
1803
  raise LaunchError(f"unknown preset: {selected_name}")
1438
1804
  plan = build_plan(config, host, selected_name)
@@ -1468,7 +1834,7 @@ def codex_agent_configs(
1468
1834
  if not isinstance(templates, dict):
1469
1835
  raise LaunchError("Codex delegation requires [hosts.codex.agent_templates]")
1470
1836
  rendered: dict[str, tuple[str, str]] = {}
1471
- for tier in ("frontier", "workhorse", "sweep"):
1837
+ for tier in SPAWNABLE_TIERS:
1472
1838
  source_value = templates.get(tier)
1473
1839
  if not isinstance(source_value, str) or not source_value:
1474
1840
  raise LaunchError(f"Codex agent template missing: {tier}")
@@ -1630,7 +1996,12 @@ def run_contract(plan: dict[str, Any]) -> str:
1630
1996
  if plan["host"] == "codex"
1631
1997
  else "Main and child model/effort defaults are CLI-projected."
1632
1998
  )
1999
+ mission = plan.get("mission")
2000
+ if mission and plan.get("trigger"):
2001
+ mission = mission.replace("{trigger}", plan["trigger"])
2002
+ mission_prefix = f"Mission: {mission} " if mission else ""
1633
2003
  return (
2004
+ f"{mission_prefix}"
1634
2005
  f"LaunchPlan: main={plan['main_tier']} ({plan['tiers'][plan['main_tier']]['model']}/"
1635
2006
  f"{tier_effort(plan, plan['main_tier'])}); tiers: {bindings}. "
1636
2007
  f"Delegation={'on' if plan['delegation'] else 'off'}. Review family={family}. "
@@ -1643,7 +2014,7 @@ def run_contract(plan: dict[str, Any]) -> str:
1643
2014
 
1644
2015
  def claude_agents(plan: dict[str, Any]) -> str:
1645
2016
  roles = {}
1646
- for tier in TIER_ORDER:
2017
+ for tier in SPAWNABLE_TIERS:
1647
2018
  binding = plan["tiers"][tier]
1648
2019
  roles[tier] = {
1649
2020
  "description": f"{tier.upper()} tier: {binding['model']} at {tier_effort(plan, tier)}",
@@ -1659,6 +2030,12 @@ def claude_agents(plan: dict[str, Any]) -> str:
1659
2030
 
1660
2031
  def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[str]:
1661
2032
  host = plan["host"]
2033
+ if plan.get("mode") == GENERAL_MODE:
2034
+ # General mode is defined as the plain backend with nothing applied: no
2035
+ # launch contract, no tier pinning, no agents, no permission/sandbox
2036
+ # flag. Short-circuit here so that invariant holds structurally instead
2037
+ # of depending on every general-mode preset being configured just so.
2038
+ return []
1662
2039
  main = plan["tiers"][plan["main_tier"]]
1663
2040
  main_effort = tier_effort(plan, plan["main_tier"])
1664
2041
  contract = run_contract(plan)
@@ -1671,11 +2048,12 @@ def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[
1671
2048
  "-c", f"features.multi_agent={'true' if plan['delegation'] else 'false'}",
1672
2049
  ]
1673
2050
  policy = plan["codex_execution_policy"]
1674
- policy_args = (
1675
- ["--dangerously-bypass-approvals-and-sandbox"]
1676
- if policy == "bypass"
1677
- else ["--sandbox", policy]
1678
- )
2051
+ if policy == "bypass":
2052
+ policy_args = ["--dangerously-bypass-approvals-and-sandbox"]
2053
+ elif policy == STANDARD_POLICY:
2054
+ policy_args = []
2055
+ else:
2056
+ policy_args = ["--sandbox", policy]
1679
2057
  args += policy_args
1680
2058
  if plan["delegation"]:
1681
2059
  for tier, (path, description) in codex_agent_configs(plan, materialize_agents).items():
@@ -1699,11 +2077,12 @@ def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[
1699
2077
  if plan["delegation"]:
1700
2078
  args += ["--agents", claude_agents(plan)]
1701
2079
  policy = plan["claude_permission_mode"]
1702
- policy_args = (
1703
- ["--dangerously-skip-permissions"]
1704
- if policy == "bypassPermissions"
1705
- else ["--permission-mode", policy]
1706
- )
2080
+ if policy == "bypassPermissions":
2081
+ policy_args = ["--dangerously-skip-permissions"]
2082
+ elif policy == STANDARD_POLICY:
2083
+ policy_args = []
2084
+ else:
2085
+ policy_args = ["--permission-mode", policy]
1707
2086
  args += policy_args
1708
2087
  if "onto" in effective_routes:
1709
2088
  onto_command = resolve_command(plan["capabilities"]["onto"]["command"])
@@ -1782,6 +2161,49 @@ def print_summary(
1782
2161
  print(" Argv " + json.dumps([command, *args]), file=stream)
1783
2162
 
1784
2163
 
2164
+ SESSION_DISTILL_STATE = pathlib.Path(
2165
+ os.environ.get(
2166
+ "AGENT_BIOS_SESSION_DISTILL_STATE",
2167
+ str(pathlib.Path.home() / ".local/share/agent-bios/session-distill-state.json"),
2168
+ )
2169
+ )
2170
+
2171
+
2172
+ def _line_count(path: pathlib.Path) -> int:
2173
+ try:
2174
+ with path.open("rb") as fh:
2175
+ return sum(chunk.count(b"\n") for chunk in iter(lambda: fh.read(1 << 20), b""))
2176
+ except OSError:
2177
+ return 0
2178
+
2179
+
2180
+ def session_distill_nudge(config: dict[str, Any]) -> str | None:
2181
+ """Nudge when enough sessions accumulated since the last mining window.
2182
+
2183
+ The baseline is written by scripts/session-distill/update-state.py at
2184
+ window close; provider history line counts are a cheap proxy for new
2185
+ sessions. No state file means no nudge.
2186
+ """
2187
+ try:
2188
+ state = json.loads(SESSION_DISTILL_STATE.read_text())
2189
+ baseline = int(state["history_lines_total"])
2190
+ except (OSError, ValueError, KeyError, TypeError):
2191
+ return None
2192
+ settings = config.get("session_distill", {})
2193
+ threshold = settings.get("nudge_after", 250) if isinstance(settings, dict) else 250
2194
+ current = _line_count(pathlib.Path.home() / ".claude/history.jsonl") + _line_count(
2195
+ pathlib.Path.home() / ".codex/history.jsonl"
2196
+ )
2197
+ delta = current - baseline
2198
+ if delta < threshold:
2199
+ return None
2200
+ return (
2201
+ f"session-distill due: ~{delta} new session entries since "
2202
+ f"{state.get('window_end', '?')} (threshold {threshold}) — launch the "
2203
+ "Session distill preset to run the next mining window"
2204
+ )
2205
+
2206
+
1785
2207
  def parse_args(argv: list[str]) -> argparse.Namespace:
1786
2208
  parser = argparse.ArgumentParser(description=__doc__)
1787
2209
  parser.add_argument("--config", type=pathlib.Path, default=default_config_path())
@@ -1811,6 +2233,12 @@ def main(argv: list[str]) -> int:
1811
2233
  config_path = args.config.expanduser()
1812
2234
  config = load_config(config_path)
1813
2235
  command, passthrough = resolve_backend(config, args.host)
2236
+ nudge = session_distill_nudge(config)
2237
+ if nudge:
2238
+ print(f"agent-launch: {nudge}", file=sys.stderr)
2239
+ nudged = config["presets"].get("session-distill")
2240
+ if isinstance(nudged, dict):
2241
+ nudged["description"] = f"{nudged.get('description', '')} ⚠ {nudge}".strip()
1814
2242
  tty = sys.stdin.isatty() and sys.stdout.isatty()
1815
2243
  if not tty and args.dry_run and not args.preset and not args.custom:
1816
2244
  if "balanced" not in config["presets"]:
@@ -1848,6 +2276,12 @@ def main(argv: list[str]) -> int:
1848
2276
  projected = [*project_args(plan, materialize_agents=not args.dry_run), *args.forward]
1849
2277
  summary_stream = sys.stdout if tty or args.dry_run else sys.stderr
1850
2278
  print_summary(plan, command, projected, summary_stream, bool(args.forward))
2279
+ trigger = plan.get("trigger")
2280
+ if trigger:
2281
+ # Black-on-yellow to match the Session Distill identity; the session
2282
+ # waits for this exact phrase before starting the workflow.
2283
+ line = f' Once the session is up, type "{trigger}" to begin the session-distill workflow. '
2284
+ print(f"\n\x1b[1;30;43m{line}\x1b[0m", file=summary_stream)
1851
2285
  if args.dry_run:
1852
2286
  print(json.dumps([command, *projected], ensure_ascii=False))
1853
2287
  return 0