agent-bios 0.13.0 → 0.15.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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/claude/CLAUDE.md +8 -8
  3. package/claude/guides/claude-prompting.md +59 -7
  4. package/claude/guides/cli-multi-model-workflow.md +6 -1
  5. package/claude/guides/coding-staged-workflow.md +12 -0
  6. package/claude/guides/gpt-prompting.md +60 -4
  7. package/claude/guides/learning-flow.md +4 -1
  8. package/claude/guides/llm-capability-boundary-patterns.md +8 -0
  9. package/claude/guides/review-request.md +13 -0
  10. package/claude/guides/session-distill-workflow.md +21 -9
  11. package/claude/guides/tooling-gotchas.md +181 -14
  12. package/claude/guides/verification-discipline.md +71 -3
  13. package/claude/hooks/tooling-gotchas-hook.py +50 -0
  14. package/codex/AGENTS.md +8 -8
  15. package/codex/guides/claude-prompting.md +59 -7
  16. package/codex/guides/cli-multi-model-workflow.md +6 -1
  17. package/codex/guides/coding-staged-workflow.md +12 -0
  18. package/codex/guides/gpt-prompting.md +60 -4
  19. package/codex/guides/learning-flow.md +4 -1
  20. package/codex/guides/llm-capability-boundary-patterns.md +8 -0
  21. package/codex/guides/review-request.md +13 -0
  22. package/codex/guides/session-distill-workflow.md +21 -9
  23. package/codex/guides/tooling-gotchas.md +181 -14
  24. package/codex/guides/verification-discipline.md +71 -3
  25. package/compose/corpus-state.py +1170 -0
  26. package/compose/write-update-cache.py +53 -0
  27. package/install.sh +198 -11
  28. package/launch/agent-launch.py +112 -6
  29. package/launch/agent-launch.zsh +109 -6
  30. package/launch/i18n/en.toml +1 -0
  31. package/launch/i18n/ja.toml +1 -0
  32. package/launch/i18n/ko.toml +1 -0
  33. package/learn/collect-learning.py +593 -62
  34. package/learn/redact.py +2 -1
  35. package/package.json +4 -2
  36. package/provenance.json +1 -1
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """Write the update-check cache atomically.
3
+
4
+ The launcher READS this file on every start and never writes it, so a half-written
5
+ cache is a crash in the TUI rather than a stale badge — hence os.replace over a
6
+ temp file in the same directory, not a plain open-and-write.
7
+
8
+ `latest` is omitted when the lookup failed; `checked_at` is written regardless, so
9
+ an offline machine records its attempt and waits out the interval instead of
10
+ retrying on every launch. Absence of `latest` therefore means "asked, no answer",
11
+ which the launcher must not render as "up to date".
12
+ """
13
+ import json
14
+ import os
15
+ import sys
16
+ import tempfile
17
+
18
+
19
+ def main(argv: list[str]) -> int:
20
+ if len(argv) != 5:
21
+ print("usage: write-update-cache.py <out> <checked_at> <latest|''> <current>",
22
+ file=sys.stderr)
23
+ return 2
24
+ out, checked_at, latest, current = argv[1], argv[2], argv[3], argv[4]
25
+ try:
26
+ checked = int(checked_at)
27
+ except ValueError:
28
+ print(f"checked_at is not an integer: {checked_at!r}", file=sys.stderr)
29
+ return 2
30
+ payload: dict[str, object] = {"checked_at": checked}
31
+ if current:
32
+ payload["current"] = current
33
+ if latest:
34
+ payload["latest"] = latest
35
+ directory = os.path.dirname(out) or "."
36
+ os.makedirs(directory, exist_ok=True)
37
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".update-check.")
38
+ try:
39
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
40
+ json.dump(payload, handle)
41
+ os.replace(tmp, out)
42
+ except BaseException:
43
+ # A temp file left in the state dir would be swept by nothing.
44
+ try:
45
+ os.unlink(tmp)
46
+ except OSError:
47
+ pass
48
+ raise
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ sys.exit(main(sys.argv))
package/install.sh CHANGED
@@ -13,6 +13,7 @@
13
13
  # agent-bios status show what is installed and where
14
14
  # agent-bios cost the session cost / context meter, from any directory
15
15
  # agent-bios update git pull + reinstall (clone), or print the npm update line
16
+ # agent-bios update --check cache the registry's latest version for the TUI badge
16
17
  # agent-bios uninstall remove deployed files and the zsh hook
17
18
  # agent-bios help
18
19
  #
@@ -68,6 +69,21 @@ PRIOR_MANIFEST="$STATE_DIR/manifest.prev.txt"
68
69
  ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"
69
70
  ZSH_HOOK='[ -r "$HOME/.config/agent-launch/shell.zsh" ] && source "$HOME/.config/agent-launch/shell.zsh"'
70
71
  HOOK_MARK='agent-launch/shell.zsh'
