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.
- package/README.md +2 -2
- package/claude/CLAUDE.md +8 -8
- package/claude/guides/claude-prompting.md +59 -7
- package/claude/guides/cli-multi-model-workflow.md +6 -1
- package/claude/guides/coding-staged-workflow.md +12 -0
- package/claude/guides/gpt-prompting.md +60 -4
- package/claude/guides/learning-flow.md +4 -1
- package/claude/guides/llm-capability-boundary-patterns.md +8 -0
- package/claude/guides/review-request.md +13 -0
- package/claude/guides/session-distill-workflow.md +21 -9
- package/claude/guides/tooling-gotchas.md +181 -14
- package/claude/guides/verification-discipline.md +71 -3
- package/claude/hooks/tooling-gotchas-hook.py +50 -0
- package/codex/AGENTS.md +8 -8
- package/codex/guides/claude-prompting.md +59 -7
- package/codex/guides/cli-multi-model-workflow.md +6 -1
- package/codex/guides/coding-staged-workflow.md +12 -0
- package/codex/guides/gpt-prompting.md +60 -4
- package/codex/guides/learning-flow.md +4 -1
- package/codex/guides/llm-capability-boundary-patterns.md +8 -0
- package/codex/guides/review-request.md +13 -0
- package/codex/guides/session-distill-workflow.md +21 -9
- package/codex/guides/tooling-gotchas.md +181 -14
- package/codex/guides/verification-discipline.md +71 -3
- package/compose/corpus-state.py +1170 -0
- package/compose/write-update-cache.py +53 -0
- package/install.sh +198 -11
- package/launch/agent-launch.py +112 -6
- package/launch/agent-launch.zsh +109 -6
- package/launch/i18n/en.toml +1 -0
- package/launch/i18n/ja.toml +1 -0
- package/launch/i18n/ko.toml +1 -0
- package/learn/collect-learning.py +593 -62
- package/learn/redact.py +2 -1
- package/package.json +4 -2
- 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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
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; } ;;
|
package/launch/agent-launch.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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"))),
|
package/launch/agent-launch.zsh
CHANGED
|
@@ -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
|
-
|
|
35
|
-
unalias
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
package/launch/i18n/en.toml
CHANGED
|
@@ -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}"
|