72
+ # .zshrc is the only file this installer WRITES, but it is not the only file zsh reads, and a
73
+ # user who moves the line to .zshenv (where non-interactive shells see it too) had every
74
+ # question about it answered wrongly: `status` said "zsh hook absent" while the integration was
75
+ # demonstrably running, `uninstall` said there was nothing to remove, and the next `install`
76
+ # appended a second copy to .zshrc. Detection therefore looks everywhere zsh would; ownership
77
+ # stays with .zshrc alone.
78
+ ZSH_HOOK_FILES="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv ${ZDOTDIR:-$HOME}/.zprofile"
79
+
80
+ # Echo every startup file that carries the hook line, one per line, or nothing.
81
+ zsh_hook_locations() {
82
+ local f
83
+ for f in $ZSH_HOOK_FILES; do
84
+ if [ -f "$f" ] && grep -qF "$HOOK_MARK" "$f"; then printf '%s\n' "$f"; fi
85
+ done
86
+ }
71
87
 
72
88
  DRY_RUN=0
73
89
  BACKUP_DIR=""
@@ -80,6 +96,13 @@ CLEANUP_FAILED=0
80
96
  # treating it as a failure made cmd_install exit 1 and the EXIT trap restore the previous
81
97
  # manifest — leaving the new corpus on disk with the record saying the old one was deployed.
82
98
  ENTRY_NEEDS_ACTION=0
99
+ # Set when the required corpus-status projection could not be written. Deferred rather than
100
+ # returned on the spot: the projection runs AFTER the corpus is deployed, and returning
101
+ # there would fire the EXIT trap and restore the PREVIOUS manifest — leaving the new corpus
102
+ # on disk with the record naming the old one, which is the split state above. The manifest
103
+ # is completed first so it describes what is really there, and the command then exits
104
+ # non-zero. The files, the record, and the exit code then each say something true.
105
+ PROJECTION_FAILED=0
83
106
  # Deployed files uninstall could not back up, so did not delete. Global rather than local
84
107
  # because the closing summary must not claim a clean removal that did not happen.
85
108
  UNBACKED=0
@@ -390,8 +413,11 @@ assemble_corpus() {
390
413
  }
391
414
 
392
415
  add_zsh_hook() {
393
- if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then
394
- info "zsh hook present $ZSHRC"
416
+ local found
417
+ found="$(zsh_hook_locations)"
418
+ if [ -n "$found" ]; then
419
+ # Appending beside an existing copy would source the integration twice per shell.
420
+ info "zsh hook present $(printf '%s' "$found" | tr '\n' ' ')"
395
421
  return
396
422
  fi
397
423
  if [ "$DRY_RUN" = 1 ]; then info "[dry-run] append zsh hook to $ZSHRC"; return; fi
@@ -689,8 +715,13 @@ PY
689
715
  }
690
716
 
691
717
  remove_zsh_hook() {
718
+ local other
719
+ # Named, never edited: a line this installer did not write is the user's, and uninstall
720
+ # removing it would be editing a file it does not own.
721
+ other="$(zsh_hook_locations | grep -vF "$ZSHRC" || true)"
722
+ [ -n "$other" ] && info "zsh hook also in $(printf '%s' "$other" | tr '\n' ' ') — left in place (not written by this installer)"
692
723
  if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
693
- info "no zsh hook to remove"
724
+ info "no zsh hook to remove $ZSHRC"
694
725
  return
695
726
  fi
696
727
  if [ "$DRY_RUN" = 1 ]; then info "[dry-run] remove zsh hook from $ZSHRC"; return; fi
@@ -836,12 +867,36 @@ cmd_install() {
836
867
  # deployed, against this script's own `--dry-run print actions without changing
837
868
  # anything` and README's identical sentence. A dry run that edits state is worse than
838
869
  # no dry run: it is consulted precisely when the user is unwilling to touch anything.
870
+ #
871
+ # The projection is REQUIRED, not best-effort. It used to be neither: stderr and the
872
+ # exit status were both discarded and every failure printed one guess of a note —
873
+ # "versions.json/ledger missing?" — which was wrong for the failure that actually
874
+ # happened. `compose/corpus-state.py` was not in the npm package at all, so a real
875
+ # npm install rewrote the corpus, updated selection.json and version.json, passed
876
+ # verification, exited 0, and left corpus-status.json stale from a previous
877
+ # deployment. The launcher's corpus panel reads that file, so the machine reported a
878
+ # selection the successful run had not recorded. A command that deploys the launcher
879
+ # and advertises its panel cannot call that a success.
880
+ #
881
+ # The script now ships and degrades honestly in an npm layout (domains real,
882
+ # version/ledger reported unavailable), so the remaining failures are real ones:
883
+ # an unwritable destination, an invalid status, a missing interpreter. Those fail
884
+ # the install, and the reason reaches the operator instead of /dev/null.
839
885
  if [ "$DRY_RUN" = 1 ]; then
840
886
  info "[dry-run] project corpus-status"
841
- elif python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
842
- info "corpus-status projected"
843
887
  else
844
- log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
888
+ local projection_log
889
+ projection_log="$(mktemp -t corpus-projection)"
890
+ if python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >"$projection_log" 2>&1; then
891
+ info "corpus-status projected"
892
+ rm -f "$projection_log"
893
+ else
894
+ log "corpus-status projection FAILED — the launcher's corpus panel would report a"
895
+ log "selection this run did not record. Its own output:"
896
+ sed 's/^/ /' "$projection_log"
897
+ log " full output: $projection_log"
898
+ PROJECTION_FAILED=1
899
+ fi
845
900
  fi
846
901
  # Deploy/system version marker for the launcher's TUI version line, read from
847
902
  # package.json (version + releaseDate) — distinct from the corpus content
@@ -894,7 +949,13 @@ PY
894
949
  # restore armed above must not fire.
895
950
  INSTALL_COMPLETED=1
896
951
  log ""
897
- log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
952
+ # Withheld when the projection failed: the run below reports INSTALL INCOMPLETE and
953
+ # exits non-zero, and printing "Done." first told the operator both things in the
954
+ # same breath. The manifest is already complete by here, which is the point — the
955
+ # files really are deployed; what failed is the record of WHICH selection they are.
956
+ if [ "$PROJECTION_FAILED" != 1 ]; then
957
+ log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
958
+ fi
898
959
  if [ "${ENTRY_NEEDS_ACTION:-0}" = 1 ]; then
899
960
  log ""
900
961
  log "ONE STEP LEFT: add this line to $CLAUDE_DIR/CLAUDE.md (yours; we never rewrite it):"
@@ -905,6 +966,13 @@ PY
905
966
  # must not become a nonzero exit under set -e.
906
967
  { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
907
968
  prune_backups
969
+ if [ "$PROJECTION_FAILED" = 1 ]; then
970
+ log ""
971
+ log "INSTALL INCOMPLETE: the corpus is deployed and the manifest records it, but the"
972
+ log "corpus-status projection failed above — the launcher's panel would describe a"
973
+ log "state this run did not record. Fix the cause and re-run: agent-bios install"
974
+ exit 1
975
+ fi
908
976
  else
909
977
  log "VERIFY FAILED after install — see messages above"
910
978
  exit 1
@@ -975,6 +1043,13 @@ cmd_verify() {
975
1043
  verify_match "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
976
1044
  verify_match "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
977
1045
  verify_match "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
1046
+ # Evidence, never a verdict: a shadow that was repaired is the mechanism working.
1047
+ shell_shadow_summary
1048
+ # Not `whence -v`: a function redefined at preexec carries no "from FILE" annotation,
1049
+ # so in exactly the shell the reassert repaired, whence reads as "not ours". And not a
1050
+ # substring of the body either — a foreign body can quote it. Byte-equality with the
1051
+ # body the interception captured when it was sourced is the same test the reassert runs.
1052
+ info "live check (run in the terminal you use): [[ \"\$functions[claude]\" == \"\$_agent_launch_body[claude]\" ]] && print OURS → expect OURS"
978
1053
  # The catalogs were the one deploy-managed artifact nothing verified, which is
979
1054
  # the state that renders key names on screen: the deployed launcher keeps no
980
1055
  # catalog beside itself, so whatever is here IS the UI text. Byte-identity, the
@@ -1055,7 +1130,19 @@ PY
1055
1130
  fi
1056
1131
  fi
1057
1132
  # Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
1058
- if [ -d "$REPO/ko" ] && [ -x "$REPO/gates/check-parity.sh" ]; then
1133
+ #
1134
+ # Not from inside the install-scenario suite, though. That suite points REPO at its own
1135
+ # checkout and writes only into a sandbox HOME, so the tree this gate would judge is the
1136
+ # very tree the umbrella that launched the suite already judged — and the suite performs
1137
+ # twelve verifies. Measured 2026-08-31: 3m37s each, 43m22s of a 50m26s commit, 86% of it,
1138
+ # for twelve re-derivations of one unchanged answer that no assertion in
1139
+ # gates/test-install-guides.sh reads. The skip announces itself, because a leg that goes
1140
+ # quiet is how a suite reports clean over something it never ran. The payload and
1141
+ # prompting-target gates above are deliberately NOT skipped: at 0.16s together they buy
1142
+ # back no time, and running them keeps proving that verify still wires its gates up.
1143
+ if [ "${AGENT_BIOS_IN_INSTALL_TEST:-0}" = 1 ]; then
1144
+ info "SKIP: repo mirror parity — the umbrella running this suite already judged this tree"
1145
+ elif [ -d "$REPO/ko" ] && [ -x "$REPO/gates/check-parity.sh" ]; then
1059
1146
  # Output kept, not discarded — the third place in this repo where a gate's own
1060
1147
  # explanation went to /dev/null and left "it failed" as the entire report. The umbrella
1061
1148
  # and the install-scenario harness each learned this after a failure cost an eleven-
@@ -1273,6 +1360,9 @@ archive_and_purge() {
1273
1360
  # the file keeps the thing that names it. Everything else goes either way.
1274
1361
  if [ "${UNBACKED:-0}" -eq 0 ]; then
1275
1362
  rm -rf "$STATE_DIR"
1363
+ else
1364
+ # The dir survives for the manifest's sake; the shell-shadow evidence is not that.
1365
+ rm -f "$STATE_DIR/shell-shadow.log"
1276
1366
  fi
1277
1367
  rm -rf "$HOME/.cache/agent-launch" \
1278
1368
  "${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch}"
@@ -1372,8 +1462,22 @@ cmd_onboard() {
1372
1462
  log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
1373
1463
  exit 1
1374
1464
  fi
1375
- python3 "$REPO/compose/corpus-state.py" record-apply \
1376
- --requested "$sel" --outcome applied >/dev/null 2>&1 || true
1465
+ # The SUCCESS record is strict; the two failure records above stay tolerant. The
1466
+ # asymmetry is the point: a swallowed failure-record rides a run that is already
1467
+ # exiting non-zero and reporting why, while a swallowed success-record is how
1468
+ # onboarding prints a completed summary over a status file that never learned the
1469
+ # selection was applied. That is the state this whole change exists to remove, so it
1470
+ # cannot be the one still guarded by `|| true`.
1471
+ record_log="$(mktemp -t corpus-record-apply)"
1472
+ if ! python3 "$REPO/compose/corpus-state.py" record-apply \
1473
+ --requested "$sel" --outcome applied >"$record_log" 2>&1; then
1474
+ log "ONBOARDING INCOMPLETE: the corpus applied, but recording that outcome failed —"
1475
+ log "the corpus panel would not show this selection as applied. Its own output:"
1476
+ sed 's/^/ /' "$record_log"
1477
+ log " full output: $record_log"
1478
+ exit 1
1479
+ fi
1480
+ rm -f "$record_log"
1377
1481
  # The prune is authorized by the canary's proof, and cmd_install ran before the canary existed
1378
1482
  # for this bundle rev — so it kept everything. Now that loading is proven, run it for real.
1379
1483
  migrate_learnings
@@ -1528,10 +1632,89 @@ cmd_status() {
1528
1632
  "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh"; do
1529
1633
  if [ -e "$p" ]; then info "present $p"; else info "MISSING $p"; fi
1530
1634
  done
1531
- if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then info "zsh hook present"; else info "zsh hook absent"; fi
1635
+ zsh_found="$(zsh_hook_locations)"
1636
+ if [ -z "$zsh_found" ]; then
1637
+ info "zsh hook absent"
1638
+ elif printf '%s\n' "$zsh_found" | grep -qxF "$ZSHRC"; then
1639
+ info "zsh hook present $(printf '%s' "$zsh_found" | tr '\n' ' ')"
1640
+ else
1641
+ # Present and working, but somewhere this installer will neither update nor remove.
1642
+ info "zsh hook present $(printf '%s' "$zsh_found" | tr '\n' ' ') (unmanaged location: not $ZSHRC)"
1643
+ fi
1644
+ shell_shadow_summary
1645
+ }
1646
+
1647
+ # The interception in launch/agent-launch.zsh reasserts `claude`/`codex` at preexec when a
1648
+ # tool redefined them after the rc files (cmux does, on its first precmd) and appends one
1649
+ # line per shadowed host per shell to $STATE_DIR/shell-shadow.log. That log is the only
1650
+ # evidence of shadowing status/verify can read: a child shell cannot reproduce the
1651
+ # terminal's own bootstrap, so a canary run from here would report PASS while the live
1652
+ # shell is shadowed — which is why there is none.
1653
+ shell_shadow_summary() {
1654
+ local f="$STATE_DIR/shell-shadow.log" n last
1655
+ # A dir that cannot be written records nothing, and "nothing" must not read as "clean" —
1656
+ # so this branch is exclusive: no "no shadowing observed" beside it.
1657
+ if [ -L "$f" ] || { [ -e "$f" ] && [ ! -f "$f" ]; }; then
1658
+ info "shell interception: evidence log is not a regular file ($f) — nothing is recorded there, so absence here is not evidence"
1659
+ elif { [ -d "$STATE_DIR" ] && [ ! -w "$STATE_DIR" ]; } || { [ -e "$f" ] && [ ! -w "$f" ]; }; then
1660
+ info "shell interception: evidence log not writable ($f) — a shadowing cannot be recorded, so absence here is not evidence"
1661
+ elif [ -s "$f" ]; then
1662
+ # One line is one host's FIRST observation in one shell, never a count of repairs: a
1663
+ # tool that redefines on every prompt is repaired on every command and logged once.
1664
+ n=$(wc -l <"$f" | tr -d ' ')
1665
+ # Control bytes are stripped again on display: the log is written by a shell the
1666
+ # user does not fully own, and a terminal-control sequence in a "foreign body" field
1667
+ # could repaint this very line.
1668
+ last=$(tail -n 1 "$f" | tr -d '\000-\010\013-\037\177')
1669
+ info "shell interception: shadowing observed $n time(s) (first observation per host per shell), last $(printf '%s' "$last" | cut -f1) — $(printf '%s' "$last" | cut -f2) was shadowed by: $(printf '%s' "$last" | cut -f4-)"
1670
+ else
1671
+ info "shell interception: no shadowing observed"
1672
+ fi
1673
+ }
1674
+
1675
+ # The update check is a READ of a public version number: it sends the package name
1676
+ # and nothing derived from this machine, which is the same class as provision-venv's
1677
+ # PyPI fetch and the onboard model probe — see ENDPOINTS.md "Zero egress by default",
1678
+ # whose claim is scoped to DATA egress. It delegates to `npm view` rather than naming
1679
+ # a registry, so a private registry, a proxy, and the user's auth all keep working and
1680
+ # no shipped file carries a URL (gates/check-endpoints.py forbids that outright).
1681
+ #
1682
+ # `checked_at` is written even when the lookup FAILS. An offline machine that recorded
1683
+ # nothing would retry on every launch, which is the opposite of once a day.
1684
+ refresh_update_cache() {
1685
+ if [ "${AGENT_BIOS_UPDATE_CHECK:-1}" = "0" ]; then
1686
+ log "update check disabled (AGENT_BIOS_UPDATE_CHECK=0)"
1687
+ return 0
1688
+ fi
1689
+ local name latest now out
1690
+ name="$(json_field "$REPO/package.json" name)" || return 1
1691
+ [ -n "$name" ] || return 1
1692
+ now="$(date +%s)"
1693
+ latest=""
1694
+ if command -v npm >/dev/null 2>&1; then
1695
+ # `|| latest=""` is not defensive noise: this file runs under `set -euo pipefail`,
1696
+ # so an npm that exits non-zero (offline, firewalled, private registry down) makes
1697
+ # the PIPELINE fail and `set -e` abort the function before it can record the
1698
+ # attempt — turning every offline launch into a retry and `update --check` into
1699
+ # exit 1. Caught by I16's failing-registry stub, not by review.
1700
+ latest="$(npm view "$name" version 2>/dev/null | tr -d '[:space:]')" || latest=""
1701
+ fi
1702
+ mkdir -p "$STATE_DIR" || return 1
1703
+ out="$STATE_DIR/update-check.json"
1704
+ # Atomic: a half-written cache read by the launcher is a crash in the TUI.
1705
+ python3 "$REPO/compose/write-update-cache.py" "$out" "$now" "$latest" "$(source_version)" || return 1
1706
+ if [ -n "$latest" ]; then
1707
+ log "update check: latest $latest (installed $(source_version))"
1708
+ else
1709
+ log "update check: could not reach the registry; recorded the attempt"
1710
+ fi
1532
1711
  }
1533
1712
 
1534
1713
  cmd_update() {
1714
+ if [ "${UPDATE_CHECK_ONLY:-0}" = 1 ]; then
1715
+ refresh_update_cache
1716
+ return $?
1717
+ fi
1535
1718
  if [ -e "$REPO/.git" ]; then
1536
1719
  log "Updating from git..."
1537
1720
  run git -C "$REPO" pull --ff-only
@@ -1559,6 +1742,9 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
1559
1742
  agent-bios cost the session cost / context meter (session-cost.py), from any
1560
1743
  directory: agent-bios cost [--context [--budget N]] <transcript>
1561
1744
  agent-bios update git pull + reinstall (clone), or print the npm update line
1745
+ agent-bios update --check ask the registry for the latest version and cache it
1746
+ for the launcher's badge; sends the package name and nothing
1747
+ else, runs at most daily, AGENT_BIOS_UPDATE_CHECK=0 disables it
1562
1748
  agent-bios uninstall remove deployed files and the zsh hook
1563
1749
  agent-bios help
1564
1750
 
@@ -1624,6 +1810,7 @@ DOMAINS_SET=0
1624
1810
  while [ $# -gt 0 ]; do
1625
1811
  case "$1" in
1626
1812
  --dry-run) DRY_RUN=1 ;;
1813
+ --check) UPDATE_CHECK_ONLY=1 ;;
1627
1814
  --with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
1628
1815
  --with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
1629
1816
  --domains) shift; DOMAINS_ARG="${1:-}"; DOMAINS_SET=1; [ -n "$DOMAINS_ARG" ] || { log "--domains needs a comma-separated domain list (use onboard for core-only)"; exit 2; } ;;
@@ -16,6 +16,7 @@ import string
16
16
  import subprocess
17
17
  import sys
18
18
  import tempfile
19
+ import time
19
20
  import unicodedata
20
21
  import textwrap
21
22
  import tomllib
@@ -7622,6 +7623,94 @@ VERSION_INFO_PATH = pathlib.Path(
7622
7623
  )
7623
7624
  )
7624
7625
 
7626
+ UPDATE_CHECK_PATH = pathlib.Path(
7627
+ os.environ.get(
7628
+ "AGENT_BIOS_UPDATE_CHECK_STATE",
7629
+ str(pathlib.Path.home() / ".local/share/agent-bios/update-check.json"),
7630
+ )
7631
+ )
7632
+
7633
+ # Once a day. The launcher never performs the lookup itself — it reads this cache and
7634
+ # at most SPAWNS the installer, detached, to refresh it. Two reasons, both load-bearing:
7635
+ # `npm view` routinely takes seconds and a launcher that blocks on the network is worse
7636
+ # than one that shows a stale badge, and the network operation belongs to the component
7637
+ # that already owns fetch-corpus (ENDPOINTS.md) rather than to the UI.
7638
+ UPDATE_CHECK_INTERVAL_S = 24 * 60 * 60
7639
+
7640
+
7641
+ def _version_tuple(text: str) -> tuple[int, ...] | None:
7642
+ """Numeric release prefix, or None when it is not one.
7643
+
7644
+ Deliberately refuses anything it does not fully understand rather than guessing:
7645
+ a prerelease like 1.2.3-rc1 returns None, so it is never compared and never
7646
+ announced. Announcing an upgrade to a version the user cannot get is worse than
7647
+ announcing nothing."""
7648
+ parts = text.strip().split(".")
7649
+ if not (2 <= len(parts) <= 4):
7650
+ return None
7651
+ out = []
7652
+ for part in parts:
7653
+ if not part.isdigit():
7654
+ return None
7655
+ out.append(int(part))
7656
+ return tuple(out)
7657
+
7658
+
7659
+ def read_update_cache() -> dict | None:
7660
+ try:
7661
+ data = json.loads(UPDATE_CHECK_PATH.read_text(encoding="utf-8"))
7662
+ except (OSError, ValueError):
7663
+ return None
7664
+ return data if isinstance(data, dict) else None
7665
+
7666
+
7667
+ def update_available(deployed: str | None, cache: dict | None) -> str | None:
7668
+ """The version to announce, or None.
7669
+
7670
+ None whenever anything is unknown — no cache, a cache whose lookup failed (it
7671
+ carries checked_at but no latest), an unparseable version on either side, or a
7672
+ latest that is not strictly greater. `latest` missing means "asked, no answer",
7673
+ which must not read as "up to date"; it simply says nothing."""
7674
+ if not deployed or not cache:
7675
+ return None
7676
+ latest = cache.get("latest")
7677
+ if not isinstance(latest, str):
7678
+ return None
7679
+ here, there = _version_tuple(deployed), _version_tuple(latest)
7680
+ if here is None or there is None:
7681
+ return None
7682
+ return latest if there > here else None
7683
+
7684
+
7685
+ def update_check_due(cache: dict | None, now: float) -> bool:
7686
+ if os.environ.get("AGENT_BIOS_UPDATE_CHECK") == "0":
7687
+ return False
7688
+ if cache is None:
7689
+ return True
7690
+ checked = cache.get("checked_at")
7691
+ if not isinstance(checked, (int, float)):
7692
+ return True
7693
+ return (now - checked) >= UPDATE_CHECK_INTERVAL_S
7694
+
7695
+
7696
+ def spawn_update_check() -> None:
7697
+ """Fire and forget. Every failure here is silent BY DESIGN — a background
7698
+ refresh that reported its own problems would interrupt a launch to say something
7699
+ the user did not ask for and cannot act on."""
7700
+ installer = shutil.which("agent-bios")
7701
+ if not installer:
7702
+ return
7703
+ try:
7704
+ subprocess.Popen(
7705
+ [installer, "update", "--check"],
7706
+ stdin=subprocess.DEVNULL,
7707
+ stdout=subprocess.DEVNULL,
7708
+ stderr=subprocess.DEVNULL,
7709
+ start_new_session=True,
7710
+ )
7711
+ except OSError:
7712
+ pass
7713
+
7625
7714
 
7626
7715
  def load_corpus_status() -> dict[str, Any] | None:
7627
7716
  """The corpus status projection, or None when the file is unreadable or is not
@@ -7653,7 +7742,12 @@ def version_label() -> str | None:
7653
7742
  if not version:
7654
7743
  return None
7655
7744
  released = info.get("releaseDate")
7656
- return f"agent-bios v{version} · {released}" if released else f"agent-bios v{version}"
7745
+ label = f"agent-bios v{version} · {released}" if released else f"agent-bios v{version}"
7746
+ cache = read_update_cache()
7747
+ if update_check_due(cache, time.time()):
7748
+ spawn_update_check()
7749
+ newer = update_available(version, cache)
7750
+ return f"{label} · update v{newer} available" if newer else label
7657
7751
 
7658
7752
 
7659
7753
  def display_width(text: str) -> int:
@@ -7723,21 +7817,33 @@ def _corpus_summary_lines(status: dict[str, Any] | None) -> list[str]:
7723
7817
  def row(label: str, value: str) -> str:
7724
7818
  return label + " " * max(1, column - display_width(label)) + value
7725
7819
 
7820
+ # `versions is None` is the projection saying "this install cannot know" — a packaged
7821
+ # install has no author version/ledger registry (compose/corpus-state.py). It is not
7822
+ # `[]`, which would mean a checkout whose registry is genuinely empty, and it must not
7823
+ # render as `0 placed · 0 versions`: a fabricated zero is worse than a blank, because
7824
+ # a reader cannot tell it from a real count. The domain rows below stay real, which is
7825
+ # the whole point of degrading rather than withholding the projection.
7826
+ unavailable = status.get("versions") is None
7827
+ summary = status.get("summary") or {}
7726
7828
  current = status.get("current_version", "?")
7727
7829
  latest = status.get("latest_version", "?")
7728
- head = row(labels[0], str(current))
7830
+ # `str(current)` printed the literal "None" on a checkout whose registry exists but
7831
+ # holds no version — pre-existing, and visible the moment the row above it learned to
7832
+ # say "unavailable". A known absence reads as none; an unknown one says so.
7833
+ head = row(labels[0], t("panel.unavailable") if unavailable
7834
+ else str(current) if current else t("panel.layers.none"))
7729
7835
  if status.get("rolled_back_to"):
7730
7836
  head += " " + t("panel.rolled-back").format(latest=latest)
7731
- layers = status.get("summary", {}).get("placed_by_layer", {})
7837
+ layers = summary.get("placed_by_layer", {})
7732
7838
  order = ("global", "guide", "hook", "enforcement", "gate")
7733
7839
  layer_text = " · ".join(
7734
7840
  f"{name} {layers[name]}" for name in order if layers.get(name)
7735
7841
  ) or t("panel.layers.none")
7736
- by_status = status.get("summary", {}).get("by_status", {})
7842
+ by_status = summary.get("by_status", {})
7737
7843
  lines = [
7738
7844
  head,
7739
- row(labels[1], layer_text),
7740
- row(labels[2], t("panel.ledger.value").format(
7845
+ row(labels[1], t("panel.unavailable") if unavailable else layer_text),
7846
+ row(labels[2], t("panel.unavailable") if unavailable else t("panel.ledger.value").format(
7741
7847
  placed=by_status.get("placed", 0),
7742
7848
  incubating=by_status.get("incubating", 0) + by_status.get("incubating-G", 0),
7743
7849
  versions=len(status_list(status.get("versions"))),
@@ -1,3 +1,10 @@
1
+ # Alias expansion is OFF while this file is parsed and restored to what it was at the
2
+ # end: zsh expands aliases at parse time, inside function bodies too, so an alias named
3
+ # `builtin`, `command`, or `claude` that exists when this file is sourced would be baked
4
+ # into the functions below and the real binary never reached.
5
+ [[ -o aliases ]] && _agent_launch_aliases_were=1 || _agent_launch_aliases_were=0
6
+ setopt no_aliases
7
+
1
8
  # Interactive zero-argument entrypoints use agent-launch. Every argument-bearing
2
9
  # or non-TTY call skips profile projection; Claude retains its direct-path
3
10
  # permission-bypass default.
@@ -31,13 +38,109 @@ _agent_launch_dispatch() {
31
38
  fi
32
39
  }
33
40
 
34
- unalias codex 2>/dev/null
35
- unalias claude 2>/dev/null
41
+ # The entrypoints are (re)defined from one place so the reassert below installs the
42
+ # same thing the initial source did. The top-level unalias stays: zsh expands aliases
43
+ # while PARSING the function below, so an alias named `claude` that exists when this
44
+ # file is sourced would turn its `claude() {` into a parse error and leave the alias in
45
+ # control. The inner unalias handles an alias that arrives later, at reassert time.
46
+ # `no_err_return`: a missing alias makes `unalias` return 1, and under ERR_RETURN that
47
+ # would end the function before either entrypoint is defined.
48
+ unalias codex 2>/dev/null || :
49
+ unalias claude 2>/dev/null || :
50
+ _agent_launch_define() {
51
+ setopt localoptions no_err_return no_err_exit
52
+ unalias codex 2>/dev/null
53
+ unalias claude 2>/dev/null
54
+ codex() { _agent_launch_dispatch codex "$@"; }
55
+ claude() { _agent_launch_dispatch claude "$@"; }
56
+ return 0
57
+ }
58
+ _agent_launch_define
59
+
60
+ # Reassert the entrypoints right before each command runs. "Last definer wins" is
61
+ # the shell's rule, and a terminal or tool that defines `claude` after the rc files
62
+ # (cmux does, on its first precmd — deliberately, "in case user startup replaced
63
+ # them") would otherwise win in silence: bare `claude` skips the preflight while
64
+ # `codex` keeps working. preexec runs after every precmd, whatever the registration
65
+ # order, so for a prompt-hook shadower the check is the last word at the only moment
66
+ # that matters. Two things it does not do, on purpose: it does not fight a hook that
67
+ # runs after it in preexec (that tool wins; the shadow log names it), and it cannot
68
+ # repair the command already parsed when the shadow is an alias — aliases expand at
69
+ # parse time, so an alias shadow is repaired from the next command on. The downstream
70
+ # stays `builtin command <host>`, so a tool that also owns a PATH shim (cmux) keeps its
71
+ # own injection.
72
+ #
73
+ # Ownership is byte-equality with the body captured at source time, never a marker
74
+ # string: a foreign function that quotes the marker would otherwise pass as ours.
75
+ zmodload -i zsh/parameter 2>/dev/null
76
+ typeset -gA _agent_launch_body _agent_launch_shadow_noted
77
+ _agent_launch_body[claude]="${functions[claude]}"
78
+ _agent_launch_body[codex]="${functions[codex]}"
36
79
 
37
- codex() {
38
- _agent_launch_dispatch codex "$@"
80
+ # Evidence, not repair: one line per host per shell, into the state dir agent-bios
81
+ # already owns — what `agent-bios status`/`verify` read. Never creates the dir (no
82
+ # install, no log). The "noted" flag is set only after the append succeeded, so a
83
+ # directory that cannot be written is retried on the next command instead of being
84
+ # recorded as "no shadowing observed". Tabs and newlines are stripped from every field
85
+ # so a hostile TERM_PROGRAM or function body cannot forge a second row.
86
+ _agent_launch_note_shadow() {
87
+ setopt localoptions extendedglob noksharrays no_err_return no_err_exit
88
+ local host="$1" foreign="$2" state="$HOME/.local/share/agent-bios" first prog stamp
89
+ [[ -n "${_agent_launch_shadow_noted[$host]-}" || ! -d "$state" ]] && return 0
90
+ # Only a regular, non-symlinked file is evidence: a log pointing at /dev/null would
91
+ # take the write, set the flag, and keep nothing.
92
+ [[ -e "$state/shell-shadow.log" && ( -L "$state/shell-shadow.log" || ! -f "$state/shell-shadow.log" ) ]] && return 0
93
+ first="${foreign%%$'\n'*}"
94
+ first="${first##[[:space:]]#}"
95
+ first="${first//[[:cntrl:]]/ }"
96
+ prog="${TERM_PROGRAM:-unknown}"
97
+ prog="${prog//[[:cntrl:]]/ }"
98
+ # `builtin command`: a shell function named `date` must not write this field.
99
+ stamp="$(builtin command date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
100
+ stamp="${stamp//[[:cntrl:]]/ }"
101
+ # `builtin printf`: a shell function named printf could report success and write nothing.
102
+ if builtin printf '%s\t%s\t%s\t%s\n' "${stamp:-<unknown>}" "$host" "$prog" \
103
+ "${first:-<undefined>}" >> "$state/shell-shadow.log" 2>/dev/null; then
104
+ _agent_launch_shadow_noted[$host]=1
105
+ fi
106
+ return 0
39
107
  }
40
108
 
41
- claude() {
42
- _agent_launch_dispatch claude "$@"
109
+ _agent_launch_reassert() {
110
+ setopt localoptions noksharrays no_err_return no_err_exit
111
+ local host foreign entry
112
+ local -a shadowed
113
+ for host in claude codex; do
114
+ foreign=""
115
+ # Existence, not non-emptiness: `alias claude=''` is a shadow that erases the command
116
+ # word. Global aliases live in $galiases, not $aliases; `unalias` removes either.
117
+ if (( ${+aliases[$host]} )); then
118
+ foreign="alias $host=${aliases[$host]}"
119
+ elif (( ${+galiases[$host]} )); then
120
+ foreign="alias -g $host=${galiases[$host]}"
121
+ elif [[ "${functions[$host]-}" != "${_agent_launch_body[$host]}" ]]; then
122
+ foreign="${functions[$host]-<undefined>}"
123
+ fi
124
+ [[ -n "$foreign" ]] && shadowed+=("${host}"$'\t'"${foreign}")
125
+ done
126
+ (( $#shadowed )) || return 0
127
+ # Repair first, then record: a failure to write evidence must never leave the
128
+ # foreign definition in place for the command about to run.
129
+ _agent_launch_define
130
+ for entry in "${shadowed[@]}"; do
131
+ _agent_launch_note_shadow "${entry%%$'\t'*}" "${entry#*$'\t'}"
132
+ done
133
+ return 0
43
134
  }
135
+ # Registered on precmd as well: a tool that redefines the names in a preexec that runs
136
+ # after ours wins for that command, but its definition is still in place when the next
137
+ # prompt is drawn — precmd is where that shadow is observed and logged, so "recorded, not
138
+ # fought" holds for the case the preexec pass cannot see.
139
+ autoload -Uz add-zsh-hook
140
+ add-zsh-hook -d preexec _agent_launch_reassert 2>/dev/null
141
+ add-zsh-hook -d precmd _agent_launch_reassert 2>/dev/null
142
+ add-zsh-hook preexec _agent_launch_reassert
143
+ add-zsh-hook precmd _agent_launch_reassert
144
+
145
+ (( _agent_launch_aliases_were )) && setopt aliases
146
+ unset _agent_launch_aliases_were
@@ -169,6 +169,7 @@
169
169
  "panel.domains.label" = "Domains"
170
170
  "panel.rolled-back" = "(ROLLED BACK; latest is {latest})"
171
171
  "panel.layers.none" = "none"
172
+ "panel.unavailable" = "unavailable in a packaged install"
172
173
  "panel.ledger.value" = "placed {placed} · incubating {incubating} · versions {versions}"
173
174
  "panel.domains.unset" = "(none selected yet)"
174
175
  "panel.last-apply" = "⚠ LAST APPLY {outcome} at {at} — requested: {requested}"