From b916deb16618e4d8fb38e94680b50ece742549ce Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:36:01 -0700 Subject: [PATCH 1/8] feat: add optional Pi configuration (#26) * Add optional Pi teaching configuration * no-mistakes(review): Cover nested Pi backups and settled completion * no-mistakes(document): Clarify Pi per-file symlink documentation --- .gitignore | 6 +- README.md | 21 ++- home.nix | 10 ++ .../agent/extensions/terminal-status-title.js | 137 ++++++++++++++++++ home/.pi/agent/models.json | 17 +++ home/.pi/agent/settings.json | 14 ++ home/.pi/agent/themes/rose-pine-moon.json | 75 ++++++++++ 7 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 home/.pi/agent/extensions/terminal-status-title.js create mode 100644 home/.pi/agent/models.json create mode 100644 home/.pi/agent/settings.json create mode 100644 home/.pi/agent/themes/rose-pine-moon.json diff --git a/.gitignore b/.gitignore index 616404b9..0a3116ff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,9 @@ home/.config/herdr/*.sock result result-* +# Pi credentials and Home Manager adoption backups stay local +/home/.pi/agent/auth.json +/home/.pi/agent/**/*.backup + # no-mistakes local validation state - never commit in this public repo -.no-mistakes/ \ No newline at end of file +.no-mistakes/ diff --git a/README.md b/README.md index de641b7e..a4b5ae16 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Running the switch builds: - Editor (Neovim config with the rose-pine moon theme) - Terminal (WezTerm config with the rose-pine moon theme and dimmed unfocused windows) - Agent configs (Claude, Codex, opencode all share one AGENTS.md) +- Optional Pi theme, generic UI settings, model overrides, and terminal-title extension ## Prerequisites @@ -128,7 +129,7 @@ If you don't use it, just remove it from `brews` in your copy. - `home.nix` - user-level config: shell, packages, prompt, and the symlinks described below. - `rebuild.sh` - re-applies the config after the first switch. Run this every time you make a change. -- `home/` - the actual config files that get symlinked into place (Neovim, WezTerm, herdr, Claude settings, the shared `AGENTS.md`). +- `home/` - the actual config files that get symlinked into place; the sections below explain the shared symlink model and Pi's narrower per-file setup. ## How the symlinks work @@ -136,6 +137,24 @@ The files under `home/` are the real files - editing them here is editing your l `home.nix` uses `mkOutOfStoreSymlink` to point paths like `~/.config/nvim` straight at `home/.config/nvim` in this repo, so the two never drift out of sync. You only run `./rebuild.sh` when you change something that isn't just a symlinked file, like a package list or a system default. +## Optional Pi configuration + +Pi is an opt-in CLI, not a dependency this repository vendors. Install it from its owner with the [official Pi instructions](https://pi.dev), for example: + +```sh +npm install -g --ignore-scripts @earendil-works/pi-coding-agent +``` + +[Pi Launcher](https://github.com/kunchenguid/homebrew-tap) is also optional and installed from its owner, not declared by this config: + +```sh +brew install --cask kunchenguid/tap/pi-launcher +``` + +Home Manager links only four authored Pi files: the theme, `models.json`, `settings.json`, and `terminal-status-title.js`. It deliberately does not manage `~/.pi/agent`, so `auth.json`, sessions, trust decisions, caches, and other runtime state remain local. The model overrides contain no credentials or endpoint settings, do not choose a default model, and only take effect after you authenticate Pi yourself. Pi may intentionally rewrite the tracked settings file. Review any drift and commit it only when it is a deliberate configuration change. + +The terminal-title extension shows a spinner while Pi is working, then a completion mark with the session name or current directory. Run `/reload` after editing it. The `rose-pine-moon` theme was authored clean-room from the public [Rosé Pine Moon palette](https://rosepinetheme.com/palette) and Pi's [public theme schema](https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json), not from a private or live theme file. This is an additive post-video layer; it installs no packages or launcher configuration. + ## Notes The first time you launch `nvim`, it bootstraps [lazy.nvim](https://github.com/folke/lazy.nvim) by cloning plugins from GitHub. diff --git a/home.nix b/home.nix index 365a1cbb..a082c397 100644 --- a/home.nix +++ b/home.nix @@ -63,6 +63,16 @@ in home.file.".claude/settings.json".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.claude/settings.json"; + # Keep Pi's credential and runtime state local by linking only authored files. + home.file.".pi/agent/themes/rose-pine-moon.json".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/themes/rose-pine-moon.json"; + home.file.".pi/agent/models.json".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/models.json"; + home.file.".pi/agent/settings.json".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/settings.json"; + home.file.".pi/agent/extensions/terminal-status-title.js".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/extensions/terminal-status-title.js"; + home.file.".claude/CLAUDE.md".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md"; home.file.".codex/AGENTS.md".source = diff --git a/home/.pi/agent/extensions/terminal-status-title.js b/home/.pi/agent/extensions/terminal-status-title.js new file mode 100644 index 00000000..bd246605 --- /dev/null +++ b/home/.pi/agent/extensions/terminal-status-title.js @@ -0,0 +1,137 @@ +const DEFAULT_TITLE = "π"; +const PREFIX = "π"; +const MAX_TITLE_LENGTH = 40; +const SPINNER_INTERVAL_MS = 120; +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function truncateTitle(title) { + if (title.length <= MAX_TITLE_LENGTH) return title; + return title.slice(0, MAX_TITLE_LENGTH - 3) + "..."; +} + +function basename(path) { + if (!path) return DEFAULT_TITLE; + + const trimmed = path.replace(/[\\/]+$/, ""); + if (!trimmed) return DEFAULT_TITLE; + + return trimmed.split(/[\\/]/).pop() || DEFAULT_TITLE; +} + +function getSessionName(pi) { + const name = pi.getSessionName?.(); + return typeof name === "string" ? name.trim() : ""; +} + +function getRawTitle(pi, ctx) { + return getSessionName(pi) || basename(ctx.cwd); +} + +function isSpinningStatus(status) { + return status === "working"; +} + +function statusIndicator(status, spinnerFrame) { + if (isSpinningStatus(status)) { + if (SPINNER_FRAMES.length === 0) return "◉"; + return SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]; + } + + if (status === "done") return "✓"; + if (status === "error") return "✗"; + return "○"; +} + +function formatTitle(pi, ctx, status, spinnerFrame) { + const rawTitle = getRawTitle(pi, ctx); + const suffix = rawTitle === DEFAULT_TITLE ? DEFAULT_TITLE : `${PREFIX} | ${truncateTitle(rawTitle)}`; + + return `${statusIndicator(status, spinnerFrame)} | ${suffix}`; +} + +export default function terminalStatusTitle(pi) { + let status = "idle"; + let spinnerFrame = 0; + let spinnerInterval; + let deferredWrite; + let lastCtx; + + function clearDeferredWrite() { + if (!deferredWrite) return; + + clearTimeout(deferredWrite); + deferredWrite = undefined; + } + + function writeTitle(ctx = lastCtx) { + if (!ctx?.hasUI) return; + + lastCtx = ctx; + ctx.ui.setTitle(formatTitle(pi, ctx, status, spinnerFrame)); + } + + function stopSpinner() { + if (!spinnerInterval) return; + + clearInterval(spinnerInterval); + spinnerInterval = undefined; + spinnerFrame = 0; + } + + function startSpinner(ctx) { + if (!ctx?.hasUI || spinnerInterval) return; + + spinnerFrame = 0; + spinnerInterval = setInterval(() => { + if (!isSpinningStatus(status)) { + stopSpinner(); + return; + } + + spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length; + writeTitle(); + }, SPINNER_INTERVAL_MS); + spinnerInterval.unref?.(); + } + + function setStatus(nextStatus, ctx) { + clearDeferredWrite(); + status = nextStatus; + lastCtx = ctx; + + if (isSpinningStatus(status)) { + startSpinner(ctx); + } else { + stopSpinner(); + } + + writeTitle(ctx); + } + + function scheduleWrite(ctx) { + clearDeferredWrite(); + deferredWrite = setTimeout(() => { + deferredWrite = undefined; + writeTitle(ctx); + }, 0); + deferredWrite.unref?.(); + } + + pi.on("session_start", async (_event, ctx) => { + setStatus("idle", ctx); + scheduleWrite(ctx); + }); + + pi.on("agent_start", async (_event, ctx) => { + setStatus("working", ctx); + }); + + pi.on("agent_settled", async (_event, ctx) => { + setStatus("done", ctx); + }); + + pi.on("session_shutdown", async () => { + clearDeferredWrite(); + stopSpinner(); + }); +} diff --git a/home/.pi/agent/models.json b/home/.pi/agent/models.json new file mode 100644 index 00000000..c4ee7894 --- /dev/null +++ b/home/.pi/agent/models.json @@ -0,0 +1,17 @@ +{ + "providers": { + "openai-codex": { + "modelOverrides": { + "gpt-5.6-luna": { + "contextWindow": 272000 + }, + "gpt-5.6-sol": { + "contextWindow": 272000 + }, + "gpt-5.6-terra": { + "contextWindow": 272000 + } + } + } + } +} diff --git a/home/.pi/agent/settings.json b/home/.pi/agent/settings.json new file mode 100644 index 00000000..3f3a85f1 --- /dev/null +++ b/home/.pi/agent/settings.json @@ -0,0 +1,14 @@ +{ + "images": { + "blockImages": false + }, + "terminal": { + "showImages": false + }, + "hideThinkingBlock": true, + "quietStartup": true, + "theme": "rose-pine-moon", + "steeringMode": "all", + "followUpMode": "all", + "collapseChangelog": true +} diff --git a/home/.pi/agent/themes/rose-pine-moon.json b/home/.pi/agent/themes/rose-pine-moon.json new file mode 100644 index 00000000..1b97b494 --- /dev/null +++ b/home/.pi/agent/themes/rose-pine-moon.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "rose-pine-moon", + "vars": { + "base": "#232136", + "surface": "#2a273f", + "overlay": "#393552", + "muted": "#6e6a86", + "subtle": "#908caa", + "text": "#e0def4", + "love": "#eb6f92", + "gold": "#f6c177", + "rose": "#ea9a97", + "pine": "#3e8fb0", + "foam": "#9ccfd8", + "iris": "#c4a7e7", + "highlightLow": "#2a283e", + "highlightMed": "#44415a", + "highlightHigh": "#56526e" + }, + "colors": { + "accent": "iris", + "border": "overlay", + "borderAccent": "iris", + "borderMuted": "muted", + "success": "foam", + "error": "love", + "warning": "gold", + "muted": "subtle", + "dim": "muted", + "text": "text", + "thinkingText": "subtle", + "selectedBg": "highlightMed", + "userMessageBg": "surface", + "userMessageText": "text", + "customMessageBg": "surface", + "customMessageText": "text", + "customMessageLabel": "iris", + "toolPendingBg": "highlightLow", + "toolSuccessBg": "surface", + "toolErrorBg": "surface", + "toolTitle": "foam", + "toolOutput": "text", + "mdHeading": "iris", + "mdLink": "foam", + "mdLinkUrl": "subtle", + "mdCode": "rose", + "mdCodeBlock": "text", + "mdCodeBlockBorder": "overlay", + "mdQuote": "subtle", + "mdQuoteBorder": "foam", + "mdHr": "overlay", + "mdListBullet": "iris", + "toolDiffAdded": "foam", + "toolDiffRemoved": "love", + "toolDiffContext": "subtle", + "syntaxComment": "muted", + "syntaxKeyword": "iris", + "syntaxFunction": "foam", + "syntaxVariable": "text", + "syntaxString": "gold", + "syntaxNumber": "rose", + "syntaxType": "pine", + "syntaxOperator": "iris", + "syntaxPunctuation": "subtle", + "thinkingOff": "muted", + "thinkingMinimal": "pine", + "thinkingLow": "foam", + "thinkingMedium": "iris", + "thinkingHigh": "rose", + "thinkingXhigh": "love", + "thinkingMax": "gold", + "bashMode": "gold" + } +} From eebc575f28b194ea71b3788c0030776fe742b9b8 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:57:30 -0700 Subject: [PATCH 2/8] feat: pin Pi packages and manage resource directories (#27) * Configure pinned Pi packages and resource directories * no-mistakes(review): Recognize Home Manager links during Pi directory migration * no-mistakes(review): Canonicalize expected Pi migration test source * no-mistakes(document): Refresh Pi link documentation --- CLAUDE.md | 1 + README.md | 17 +++++-- home.nix | 36 ++++++++++--- home/.pi/agent/settings.json | 6 ++- tests/pi-home-manager.sh | 99 ++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 11 deletions(-) create mode 120000 CLAUDE.md create mode 100755 tests/pi-home-manager.sh diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index a4b5ae16..76848059 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Running the switch builds: - Editor (Neovim config with the rose-pine moon theme) - Terminal (WezTerm config with the rose-pine moon theme and dimmed unfocused windows) - Agent configs (Claude, Codex, opencode all share one AGENTS.md) -- Optional Pi theme, generic UI settings, model overrides, and terminal-title extension +- Optional Pi theme and terminal-title extension, generic UI settings and model overrides, plus two deliberately pinned third-party Pi packages ## Prerequisites @@ -129,7 +129,7 @@ If you don't use it, just remove it from `brews` in your copy. - `home.nix` - user-level config: shell, packages, prompt, and the symlinks described below. - `rebuild.sh` - re-applies the config after the first switch. Run this every time you make a change. -- `home/` - the actual config files that get symlinked into place; the sections below explain the shared symlink model and Pi's narrower per-file setup. +- `home/` - the actual config files that get symlinked into place; the sections below explain the shared symlink model and Pi's narrower selective setup. ## How the symlinks work @@ -151,9 +151,18 @@ npm install -g --ignore-scripts @earendil-works/pi-coding-agent brew install --cask kunchenguid/tap/pi-launcher ``` -Home Manager links only four authored Pi files: the theme, `models.json`, `settings.json`, and `terminal-status-title.js`. It deliberately does not manage `~/.pi/agent`, so `auth.json`, sessions, trust decisions, caches, and other runtime state remain local. The model overrides contain no credentials or endpoint settings, do not choose a default model, and only take effect after you authenticate Pi yourself. Pi may intentionally rewrite the tracked settings file. Review any drift and commit it only when it is a deliberate configuration change. +Home Manager owns exactly two repository-authored Pi directories: `~/.pi/agent/themes` and `~/.pi/agent/extensions`. It also links `models.json` and `settings.json` as individual files. The local extension directory is for public, repository-authored extensions only - third-party package code never belongs there. Run `/reload` after editing a local extension or other Pi resources. The terminal-title extension shows a spinner while Pi is working, then a completion mark with the session name or current directory. The `rose-pine-moon` theme was authored clean-room from the public [Rosé Pine Moon palette](https://rosepinetheme.com/palette) and Pi's [public theme schema](https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json), not from a private or live theme file. -The terminal-title extension shows a spinner while Pi is working, then a completion mark with the session name or current directory. Run `/reload` after editing it. The `rose-pine-moon` theme was authored clean-room from the public [Rosé Pine Moon palette](https://rosepinetheme.com/palette) and Pi's [public theme schema](https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json), not from a private or live theme file. This is an additive post-video layer; it installs no packages or launcher configuration. +Pi's package system declares two third-party sources in the linked global `settings.json`: + +- `npm:@ryan_nookpi/pi-extension-codex-fast-mode@0.2.6` - the exact public npm release from `ryan_nookpi`. +- `git:github.com/algal/pi-openai-server-compaction@c6d593087709e9481223dc6c6c2269b371b5e055` - the exact public `algal` commit for experimental OpenAI server-side compaction. + +The version and commit are immutable pins, so Pi does not move them during package updates. Deliberate updates require a new source and security audit, followed by an explicit pin change in `home/.pi/agent/settings.json`. On Pi 0.82.0, global settings declarations install missing pinned packages automatically at startup. No one-time install command is required. Pi keeps the downloaded npm and git package trees in its own unmanaged `~/.pi/agent/npm` and `~/.pi/agent/git` runtime directories, outside Home Manager and Git tracking. + +Both packages execute with your full user permissions and must be trusted like any other executable code. The compaction package is experimental, sends the relevant OpenAI compaction and continuity data to OpenAI, and upstream declares the stale peer range `>=0.80.9 <0.81.0`; this exact immutable ref was locally proven to load and perform remote compaction on Pi 0.82.0. Do not treat that proof as a guarantee for a different Pi version or a different package ref. + +Home Manager deliberately does not manage `~/.pi/agent` itself, or Pi authentication, sessions, trust decisions, caches, npm/git package trees, or any other runtime state. The model overrides contain no credentials or endpoint settings, do not choose a default model, and only take effect after you authenticate Pi yourself. This remains an additive post-video layer: it does not install Pi, a launcher, or package source code into this repository. ## Notes diff --git a/home.nix b/home.nix index a082c397..3f7753fe 100644 --- a/home.nix +++ b/home.nix @@ -1,4 +1,4 @@ -{ config, pkgs, user, ... }: +{ config, lib, pkgs, user, ... }: let dotfiles = "${config.home.homeDirectory}/.dotfiles"; @@ -63,15 +63,39 @@ in home.file.".claude/settings.json".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.claude/settings.json"; - # Keep Pi's credential and runtime state local by linking only authored files. - home.file.".pi/agent/themes/rose-pine-moon.json".source = - config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/themes/rose-pine-moon.json"; + # Keep Pi's credential and runtime state local by linking only authored files and directories. + home.file.".pi/agent/themes".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/themes"; + home.file.".pi/agent/extensions".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/extensions"; home.file.".pi/agent/models.json".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/models.json"; home.file.".pi/agent/settings.json".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/settings.json"; - home.file.".pi/agent/extensions/terminal-status-title.js".source = - config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/extensions/terminal-status-title.js"; + + # Remove only the two legacy managed child links before Home Manager adopts their directories. + home.activation.migratePiAuthoredDirectories = lib.hm.dag.entryBefore [ "checkLinkTargets" ] '' + removeLegacyPiLink() { + local target="$1" + local source="$2" + local relativeTarget="''${target#"$HOME/"}" + local linkTarget + + [ -L "$target" ] || return 0 + linkTarget="$(readlink "$target")" + case "$linkTarget" in + /nix/store/*-home-manager-files/"$relativeTarget") ;; + *) return 0 ;; + esac + [ "$(readlink -f "$target")" = "$(readlink -f "$source")" ] || return 0 + $DRY_RUN_CMD rm "$target" + } + + removeLegacyPiLink "$HOME/.pi/agent/themes/rose-pine-moon.json" "${dotfiles}/home/.pi/agent/themes/rose-pine-moon.json" + removeLegacyPiLink "$HOME/.pi/agent/extensions/terminal-status-title.js" "${dotfiles}/home/.pi/agent/extensions/terminal-status-title.js" + $DRY_RUN_CMD rmdir "$HOME/.pi/agent/themes" 2>/dev/null || true + $DRY_RUN_CMD rmdir "$HOME/.pi/agent/extensions" 2>/dev/null || true + ''; home.file.".claude/CLAUDE.md".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md"; diff --git a/home/.pi/agent/settings.json b/home/.pi/agent/settings.json index 3f3a85f1..625b9c51 100644 --- a/home/.pi/agent/settings.json +++ b/home/.pi/agent/settings.json @@ -10,5 +10,9 @@ "theme": "rose-pine-moon", "steeringMode": "all", "followUpMode": "all", - "collapseChangelog": true + "collapseChangelog": true, + "packages": [ + "npm:@ryan_nookpi/pi-extension-codex-fast-mode@0.2.6", + "git:github.com/algal/pi-openai-server-compaction@c6d593087709e9481223dc6c6c2269b371b5e055" + ] } diff --git a/tests/pi-home-manager.sh b/tests/pi-home-manager.sh new file mode 100755 index 00000000..192eaed6 --- /dev/null +++ b/tests/pi-home-manager.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +python3 - "$repo_root" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +settings = json.loads((root / "home/.pi/agent/settings.json").read_text()) +expected_packages = [ + "npm:@ryan_nookpi/pi-extension-codex-fast-mode@0.2.6", + "git:github.com/algal/pi-openai-server-compaction@c6d593087709e9481223dc6c6c2269b371b5e055", +] +assert settings.get("packages") == expected_packages, "Pi package declarations must be exactly the two audited pins" + +home_nix = (root / "home.nix").read_text() +required_links = { + '.pi/agent/themes': '${dotfiles}/home/.pi/agent/themes', + '.pi/agent/extensions': '${dotfiles}/home/.pi/agent/extensions', + '.pi/agent/models.json': '${dotfiles}/home/.pi/agent/models.json', + '.pi/agent/settings.json': '${dotfiles}/home/.pi/agent/settings.json', +} +for destination, source in required_links.items(): + declaration = f'home.file."{destination}".source =\n config.lib.file.mkOutOfStoreSymlink "{source}";' + assert declaration in home_nix, f"missing exact out-of-store link: {destination}" + +for old_child in [ + '.pi/agent/themes/rose-pine-moon.json', + '.pi/agent/extensions/terminal-status-title.js', +]: + assert f'home.file."{old_child}"' not in home_nix, f"legacy child link remains: {old_child}" + +for forbidden in [ + '.pi/agent', '.pi/agent/auth.json', '.pi/agent/sessions', '.pi/agent/trust.json', + '.pi/agent/npm', '.pi/agent/git', '.pi/agent/cache', +]: + assert f'home.file."{forbidden}"' not in home_nix, f"Pi runtime path became managed: {forbidden}" + +assert 'entryBefore [ "checkLinkTargets" ]' in home_nix, "migration must run before Home Manager collision checks" +assert 'removeLegacyPiLink "$HOME/.pi/agent/themes/rose-pine-moon.json"' in home_nix +assert 'removeLegacyPiLink "$HOME/.pi/agent/extensions/terminal-status-title.js"' in home_nix +assert (root / "home/.pi/agent/themes/rose-pine-moon.json").is_file() +assert (root / "home/.pi/agent/extensions/terminal-status-title.js").is_file() +assert [p.relative_to(root / "home/.pi/agent/themes").as_posix() for p in (root / "home/.pi/agent/themes").rglob("*") if p.is_file()] == ["rose-pine-moon.json"] +assert [p.relative_to(root / "home/.pi/agent/extensions").as_posix() for p in (root / "home/.pi/agent/extensions").rglob("*") if p.is_file()] == ["terminal-status-title.js"] +PY + +# Build only the Home Manager activation package. This never activates the captain's configuration. +activation=$(nix build --no-link --print-out-paths \ + .#darwinConfigurations.mac.config.home-manager.users.kunchen.home.activationPackage) + +probe=$(mktemp -d) +trap 'rm -rf "$probe"' EXIT +fake_home="$probe/home" +mkdir -p "$fake_home/.pi/agent/themes" "$fake_home/.pi/agent/extensions" +legacy_tree="$probe/home-manager-files" +mkdir -p "$legacy_tree/.pi/agent/themes" "$legacy_tree/.pi/agent/extensions" +ln -s "$repo_root/home/.pi/agent/themes/rose-pine-moon.json" \ + "$legacy_tree/.pi/agent/themes/rose-pine-moon.json" +ln -s "$repo_root/home/.pi/agent/extensions/terminal-status-title.js" \ + "$legacy_tree/.pi/agent/extensions/terminal-status-title.js" +legacy_home_manager_files=$(nix store add-path "$legacy_tree") +ln -s "$legacy_home_manager_files/.pi/agent/themes/rose-pine-moon.json" \ + "$fake_home/.pi/agent/themes/rose-pine-moon.json" +ln -s "$legacy_home_manager_files/.pi/agent/extensions/terminal-status-title.js" \ + "$fake_home/.pi/agent/extensions/terminal-status-title.js" + +test "$(readlink -f "$fake_home/.pi/agent/themes/rose-pine-moon.json")" = \ + "$repo_root/home/.pi/agent/themes/rose-pine-moon.json" + +# Execute only the generated pre-check migration block against a disposable HOME. +awk ' + /_iNote "Activating %s" "migratePiAuthoredDirectories"/ { enabled = 1; next } + /_iNote "Activating %s" "checkLinkTargets"/ { exit } + enabled { print } +' "$activation/activate" | \ + sed "s|/Users/kunchen/.dotfiles|$repo_root|g" > "$probe/migrate.sh" +HOME="$fake_home" DRY_RUN_CMD='' bash -e "$probe/migrate.sh" + +test ! -e "$fake_home/.pi/agent/themes" +test ! -e "$fake_home/.pi/agent/extensions" +ln -s "$repo_root/home/.pi/agent/themes" "$fake_home/.pi/agent/themes" +ln -s "$repo_root/home/.pi/agent/extensions" "$fake_home/.pi/agent/extensions" +test -L "$fake_home/.pi/agent/themes" +test -L "$fake_home/.pi/agent/extensions" + +# Safe skip paths must succeed and leave unrelated user state untouched. +mkdir -p "$probe/unmanaged/.pi/agent/themes" "$probe/unmanaged/.pi/agent/extensions" +touch "$probe/unmanaged/.pi/agent/themes/user-theme.json" +ln -s "$repo_root/home/.pi/agent/extensions/terminal-status-title.js" \ + "$probe/unmanaged/.pi/agent/extensions/user-extension.js" +HOME="$probe/unmanaged" DRY_RUN_CMD='' bash -e "$probe/migrate.sh" +test -f "$probe/unmanaged/.pi/agent/themes/user-theme.json" +test -L "$probe/unmanaged/.pi/agent/extensions/user-extension.js" + +echo "Pi package declarations, runtime boundary, link shape, and child-to-parent migration passed." From 8e8d0453adcceb5787a7d3fcab55faac972613a2 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:21:00 -0700 Subject: [PATCH 3/8] Remove Pi directory migration (#28) --- home.nix | 26 +---------- tests/pi-home-manager.sh | 99 ---------------------------------------- 2 files changed, 1 insertion(+), 124 deletions(-) delete mode 100755 tests/pi-home-manager.sh diff --git a/home.nix b/home.nix index 3f7753fe..540dc972 100644 --- a/home.nix +++ b/home.nix @@ -1,4 +1,4 @@ -{ config, lib, pkgs, user, ... }: +{ config, pkgs, user, ... }: let dotfiles = "${config.home.homeDirectory}/.dotfiles"; @@ -73,30 +73,6 @@ in home.file.".pi/agent/settings.json".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.pi/agent/settings.json"; - # Remove only the two legacy managed child links before Home Manager adopts their directories. - home.activation.migratePiAuthoredDirectories = lib.hm.dag.entryBefore [ "checkLinkTargets" ] '' - removeLegacyPiLink() { - local target="$1" - local source="$2" - local relativeTarget="''${target#"$HOME/"}" - local linkTarget - - [ -L "$target" ] || return 0 - linkTarget="$(readlink "$target")" - case "$linkTarget" in - /nix/store/*-home-manager-files/"$relativeTarget") ;; - *) return 0 ;; - esac - [ "$(readlink -f "$target")" = "$(readlink -f "$source")" ] || return 0 - $DRY_RUN_CMD rm "$target" - } - - removeLegacyPiLink "$HOME/.pi/agent/themes/rose-pine-moon.json" "${dotfiles}/home/.pi/agent/themes/rose-pine-moon.json" - removeLegacyPiLink "$HOME/.pi/agent/extensions/terminal-status-title.js" "${dotfiles}/home/.pi/agent/extensions/terminal-status-title.js" - $DRY_RUN_CMD rmdir "$HOME/.pi/agent/themes" 2>/dev/null || true - $DRY_RUN_CMD rmdir "$HOME/.pi/agent/extensions" 2>/dev/null || true - ''; - home.file.".claude/CLAUDE.md".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md"; home.file.".codex/AGENTS.md".source = diff --git a/tests/pi-home-manager.sh b/tests/pi-home-manager.sh deleted file mode 100755 index 192eaed6..00000000 --- a/tests/pi-home-manager.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) - -python3 - "$repo_root" <<'PY' -import json -import sys -from pathlib import Path - -root = Path(sys.argv[1]) -settings = json.loads((root / "home/.pi/agent/settings.json").read_text()) -expected_packages = [ - "npm:@ryan_nookpi/pi-extension-codex-fast-mode@0.2.6", - "git:github.com/algal/pi-openai-server-compaction@c6d593087709e9481223dc6c6c2269b371b5e055", -] -assert settings.get("packages") == expected_packages, "Pi package declarations must be exactly the two audited pins" - -home_nix = (root / "home.nix").read_text() -required_links = { - '.pi/agent/themes': '${dotfiles}/home/.pi/agent/themes', - '.pi/agent/extensions': '${dotfiles}/home/.pi/agent/extensions', - '.pi/agent/models.json': '${dotfiles}/home/.pi/agent/models.json', - '.pi/agent/settings.json': '${dotfiles}/home/.pi/agent/settings.json', -} -for destination, source in required_links.items(): - declaration = f'home.file."{destination}".source =\n config.lib.file.mkOutOfStoreSymlink "{source}";' - assert declaration in home_nix, f"missing exact out-of-store link: {destination}" - -for old_child in [ - '.pi/agent/themes/rose-pine-moon.json', - '.pi/agent/extensions/terminal-status-title.js', -]: - assert f'home.file."{old_child}"' not in home_nix, f"legacy child link remains: {old_child}" - -for forbidden in [ - '.pi/agent', '.pi/agent/auth.json', '.pi/agent/sessions', '.pi/agent/trust.json', - '.pi/agent/npm', '.pi/agent/git', '.pi/agent/cache', -]: - assert f'home.file."{forbidden}"' not in home_nix, f"Pi runtime path became managed: {forbidden}" - -assert 'entryBefore [ "checkLinkTargets" ]' in home_nix, "migration must run before Home Manager collision checks" -assert 'removeLegacyPiLink "$HOME/.pi/agent/themes/rose-pine-moon.json"' in home_nix -assert 'removeLegacyPiLink "$HOME/.pi/agent/extensions/terminal-status-title.js"' in home_nix -assert (root / "home/.pi/agent/themes/rose-pine-moon.json").is_file() -assert (root / "home/.pi/agent/extensions/terminal-status-title.js").is_file() -assert [p.relative_to(root / "home/.pi/agent/themes").as_posix() for p in (root / "home/.pi/agent/themes").rglob("*") if p.is_file()] == ["rose-pine-moon.json"] -assert [p.relative_to(root / "home/.pi/agent/extensions").as_posix() for p in (root / "home/.pi/agent/extensions").rglob("*") if p.is_file()] == ["terminal-status-title.js"] -PY - -# Build only the Home Manager activation package. This never activates the captain's configuration. -activation=$(nix build --no-link --print-out-paths \ - .#darwinConfigurations.mac.config.home-manager.users.kunchen.home.activationPackage) - -probe=$(mktemp -d) -trap 'rm -rf "$probe"' EXIT -fake_home="$probe/home" -mkdir -p "$fake_home/.pi/agent/themes" "$fake_home/.pi/agent/extensions" -legacy_tree="$probe/home-manager-files" -mkdir -p "$legacy_tree/.pi/agent/themes" "$legacy_tree/.pi/agent/extensions" -ln -s "$repo_root/home/.pi/agent/themes/rose-pine-moon.json" \ - "$legacy_tree/.pi/agent/themes/rose-pine-moon.json" -ln -s "$repo_root/home/.pi/agent/extensions/terminal-status-title.js" \ - "$legacy_tree/.pi/agent/extensions/terminal-status-title.js" -legacy_home_manager_files=$(nix store add-path "$legacy_tree") -ln -s "$legacy_home_manager_files/.pi/agent/themes/rose-pine-moon.json" \ - "$fake_home/.pi/agent/themes/rose-pine-moon.json" -ln -s "$legacy_home_manager_files/.pi/agent/extensions/terminal-status-title.js" \ - "$fake_home/.pi/agent/extensions/terminal-status-title.js" - -test "$(readlink -f "$fake_home/.pi/agent/themes/rose-pine-moon.json")" = \ - "$repo_root/home/.pi/agent/themes/rose-pine-moon.json" - -# Execute only the generated pre-check migration block against a disposable HOME. -awk ' - /_iNote "Activating %s" "migratePiAuthoredDirectories"/ { enabled = 1; next } - /_iNote "Activating %s" "checkLinkTargets"/ { exit } - enabled { print } -' "$activation/activate" | \ - sed "s|/Users/kunchen/.dotfiles|$repo_root|g" > "$probe/migrate.sh" -HOME="$fake_home" DRY_RUN_CMD='' bash -e "$probe/migrate.sh" - -test ! -e "$fake_home/.pi/agent/themes" -test ! -e "$fake_home/.pi/agent/extensions" -ln -s "$repo_root/home/.pi/agent/themes" "$fake_home/.pi/agent/themes" -ln -s "$repo_root/home/.pi/agent/extensions" "$fake_home/.pi/agent/extensions" -test -L "$fake_home/.pi/agent/themes" -test -L "$fake_home/.pi/agent/extensions" - -# Safe skip paths must succeed and leave unrelated user state untouched. -mkdir -p "$probe/unmanaged/.pi/agent/themes" "$probe/unmanaged/.pi/agent/extensions" -touch "$probe/unmanaged/.pi/agent/themes/user-theme.json" -ln -s "$repo_root/home/.pi/agent/extensions/terminal-status-title.js" \ - "$probe/unmanaged/.pi/agent/extensions/user-extension.js" -HOME="$probe/unmanaged" DRY_RUN_CMD='' bash -e "$probe/migrate.sh" -test -f "$probe/unmanaged/.pi/agent/themes/user-theme.json" -test -L "$probe/unmanaged/.pi/agent/extensions/user-extension.js" - -echo "Pi package declarations, runtime boundary, link shape, and child-to-parent migration passed." From 0e8c10facbaa6c53c34873504ba8ad2c2eacd2e7 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:32:43 -0700 Subject: [PATCH 4/8] feat(pi): add standalone Calm presentation mode (#29) * feat(pi): add standalone Calm extension * no-mistakes(review): Preserve Pi tool semantics in Calm rendering * no-mistakes(review): Preserve built-in-named custom tool rendering * no-mistakes(review): Preserve SDK base-tool override rendering * no-mistakes(document): Document Pi Calm autoload and runtime boundaries --- .gitignore | 3 + README.md | 10 +- home/.pi/agent/extensions/calm/LICENSE | 21 + home/.pi/agent/extensions/calm/index.ts | 157 ++++ .../calm/lib/built-in-tool-shells.ts | 117 +++ .../extensions/calm/lib/collapsed-thinking.ts | 82 ++ .../agent/extensions/calm/lib/preference.ts | 77 ++ .../agent/extensions/calm/lib/visibility.ts | 39 + .../agent/extensions/calm/lib/working-ship.ts | 247 ++++++ tests/lib.sh | 76 ++ tests/pi-calm.test.sh | 716 ++++++++++++++++++ 11 files changed, 1544 insertions(+), 1 deletion(-) create mode 100644 home/.pi/agent/extensions/calm/LICENSE create mode 100644 home/.pi/agent/extensions/calm/index.ts create mode 100644 home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts create mode 100644 home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts create mode 100644 home/.pi/agent/extensions/calm/lib/preference.ts create mode 100644 home/.pi/agent/extensions/calm/lib/visibility.ts create mode 100644 home/.pi/agent/extensions/calm/lib/working-ship.ts create mode 100755 tests/lib.sh create mode 100755 tests/pi-calm.test.sh diff --git a/.gitignore b/.gitignore index 0a3116ff..1c3ba93a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,8 @@ result-* /home/.pi/agent/auth.json /home/.pi/agent/**/*.backup +# Pi Calm is a runtime toggle; its local state file is never tracked +/home/.pi/agent/calm + # no-mistakes local validation state - never commit in this public repo .no-mistakes/ diff --git a/README.md b/README.md index 76848059..d555cb33 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Running the switch builds: - Editor (Neovim config with the rose-pine moon theme) - Terminal (WezTerm config with the rose-pine moon theme and dimmed unfocused windows) - Agent configs (Claude, Codex, opencode all share one AGENTS.md) -- Optional Pi theme and terminal-title extension, generic UI settings and model overrides, plus two deliberately pinned third-party Pi packages +- Optional Pi theme and local extensions, generic UI settings and model overrides, plus two deliberately pinned third-party Pi packages ## Prerequisites @@ -153,6 +153,14 @@ brew install --cask kunchenguid/tap/pi-launcher Home Manager owns exactly two repository-authored Pi directories: `~/.pi/agent/themes` and `~/.pi/agent/extensions`. It also links `models.json` and `settings.json` as individual files. The local extension directory is for public, repository-authored extensions only - third-party package code never belongs there. Run `/reload` after editing a local extension or other Pi resources. The terminal-title extension shows a spinner while Pi is working, then a completion mark with the session name or current directory. The `rose-pine-moon` theme was authored clean-room from the public [Rosé Pine Moon palette](https://rosepinetheme.com/palette) and Pi's [public theme schema](https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json), not from a private or live theme file. +### Pi Calm + +`home/.pi/agent/extensions/calm` is a standalone local Pi extension. Home Manager's existing global extensions-directory link makes Pi auto-load it without another declaration. `/calm` toggles a conversation-only presentation mode and is off by default. Its choice is stored locally in `~/.pi/agent/calm` (or the directory selected by `PI_CODING_AGENT_DIR`), not in this repository or Home Manager. Adapted from Firstmate under the bundled MIT license, Calm imports no Firstmate modules and has no Firstmate runtime dependency. + +When enabled, Calm hides collapsed thinking and the call/result shells for Pi's seven built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`) without leaving blank transcript rows. During an active run it replaces Pi's working row with a two-line animated blue-water, yellow-boat widget. `/calm` restores Pi's stock rendering and preserves the existing Ctrl+O tool-expansion choice. + +Calm never changes prompts, tool execution, model context, session data, or ordering. `/share` and `/export` use the complete stock transcript. Generic custom tools, images, and unsupported Pi transcript classes deliberately remain visible because Pi has no safe general-purpose transcript filter. If a future Pi release no longer exports the exact collapsed-thinking rendering seam, Calm logs one diagnostic and leaves only that adapter disabled; all other behavior remains available. + Pi's package system declares two third-party sources in the linked global `settings.json`: - `npm:@ryan_nookpi/pi-extension-codex-fast-mode@0.2.6` - the exact public npm release from `ryan_nookpi`. diff --git a/home/.pi/agent/extensions/calm/LICENSE b/home/.pi/agent/extensions/calm/LICENSE new file mode 100644 index 00000000..5043b3a5 --- /dev/null +++ b/home/.pi/agent/extensions/calm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kun Chen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/home/.pi/agent/extensions/calm/index.ts b/home/.pi/agent/extensions/calm/index.ts new file mode 100644 index 00000000..cb4d231e --- /dev/null +++ b/home/.pi/agent/extensions/calm/index.ts @@ -0,0 +1,157 @@ +// Pi Calm - a standalone conversation-presentation toggle for Pi. +// +// Adapted from the Firstmate project's Calm implementation. +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// Verified against Pi 0.82.0, which exports its shared tool-row component, +// session_start replacement reasons, agent_start +// and agent_settled, ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), +// setWidget() with a disposable component factory, and setHiddenThinkingLabel(). +// ./lib/working-ship.ts owns the animated working presentation this file +// installs. ./lib/preference.ts owns the local state file. The collapsed-thinking +// presentation adapter probes the exact public API seam it patches and degrades +// independently with one clear diagnostic (see installCalmPresentationAdapter +// below) if a future Pi removes it. The shared tool-row adapter is limited to +// Pi's seven known built-in names, so generic custom tools and unsupported +// transcript classes deliberately stay visible. +// +// Calm changes presentation only. It never intercepts, transforms, reroutes, +// removes, or reorders semantic input, tool execution, model context, session +// storage, or export data; /export and /share render the complete stock +// transcript. +import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import { getKeybindings } from "@earendil-works/pi-tui"; +import { installCalmBuiltInToolShellLayout } from "./lib/built-in-tool-shells.ts"; +import { installCalmCollapsedThinkingLayout } from "./lib/collapsed-thinking.ts"; +import { loadCalmPreference, persistCalmPreference } from "./lib/preference.ts"; +import { + calmPresentationIsActive, + setCalmPresentation, + setCalmStockExportRendering, +} from "./lib/visibility.ts"; +import { + CALM_WORKING_SHIP_WIDGET_KEY, + createCalmWorkingShipAnimation, + createCalmWorkingShipWidget, +} from "./lib/working-ship.ts"; + +// Each presentation adapter probes the exact Pi API it patches. If a future Pi +// removes that API, only the affected adapter degrades; the rest of Calm keeps +// working. +function installCalmPresentationAdapter(name: string, install: () => void): void { + try { + install(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Pi Calm: ${name} presentation adapter unavailable, skipping. ${reason}`); + } +} + +export default function (pi: ExtensionAPI) { + installCalmPresentationAdapter("collapsed-thinking", installCalmCollapsedThinkingLayout); + installCalmPresentationAdapter("built-in-tool-shells", installCalmBuiltInToolShellLayout); + + let removeTerminalInputHandler: (() => void) | undefined; + // One logical agent run, tracked from agent_start through agent_settled rather + // than from turns or tool calls, so the boat never flickers between tool calls, + // automatic continuations, retries, or compaction that stay inside the same run. + let agentRunActive = false; + let workingShipShown = false; + // One animation instance per extension lifetime. Hiding the working widget + // freezes this state; the next working period resumes it. session_start resets + // it so a fresh Pi session starts at the normal initial position. Never + // module-global. + const workingShipAnimation = createCalmWorkingShipAnimation(); + + // Single owner of Calm's working-row presentation choice. The widget is only + // created or removed on a real transition, so repeated starts cannot duplicate + // its timer. + const applyWorkingPresentation = ( + ui: ExtensionUIContext, + forceStockVisibility = false, + ): void => { + const showShip = agentRunActive && calmPresentationIsActive(); + if (showShip !== workingShipShown) { + workingShipShown = showShip; + ui.setWidget( + CALM_WORKING_SHIP_WIDGET_KEY, + showShip + ? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation) + : undefined, + ); + ui.setWorkingVisible(!showShip); + } else if (forceStockVisibility && !showShip) { + ui.setWorkingVisible(true); + } + }; + + pi.on("session_start", (_event, ctx) => { + setCalmPresentation(loadCalmPreference()); + setCalmStockExportRendering(false); + agentRunActive = false; + workingShipShown = false; + // A genuine new session lifetime starts the boat at the normal initial position. + workingShipAnimation.reset(); + applyWorkingPresentation(ctx.ui, true); + ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined); + removeTerminalInputHandler?.(); + removeTerminalInputHandler = ctx.ui.onTerminalInput((data) => { + if (!getKeybindings().matches(data, "tui.input.submit")) return; + + const input = ctx.ui.getEditorText().trim(); + if ( + input !== "/share" && + input !== "/export" && + !input.startsWith("/export ") + ) { + return; + } + + // /export and /share render through the same tool renderers the transcript + // uses, so force stock output for the duration of the command. Session and + // export data are never filtered; this only concerns the visual components. + setCalmStockExportRendering(true); + setTimeout(() => { + setCalmStockExportRendering(false); + const expanded = ctx.ui.getToolsExpanded(); + ctx.ui.setToolsExpanded(!expanded); + ctx.ui.setToolsExpanded(expanded); + }, 0); + }); + }); + + pi.on("agent_start", (_event, ctx) => { + agentRunActive = true; + applyWorkingPresentation(ctx.ui); + }); + + // agent_settled is emitted from a finally block, so it also covers abort and failure. + pi.on("agent_settled", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + + pi.on("session_shutdown", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + + pi.registerCommand("calm", { + description: "Toggle Calm: hide collapsed thinking and built-in tool shells from the transcript (presentation only).", + handler: async (_args, ctx) => { + const active = !calmPresentationIsActive(); + // Persist first: if the state file cannot be written, the toggle fails + // with a clear error instead of silently reverting on the next restart. + persistCalmPreference(active); + setCalmPresentation(active); + applyWorkingPresentation(ctx.ui, true); + ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); + + // Flip expansion twice to force a transcript redraw while preserving the + // user's exact Ctrl+O tools-expanded state. + const expanded = ctx.ui.getToolsExpanded(); + ctx.ui.setToolsExpanded(!expanded); + ctx.ui.setToolsExpanded(expanded); + }, + }); +} diff --git a/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts new file mode 100644 index 00000000..7b060e32 --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts @@ -0,0 +1,117 @@ +// Pi Calm - gapless built-in tool-shell presentation adapter. +// +// Adapted from the Firstmate project's Calm implementation. +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// Verified against Pi 0.82.0, which exports AgentSession and +// ToolExecutionComponent. The source-aware lookup returns Pi's active definition +// unchanged and the adapter changes only its final TUI row layout. Execution, +// settings, SDK overrides, extension collisions, and stored results remain +// owned by Pi. Image results remain visible without their call/result shell, +// and custom tools or tools outside Pi's seven built-ins render unchanged. +import { + AgentSession, + ToolExecutionComponent, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import type { Component } from "@earendil-works/pi-tui"; +import { calmHidesTranscriptChrome } from "./visibility.ts"; + +const CALM_BUILT_IN_TOOL_NAMES = new Set([ + "read", + "bash", + "edit", + "write", + "grep", + "find", + "ls", +]); + +type ToolRowPresentationState = { + toolName: string; + toolDefinition?: ToolDefinition; + imageComponents: Component[]; + imageSpacers: Component[]; +}; + +type AgentSessionPresentationState = { + _baseToolsOverride?: Record; +}; + +type CalmBuiltInToolShellPatch = { + hidesShell: () => boolean; + builtInDefinitions: WeakSet; +}; + +const CALM_BUILT_IN_TOOL_SHELL_PATCH = Symbol.for( + "pi-calm:built-in-tool-shell-layout:pi-0.82.0", +); + +export function installCalmBuiltInToolShellLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmBuiltInToolShellPatch | undefined; + }; + const hidesShell = (): boolean => calmHidesTranscriptChrome(); + const installed = registry[CALM_BUILT_IN_TOOL_SHELL_PATCH]; + if (installed?.builtInDefinitions) { + installed.hidesShell = hidesShell; + return; + } + + const originalGetToolDefinition = AgentSession.prototype.getToolDefinition; + if (typeof originalGetToolDefinition !== "function") { + throw new Error("Pi Calm requires Pi AgentSession.getToolDefinition"); + } + if (typeof ToolExecutionComponent !== "function") { + throw new Error("Pi Calm requires Pi ToolExecutionComponent"); + } + const originalRender = ToolExecutionComponent.prototype.render; + if (typeof originalRender !== "function") { + throw new Error("Pi Calm requires Pi ToolExecutionComponent.render"); + } + + if (installed) installed.hidesShell = () => false; + + const patch: CalmBuiltInToolShellPatch = { + hidesShell, + builtInDefinitions: new WeakSet(), + }; + AgentSession.prototype.getToolDefinition = function ( + name: string, + ): ToolDefinition | undefined { + const definition = originalGetToolDefinition.call(this, name); + const source = this.getAllTools().find((tool) => tool.name === name)?.sourceInfo.source; + if (definition) { + const session = this as unknown as AgentSessionPresentationState; + const isSdkBaseOverride = Object.hasOwn(session._baseToolsOverride ?? {}, name); + if (source === "builtin" && !isSdkBaseOverride) { + patch.builtInDefinitions.add(definition); + } else { + patch.builtInDefinitions.delete(definition); + } + } + return definition; + }; + + ToolExecutionComponent.prototype.render = function (width: number): string[] { + const state = this as unknown as ToolRowPresentationState; + const isKnownBuiltIn = + CALM_BUILT_IN_TOOL_NAMES.has(state.toolName) && + state.toolDefinition !== undefined && + patch.builtInDefinitions.has(state.toolDefinition); + if (!isKnownBuiltIn || !patch.hidesShell()) { + return originalRender.call(this, width); + } + + const lines: string[] = []; + for (let index = 0; index < state.imageComponents.length; index += 1) { + const spacer = state.imageSpacers[index]; + if (spacer) lines.push(...spacer.render(width)); + const image = state.imageComponents[index]; + if (image) lines.push(...image.render(width)); + } + return lines; + }; + + registry[CALM_BUILT_IN_TOOL_SHELL_PATCH] = patch; +} diff --git a/home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts b/home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts new file mode 100644 index 00000000..6e7c157a --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts @@ -0,0 +1,82 @@ +// Pi Calm - gapless collapsed-thinking presentation adapter. +// +// Adapted from the Firstmate project's Calm implementation. +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// Verified against Pi 0.82.0, which exports AssistantMessageComponent with an +// updateContent method. installCalmCollapsedThinkingLayout() probes that exact +// public seam and throws if it is missing; index.ts catches that and skips only +// this adapter with one clear diagnostic instead of blocking Calm or Pi. +// +// How it works: Pi renders a hidden thinking block as one static label row. +// Calm sets that label to the empty string and this adapter filters thinking +// blocks out of the message handed to the stock renderer, so a collapsed +// thinking block occupies zero rows instead of one blank one. The unfiltered +// message is kept on lastMessage so expanding thinking (Ctrl+T) and turning +// Calm off both restore the original reasoning content byte-for-byte. Only +// collapsed thinking is affected: expanded reasoning, assistant text, and tool +// calls render exactly as Pi renders them. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; +import { calmHidesTranscriptChrome } from "./visibility.ts"; + +type AssistantMessage = Parameters[0]; + +type AssistantMessagePresentationState = { + hiddenThinkingLabel: string; + hideThinkingBlock: boolean; + lastMessage?: AssistantMessage; +}; + +type CalmCollapsedThinkingPatch = { + hidesThinking: () => boolean; +}; + +// Keep the introduction-version symbol stable so a compatible upgrade cannot +// double-patch a live process. +const CALM_COLLAPSED_THINKING_PATCH = Symbol.for( + "pi-calm:collapsed-thinking-layout:pi-0.82.0", +); + +export function installCalmCollapsedThinkingLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmCollapsedThinkingPatch | undefined; + }; + const hidesThinking = (): boolean => calmHidesTranscriptChrome(); + const installed = registry[CALM_COLLAPSED_THINKING_PATCH]; + if (installed) { + installed.hidesThinking = hidesThinking; + return; + } + + const patch: CalmCollapsedThinkingPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Pi Calm requires Pi AssistantMessageComponent"); + } + const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; + if (typeof originalUpdateContent !== "function") { + throw new Error("Pi Calm requires Pi AssistantMessageComponent.updateContent"); + } + + AssistantMessageComponent.prototype.updateContent = function ( + message: AssistantMessage, + ): void { + const state = this as unknown as AssistantMessagePresentationState; + const hideThinking = + state.hiddenThinkingLabel === "" && + state.hideThinkingBlock && + patch.hidesThinking(); + const presentationMessage = hideThinking + ? { + ...message, + content: message.content.filter((block) => block.type !== "thinking"), + } + : message; + + originalUpdateContent.call(this, presentationMessage); + if (presentationMessage !== message) state.lastMessage = message; + }; + + registry[CALM_COLLAPSED_THINKING_PATCH] = patch; +} diff --git a/home/.pi/agent/extensions/calm/lib/preference.ts b/home/.pi/agent/extensions/calm/lib/preference.ts new file mode 100644 index 00000000..153d2c8c --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/preference.ts @@ -0,0 +1,77 @@ +// Pi Calm - persisted on/off preference. +// +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// The preference lives in a plain local state file named "calm" directly under +// Pi's agent directory (~/.pi/agent by default, PI_CODING_AGENT_DIR when set). +// That directory is Pi runtime territory: this repository never tracks the +// state file and Home Manager never manages it. The file contains exactly +// "on\n" or "off\n"; anything else, including a missing or unreadable file, +// means off. + +import { randomUUID } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; + +export const CALM_PREFERENCE_FILE_NAME = "calm"; + +/** + * Resolve Pi's agent directory through Pi's exported getAgentDir(), which + * honors PI_CODING_AGENT_DIR and tilde expansion. If a future Pi stops + * exporting it, fall back to the documented environment variable and default + * path instead of failing. + */ +export function calmAgentDir(): string { + if (typeof PiCodingAgent.getAgentDir === "function") return PiCodingAgent.getAgentDir(); + const envDir = process.env.PI_CODING_AGENT_DIR?.trim(); + if (envDir) return envDir; + return join(homedir(), ".pi", "agent"); +} + +export function calmPreferencePath(): string { + return join(calmAgentDir(), CALM_PREFERENCE_FILE_NAME); +} + +/** Load the persisted preference. Calm is off by default and on any read error. */ +export function loadCalmPreference(): boolean { + try { + return readFileSync(calmPreferencePath(), "utf8").trim() === "on"; + } catch { + return false; + } +} + +/** + * Persist the preference atomically (unique temp file plus rename) so a + * crashed write never leaves a truncated state file. A failure throws a clear + * error naming the path so /calm can surface it instead of silently applying + * a toggle that would not survive a restart. + */ +export function persistCalmPreference(active: boolean): void { + const path = calmPreferencePath(); + try { + mkdirSync(dirname(path), { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(temporaryPath, active ? "on\n" : "off\n", { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, path); + } finally { + rmSync(temporaryPath, { force: true }); + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Pi Calm could not persist its preference to ${path}: ${reason}`); + } +} diff --git a/home/.pi/agent/extensions/calm/lib/visibility.ts b/home/.pi/agent/extensions/calm/lib/visibility.ts new file mode 100644 index 00000000..3b13a4ea --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/visibility.ts @@ -0,0 +1,39 @@ +// Pi Calm - shared presentation state for the standalone Calm extension. +// +// Adapted from the Firstmate project's Calm implementation. +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// This module owns only the in-memory presentation flags. Presentation filtering +// must never delete or alter semantic, session, or export data, so the export +// path forces stock rendering for the duration of an /export or /share command. + +let active = false; +let stockExportRendering = false; + +/** True while Calm presentation filtering is enabled. */ +export function calmPresentationIsActive(): boolean { + return active; +} + +export function setCalmPresentation(next: boolean): void { + active = next; +} + +/** True while an /export or /share render is in flight and stock output is required. */ +export function calmStockExportRenderingIsActive(): boolean { + return stockExportRendering; +} + +export function setCalmStockExportRendering(next: boolean): void { + stockExportRendering = next; +} + +/** + * True while Calm should hide the supported transcript chrome: collapsed + * thinking labels and the known Pi built-in tool call/result shells. Genuine + * user prompts, assistant text, custom tools, and every other transcript row + * class are never filtered by this flag. + */ +export function calmHidesTranscriptChrome(): boolean { + return active && !stockExportRendering; +} diff --git a/home/.pi/agent/extensions/calm/lib/working-ship.ts b/home/.pi/agent/extensions/calm/lib/working-ship.ts new file mode 100644 index 00000000..57460a66 --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/working-ship.ts @@ -0,0 +1,247 @@ +// Pi Calm - animated working presentation. +// +// Adapted from the Firstmate project's Calm implementation. +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// Calm replaces Pi's stock working row with a tiny two-row ASCII boat while one +// logical agent run is active. This module owns only the sprite geometry, the +// bounce track, the two animation cadences, the session-scoped freeze/resume +// state, and the temporary TUI widget; ../index.ts owns when the presentation +// is installed and removed, and stays the sole caller of setWorkingVisible(). +// +// Cadence: one scheduler drives two logically independent clocks. Every tick +// advances the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th +// tick moves the boat, so the water visibly ripples several times between boat +// steps and the boat itself reads as calm. Both clocks stop together when the +// widget is disposed. Ticks, not wall-clock timestamps, drive every state +// change, so tests can seek time exactly. +// +// Continuity: one extension-owned animation instance survives hide/show within +// the same Pi process and Calm extension lifetime. Disposing the widget freezes +// column, direction, water phase, and tick cadence without advancing them for +// hidden wall time. The next working period resumes from that exact logical +// state. A fresh session or new extension lifetime calls reset() and starts at +// the normal initial position. State is never a module-level or process-global +// singleton. +// +// Verified against Pi 0.82.0, which exposes ExtensionUIContext.setWidget() with +// a component factory, per-widget dispose(), and TUI.requestRender(). Pi renders +// a widget through Component.render(width), so this module recomputes its track +// from that width on every frame instead of caching a terminal size that a +// resize would invalidate. A resize while the boat is hidden is applied on the +// first resumed frame through the same clamp path. +import type { Component, TUI } from "@earendil-works/pi-tui"; + +// The hull is symmetric and replaces waves on its row rather than adding a third row. +const HULL = "\\__/"; +// A mainsail extends aft of the mast, so it trails behind the bow relative to travel. +const SAIL_RIGHT = "<|"; +const SAIL_LEFT = "|>"; +// Centers the two-cell sail over the four-cell hull. +const SAIL_OFFSET = 1; +const HULL_WIDTH = HULL.length; +const SAIL_WIDTH = SAIL_RIGHT.length; + +// Bounded deterministic fixed-cell water phases. Every entry is exactly one column, so +// advancing the phase ripples the surface without changing visible width or row count. +const WAVE_CYCLE = ["~", "~", "-", "~"] as const; + +// Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB. +const BLUE = "\u001b[34m"; +const YELLOW = "\u001b[33m"; +// Restores the default foreground so color never bleeds into padding or later frames. +const RESET = "\u001b[39m"; + +export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship"; +/** Scheduler period. One tick advances the water by one phase. */ +export const CALM_WORKING_SHIP_TICK_MS = 220; +/** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */ +export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4; + +export type CalmWorkingShipAnimation = { + /** Render one frame that exactly fits `width`, clamping the track to it first. */ + render(width: number): string[]; + /** Advance one scheduler tick: water every tick, boat on its slower cadence. */ + tick(): void; + restoreLastRendered(): void; + /** Restore the normal initial column, direction, water phase, and cadence. */ + reset(): void; + /** + * Clamp the frozen column and direction to `width` without advancing time. + * Used when a terminal resize lands while the working presentation is hidden. + */ + clampToWidth(width: number): void; + /** Current hull column, exposed for deterministic motion assertions. */ + position(): number; + /** Current travel direction: 1 travelling right, -1 travelling left. */ + direction(): number; + /** Current water phase, exposed for deterministic ripple assertions. */ + waterPhase(): number; +}; + +/** Longest hull start column that still fits the sprite in `width` usable cells. */ +function trackSpan(width: number): number { + if (width >= HULL_WIDTH) return width - HULL_WIDTH; + if (width >= SAIL_WIDTH) return width - SAIL_WIDTH; + return 0; +} + +export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation { + let position = 0; + let direction = 1; + let span = 0; + let phase = 0; + let ticks = 0; + let renderedPosition = position; + let renderedDirection = direction; + let renderedSpan = span; + let renderedPhase = phase; + let renderedTicks = ticks; + + // Reversing the moment the boat lands on an endpoint means the endpoint frame itself + // already shows the new heading, so no frame at or after a bounce shows the old sail. + const settleDirectionAtEdges = (): void => { + if (span <= 0) return; + if (position >= span) direction = -1; + else if (position <= 0) direction = 1; + }; + + const applyWidth = (width: number): void => { + if (width <= 0) { + span = 0; + position = 0; + return; + } + span = trackSpan(width); + position = Math.min(position, span); + settleDirectionAtEdges(); + }; + + const commitRenderedState = (): void => { + renderedPosition = position; + renderedDirection = direction; + renderedSpan = span; + renderedPhase = phase; + renderedTicks = ticks; + }; + + const restoreLastRenderedState = (): void => { + position = renderedPosition; + direction = renderedDirection; + span = renderedSpan; + phase = renderedPhase; + ticks = renderedTicks; + }; + + /** One colored run of water covering absolute columns [from, from + count). */ + const water = (from: number, count: number): string => { + if (count <= 0) return ""; + let cells = ""; + for (let column = from; column < from + count; column += 1) { + cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length]; + } + return `${BLUE}${cells}${RESET}`; + }; + + const boat = (text: string): string => `${YELLOW}${text}${RESET}`; + + return { + position: () => position, + direction: () => direction, + waterPhase: () => phase, + + restoreLastRendered: restoreLastRenderedState, + + reset(): void { + position = 0; + direction = 1; + span = 0; + phase = 0; + ticks = 0; + commitRenderedState(); + }, + + clampToWidth(width: number): void { + applyWidth(width); + }, + + tick(): void { + ticks += 1; + phase = (phase + 1) % WAVE_CYCLE.length; + if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return; + if (span <= 0) { + position = 0; + return; + } + position = Math.min(span, Math.max(0, position + direction)); + settleDirectionAtEdges(); + }, + + render(width: number): string[] { + if (width <= 0) return []; + + // A resize lands here before the next frame, so recompute and clamp the track + // immediately rather than trusting a position measured against the old width. + applyWidth(width); + + const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT; + + let frame: string[]; + if (width < SAIL_WIDTH) { + // Too narrow for even the sail: a deterministic single row of water. + frame = [water(0, width)]; + } else if (width < HULL_WIDTH) { + // Too narrow for the hull: the sail alone rides the water row. + frame = [ + water(0, position) + + boat(sail) + + water(position + SAIL_WIDTH, width - position - SAIL_WIDTH), + ]; + } else { + frame = [ + " ".repeat(position + SAIL_OFFSET) + boat(sail), + water(0, position) + + boat(HULL) + + water(position + HULL_WIDTH, width - position - HULL_WIDTH), + ]; + } + + commitRenderedState(); + return frame; + }, + }; +} + +/** + * Build the temporary Calm working widget bound to one caller-owned animation. + * Pi disposes the previous component before installing a replacement under the same + * key and when it clears extension widgets, so the single scheduler driving both + * cadences cannot outlive the widget or duplicate. Disposing freezes the shared + * animation in place; the next widget bound to the same animation resumes without + * applying hidden wall time. + */ +export function createCalmWorkingShipWidget( + tui: TUI, + animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(), +): Component & { dispose(): void } { + let disposed = false; + const timer = setInterval(() => { + if (disposed) return; + animation.tick(); + tui.requestRender(); + }, CALM_WORKING_SHIP_TICK_MS); + // The animation must never keep Pi's process alive on its own. + timer.unref?.(); + + return { + render: (width) => (disposed ? [] : animation.render(width)), + // Every frame is rebuilt from fixed standard ANSI codes, so there is no cache. + invalidate: () => {}, + dispose: () => { + if (disposed) return; + disposed = true; + clearInterval(timer); + animation.restoreLastRendered(); + }, + }; +} diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100755 index 00000000..d436adec --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# tests/lib.sh - shared primitives for dotfiles behavior tests. +# +# Source this from a test file: +# # shellcheck source=tests/lib.sh +# . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# +# ROOT is exported as the repository root (this file lives in tests/). + +if [ -n "${DOTFILES_TEST_LIB_SOURCED:-}" ]; then + return 0 +fi +DOTFILES_TEST_LIB_SOURCED=1 + +# shellcheck disable=SC2034 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +# --- self-cleaning temp root ------------------------------------------------- + +DOTFILES_TEST_CLEANUP_DIRS=() + +dotfiles_test_cleanup() { + local d + for d in "${DOTFILES_TEST_CLEANUP_DIRS[@]:-}"; do + [ -n "$d" ] && rm -rf "$d" + done +} + +dotfiles_test_tmproot() { + local prefix=${1:-dotfiles-test} root + root=$(mktemp -d "${TMPDIR:-/tmp}/${prefix}.XXXXXX") + if [ "${#DOTFILES_TEST_CLEANUP_DIRS[@]}" -eq 0 ]; then + trap dotfiles_test_cleanup EXIT + fi + DOTFILES_TEST_CLEANUP_DIRS+=("$root") + printf '%s\n' "$root" +} + +# --- assertions --------------------------------------------------------------- + +assert_contains() { + local haystack=$1 needle=$2 message=$3 + case "$haystack" in + *"$needle"*) : ;; + *) fail "$message" ;; + esac +} + +assert_not_contains() { + local haystack=$1 needle=$2 message=$3 + case "$haystack" in + *"$needle"*) fail "$message" ;; + *) : ;; + esac +} + +# --- deterministic git fixtures ------------------------------------------------ + +dotfiles_git_init_commit() { + local dir=$1 + mkdir -p "$dir" + git -C "$dir" init -q + printf '# %s\n' "$(basename "$dir")" > "$dir/README.md" + git -C "$dir" add README.md + git -C "$dir" -c user.name=dotfiles-test -c user.email=dotfiles-test@example.invalid \ + commit -qm "fixture" +} diff --git a/tests/pi-calm.test.sh b/tests/pi-calm.test.sh new file mode 100755 index 00000000..e5de6246 --- /dev/null +++ b/tests/pi-calm.test.sh @@ -0,0 +1,716 @@ +#!/usr/bin/env bash +# Deterministic rendering, lifecycle, persistence, and interactive TUI checks +# for the standalone Pi Calm extension (home/.pi/agent/extensions/calm). +# +# Adapted from the Firstmate project's Calm test suite. +# Copyright (c) 2026 Kun Chen. MIT License - see home/.pi/agent/extensions/calm/LICENSE. +# +# Coverage: +# - zero coupling: no forbidden identifiers anywhere in the shipped source, +# tests, or docs, and the runtime state file is never tracked or managed; +# - static wiring: Home Manager auto-load, TypeScript typecheck, JS syntax; +# - preference: off by default, persisted toggle, malformed/unwritable state; +# - filtering: the seven built-in tool shells hide gaplessly while custom tools +# and unsupported transcript classes stay visible, /export and /share render +# stock HTML, session data is untouched; +# - collapsed thinking: gapless hiding, expansion restore, isolated adapter +# degradation with one clear diagnostic; +# - working ship: geometry, cadence, colors, resize, narrow fallback, +# freeze/resume, timer disposal, extension lifecycle; +# - real Pi 0.82 TUI proofs in tmux without credentials or provider calls. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(dotfiles_test_tmproot pi-calm) +CALM_DIR="$ROOT/home/.pi/agent/extensions/calm" +PI_PACKAGE_DIR=${PI_CALM_TEST_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"} +TMUX_SOCKET="pi-calm-test-$$" +TMUX_SESSION="pi-calm-e2e" + +cleanup() { + if command -v tmux >/dev/null 2>&1; then + tmux -L "$TMUX_SOCKET" kill-server 2>/dev/null || true + fi + if [ "${KEEP_TMP:-}" = 1 ]; then + printf 'kept disposable Pi Calm evidence: %s\n' "$TMP_ROOT" >&2 + return + fi + dotfiles_test_cleanup +} +trap cleanup EXIT + +wait_for_text() { + local file=$1 text=$2 i=0 + while [ "$i" -lt 120 ]; do + # Include recent scrollback: expanding a long restored transcript can move + # the asserted tool output above the current viewport while the footer and + # editor remain visible. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$file" 2>/dev/null || true + grep -Fq "$text" "$file" 2>/dev/null && return 0 + sleep 0.05 + i=$((i + 1)) + done + return 1 +} + +find_chrome() { + local candidate + if [ -n "${PI_CALM_TEST_CHROME_BIN:-}" ] && [ -x "$PI_CALM_TEST_CHROME_BIN" ]; then + printf '%s\n' "$PI_CALM_TEST_CHROME_BIN" + return 0 + fi + for candidate in \ + google-chrome \ + google-chrome-stable \ + chromium \ + chromium-browser \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + return 1 +} + +# Copy the shipped extension into a fixture layout with resolvable node_modules. +# Echoes the fixture root. Requires $1 = fixture directory. +build_node_fixture() { + local fixture=$1 + mkdir -p "$fixture/calm" "$fixture/node_modules/@earendil-works" + cp -R "$CALM_DIR/index.ts" "$CALM_DIR/lib" "$fixture/calm/" + ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/package.json" +} + +have_pi_package() { + [ -f "$PI_PACKAGE_DIR/package.json" ] +} + +test_zero_coupling_and_state_file() { + local source_files license_hits file separator + source_files=$(find "$CALM_DIR" -type f | sort) + [ -n "$source_files" ] || fail "Pi Calm extension files are missing" + # U+2063 (invisible separator) without embedding the literal in this file. + separator=$(printf '\xe2\x81\xa3') + # Forbidden patterns are concatenated so this checker never matches itself. + local pat_fm_home="FM_""HOME" pat_fm_root="FM_""ROOT" pat_config="config/""calm" + local pat_watch="fm_""watch_arm_pi" pat_op="FIRSTMATE""_OP" pat_dash="fm-""calm" + + # The operational marker and upstream runtime surfaces must not exist anywhere. + for file in $source_files "$ROOT/tests/pi-calm.test.sh" "$ROOT/tests/lib.sh" "$ROOT/README.md" "$ROOT/home.nix"; do + + assert_not_contains "$(cat "$file")" "$pat_fm_home" "$file mentions $pat_fm_home" + assert_not_contains "$(cat "$file")" "$pat_fm_root" "$file mentions $pat_fm_root" + assert_not_contains "$(cat "$file")" "$pat_config" "$file mentions $pat_config" + assert_not_contains "$(cat "$file")" "$pat_watch" "$file mentions $pat_watch" + assert_not_contains "$(cat "$file")" "$pat_op" "$file mentions $pat_op" + assert_not_contains "$(cat "$file")" "$pat_dash" "$file mentions $pat_dash" + assert_not_contains "$(cat "$file")" "$separator" "$file contains the operational separator" + done + # The upstream project name may appear only in a license attribution. + local attribution_name="First""mate" + license_hits=$(grep -rni "$attribution_name" "$CALM_DIR" "$ROOT/README.md" "$ROOT/home.nix" 2>/dev/null | grep -v "Adapted from" || true) + [ -z "$license_hits" ] || fail "unexpected upstream references outside license attribution: $license_hits" + grep -q "MIT License" "$CALM_DIR/LICENSE" || fail "calm LICENSE lost the MIT permission text" + grep -q "Copyright (c) 2026 Kun Chen" "$CALM_DIR/LICENSE" || fail "calm LICENSE lost the copyright notice" + for file in "$CALM_DIR/index.ts" "$CALM_DIR/lib/visibility.ts" "$CALM_DIR/lib/preference.ts" "$CALM_DIR/lib/collapsed-thinking.ts" "$CALM_DIR/lib/working-ship.ts"; do + grep -q "Copyright (c) 2026 Kun Chen" "$file" || fail "$file lost its copyright attribution header" + done + + # The runtime preference file must never be tracked or Home Manager managed. + if git -C "$ROOT" ls-files --error-unmatch home/.pi/agent/calm >/dev/null 2>&1; then + fail "the Calm state file is tracked in the repository" + fi + assert_not_contains "$(cat "$ROOT/home.nix")" '.pi/agent/calm' "home.nix manages the Calm state file" + grep -q '^/home/.pi/agent/calm$' "$ROOT/.gitignore" \ + || fail ".gitignore does not guard /home/.pi/agent/calm" + + # The shipped tree never references upstream paths or identifiers in code. + assert_not_contains "$(cat "$CALM_DIR/index.ts")" "pi.events" "index.ts emits on a shared event bus" + assert_not_contains "$(cat "$CALM_DIR/index.ts")" "registerEntryRenderer" "index.ts registers a synthetic entry renderer" + assert_not_contains "$(cat "$CALM_DIR/index.ts")" "InteractiveMode" "index.ts patches user-row layout" + + pass "zero coupling: no forbidden identifiers, attribution limited to license headers, state file untracked and unmanaged" +} + +test_static_typescript_and_repo_wiring() { + # Home Manager links the extensions directory as a whole, so the calm + # subdirectory auto-loads without any new declaration. + grep -q 'home.file.".pi/agent/extensions".source =' "$ROOT/home.nix" \ + || fail "home.nix no longer links ~/.pi/agent/extensions as a directory" + grep -q "mkOutOfStoreSymlink \"\${dotfiles}/home/.pi/agent/extensions\"" "$ROOT/home.nix" \ + || fail "home.nix changed the Pi extensions link target" + [ -f "$CALM_DIR/index.ts" ] || fail "calm extension entry point missing" + [ -f "$CALM_DIR/LICENSE" ] || fail "calm license file missing" + + # JavaScript syntax of the pre-existing extension stays valid. + node --check "$ROOT/home/.pi/agent/extensions/terminal-status-title.js" \ + || fail "terminal-status-title.js has a JavaScript syntax error" + + if ! have_pi_package; then + echo "skip: installed @earendil-works/pi-coding-agent package not found for TypeScript check" + elif ! command -v tsc >/dev/null 2>&1; then + echo "skip: tsc not found for TypeScript check" + else + local fixture="$TMP_ROOT/typecheck" + build_node_fixture "$fixture" + mkdir -p "$fixture/node_modules/@types" + ln -s "$PI_PACKAGE_DIR/node_modules/@types/node" "$fixture/node_modules/@types/node" + cat >"$fixture/tsconfig.json" <<'JSON' +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["esnext"], + "types": ["node"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["calm/**/*.ts"] +} +JSON + (cd "$fixture" && tsc -p tsconfig.json) \ + || fail "Pi Calm extension does not typecheck under strict TypeScript" + fi + + pass "static wiring: Home Manager auto-load intact, TypeScript typechecks, existing JS extension parses" +} + +test_preference_and_command() { + local fixture out status + if ! command -v node >/dev/null 2>&1; then + echo "skip: node not found for Pi Calm preference test" + return 0 + fi + if ! have_pi_package; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + + fixture="$TMP_ROOT/preference" + mkdir -p "$fixture/agent" "$fixture/readonly-agent" + build_node_fixture "$fixture" + + out=$(cd "$fixture" && \ + EXT="$fixture/calm/index.ts" \ + AGENT_DIR="$fixture/agent" \ + READONLY_AGENT_DIR="$fixture/readonly-agent" \ + node --input-type=module 2>&1 <<'JS' +import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +process.env.PI_CODING_AGENT_DIR = process.env.AGENT_DIR; +const extension = await import(`${pathToFileURL(process.env.EXT).href}?pref=${Date.now()}`); +const preference = await import(`${pathToFileURL(`${process.cwd()}/calm/lib/preference.ts`).href}?pref=${Date.now()}`); +const visibility = await import(pathToFileURL(`${process.cwd()}/calm/lib/visibility.ts`).href); + +const check = (condition, message) => { + if (!condition) throw new Error(message); +}; + +check( + preference.calmPreferencePath() === join(process.env.AGENT_DIR, "calm"), + `preference path did not follow PI_CODING_AGENT_DIR: ${preference.calmPreferencePath()}`, +); + +let calmCommand; +const handlers = new Map(); +const pi = { + on(event, handler) { + const existing = handlers.get(event) ?? []; + existing.push(handler); + handlers.set(event, existing); + }, + registerCommand(name, command) { + if (name === "calm") calmCommand = command; + }, + registerTool() {}, +}; +extension.default(pi); +check(!!calmCommand, "Calm command was not registered"); +for (const event of ["session_start", "agent_start", "agent_settled", "session_shutdown"]) { + check(handlers.has(event), `Calm did not register a ${event} handler`); +} +check(!handlers.has("input"), "Calm registered a semantic input interceptor"); +check(!handlers.has("tool_call"), "Calm registered a tool-call interceptor"); +check(!handlers.has("tool_result"), "Calm registered a tool-result interceptor"); +check(!handlers.has("context"), "Calm registered a model-context interceptor"); + +let editorText = ""; +let expanded = false; +let hiddenThinkingLabel = "unset"; +const uiCalls = []; +const ctx = { + ui: { + getEditorText: () => editorText, + getToolsExpanded: () => expanded, + setToolsExpanded(value) { + uiCalls.push(["setToolsExpanded", value]); + expanded = value; + }, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel(value) { + hiddenThinkingLabel = value; + }, + setWidget() {}, + setWorkingVisible() {}, + }, +}; +const fire = async (event, payload = {}) => { + for (const handler of handlers.get(event) ?? []) await handler(payload, ctx); +}; + +// Off by default: no state file means inactive and nothing is written. +await fire("session_start", { reason: "startup" }); +check(!visibility.calmPresentationIsActive(), "Calm was not off by default"); +check(!existsSync(`${process.env.AGENT_DIR}/calm`), "session start created the state file without a toggle"); +check(hiddenThinkingLabel === undefined, "default session did not keep the stock thinking label"); + +// Malformed state content also means off, and is left untouched until a toggle. +writeFileSync(`${process.env.AGENT_DIR}/calm`, "sometimes\n"); +await fire("session_start", { reason: "reload" }); +check(!visibility.calmPresentationIsActive(), "malformed state did not fall back to off"); +check(readFileSync(`${process.env.AGENT_DIR}/calm`, "utf8") === "sometimes\n", "loading a malformed state rewrote it"); + +// Toggle on: persists "on\n" with owner-only permissions and hides the label. +expanded = true; +await calmCommand.handler("", ctx); +check(visibility.calmPresentationIsActive(), "toggle did not activate Calm"); +check(readFileSync(`${process.env.AGENT_DIR}/calm`, "utf8") === "on\n", "toggle did not persist on"); +check((statSync(`${process.env.AGENT_DIR}/calm`).mode & 0o777) === 0o600, "state file is not owner-only"); +check(hiddenThinkingLabel === "", "Calm did not hide the collapsed thinking label"); +check(expanded === true, "toggle changed the Ctrl+O tools-expanded state"); + +// The choice survives new sessions and replacement reasons. +for (const reason of ["startup", "new", "resume", "fork", "reload"]) { + await fire("session_start", { reason }); + check(visibility.calmPresentationIsActive(), `${reason} session lost the persisted on choice`); + check(hiddenThinkingLabel === "", `${reason} session lost the hidden thinking label`); +} + +// Toggle off again: persists "off\n" and restores the stock label. +await calmCommand.handler("", ctx); +check(!visibility.calmPresentationIsActive(), "second toggle did not deactivate Calm"); +check(readFileSync(`${process.env.AGENT_DIR}/calm`, "utf8") === "off\n", "second toggle did not persist off"); +check(hiddenThinkingLabel === undefined, "turning Calm off did not restore the stock thinking label"); +await fire("session_start", { reason: "startup" }); +check(!visibility.calmPresentationIsActive(), "restart did not retain the persisted off choice"); + +// An unwritable state file fails the toggle with a clear error and leaves the +// in-memory state untouched, so the failure is loud instead of silently +// reverting on the next restart. +process.env.PI_CODING_AGENT_DIR = process.env.READONLY_AGENT_DIR; +const readonlyPreferencePath = preference.calmPreferencePath(); +chmodSync(process.env.READONLY_AGENT_DIR, 0o555); +let thrown = ""; +try { + await calmCommand.handler("", ctx); +} catch (error) { + thrown = error instanceof Error ? error.message : String(error); +} +chmodSync(process.env.READONLY_AGENT_DIR, 0o755); +check(thrown.length > 0, "an unwritable state file did not fail the toggle"); +check( + thrown.includes(readonlyPreferencePath), + `toggle error did not name the state path: ${thrown}`, +); +check(!visibility.calmPresentationIsActive(), "a failed persist changed the in-memory state"); +check(!existsSync(readonlyPreferencePath), "a failed persist left a state file"); +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm preference and command contract failed: $out" + [ -z "$out" ] || fail "Pi Calm preference test printed output: $out" + pass "Pi Calm registers /calm with no semantic interceptors, defaults to off, persists the toggle with atomic owner-only writes, treats malformed state as off, and fails loudly without state changes on an unwritable preference file" +} + +test_rendering_adapters_and_tool_shells() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! have_pi_package; then + echo "skip: node or installed Pi package not found for rendering contract" + return 0 + fi + + fixture="$TMP_ROOT/rendering" + mkdir -p "$fixture/agent" + build_node_fixture "$fixture" + out=$(cd "$fixture" && EXT="$fixture/calm/index.ts" AGENT_DIR="$fixture/agent" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +process.env.PI_CODING_AGENT_DIR = process.env.AGENT_DIR; +const root = process.env.PI_PACKAGE_DIR; +const [{ AgentSession, AssistantMessageComponent, ToolExecutionComponent, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition }, { initTheme }, { Text }] = await Promise.all([ + import("@earendil-works/pi-coding-agent"), + import(pathToFileURL(`${root}/dist/modes/interactive/theme/theme.js`).href), + import("@earendil-works/pi-tui"), +]); +initTheme("dark"); +const extension = await import(pathToFileURL(process.env.EXT).href); +const visibility = await import(pathToFileURL(`${process.cwd()}/calm/lib/visibility.ts`).href); +const check = (condition, message) => { if (!condition) throw new Error(message); }; + +const tools = []; +const handlers = new Map(); +let command; +const pi = { + on(event, handler) { const list = handlers.get(event) ?? []; list.push(handler); handlers.set(event, list); }, + registerCommand(name, value) { if (name === "calm") command = value; }, + registerTool(tool) { tools.push(tool); }, +}; +extension.default(pi); +check(tools.length === 0, "Calm replaced active tool definitions"); +const ui = { + getEditorText: () => "", + getToolsExpanded: () => false, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel() {}, + setToolsExpanded() {}, + setWidget() {}, + setWorkingVisible() {}, +}; +const ctx = { ui }; +for (const handler of handlers.get("session_start")) await handler({ reason: "startup" }, ctx); + +const assistant = new AssistantMessageComponent({ + role: "assistant", api: "test", provider: "test", model: "test", timestamp: 1, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + content: [{ type: "thinking", thinking: "PRIVATE_REASONING" }, { type: "text", text: "VISIBLE_ASSISTANT_TEXT" }], +}, true, undefined, "Thinking..."); +check(assistant.render(100).join("\\n").includes("Thinking..."), "Calm changed collapsed thinking while off"); +await command.handler("", ctx); +check(visibility.calmPresentationIsActive(), "Calm did not activate for rendering coverage"); +assistant.setHiddenThinkingLabel(""); +const hiddenAssistant = assistant.render(100).join("\\n"); +check(!hiddenAssistant.includes("PRIVATE_REASONING") && hiddenAssistant.includes("VISIBLE_ASSISTANT_TEXT"), "collapsed thinking was not removed gaplessly while assistant text stayed visible"); +assistant.setHideThinkingBlock(false); +check(assistant.render(100).join("\\n").includes("PRIVATE_REASONING"), "expanding thinking did not restore its original content"); +assistant.setHideThinkingBlock(true); + +const renderUi = { requestRender() {} }; +const builtInFactories = [createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition]; +for (const factory of builtInFactories) { + const definition = factory(process.cwd()); + const toolName = definition.name; + const session = Object.create(AgentSession.prototype); + session._toolDefinitions = new Map([[toolName, { definition, sourceInfo: { source: "builtin" } }]]); + check(session.getToolDefinition(toolName) === definition, `${toolName} definition lookup changed identity`); + const row = new ToolExecutionComponent(toolName, `call-${toolName}`, {}, { showImages: false }, definition, renderUi, process.cwd()); + row.markExecutionStarted(); + row.setArgsComplete(); + row.updateResult({ content: [{ type: "text", text: `RESULT_${toolName}` }], details: {}, isError: false }); + check(row.render(100).length === 0, `${toolName} left a supported call/result shell visible`); + visibility.setCalmStockExportRendering(true); + row.setExpanded(true); + check(row.render(100).length > 0, `${toolName} was missing from stock export/share rendering`); + visibility.setCalmStockExportRendering(false); +} +const custom = { + name: "custom_boundary", label: "Custom boundary", description: "test", parameters: { type: "object", properties: {} }, renderShell: "self", + async execute() { return { content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {} }; }, + renderCall() { return new Text("CUSTOM_CALL", 0, 0); }, + renderResult() { return new Text("CUSTOM_RESULT", 0, 0); }, +}; +const customRow = new ToolExecutionComponent("custom_boundary", "custom-call", {}, { showImages: false }, custom, renderUi, process.cwd()); +customRow.markExecutionStarted(); customRow.setArgsComplete(); customRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); +check(customRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid a generic custom tool"); +const collidingCustom = { ...custom, name: "read" }; +const customSession = Object.create(AgentSession.prototype); +customSession._toolDefinitions = new Map([["read", { definition: collidingCustom, sourceInfo: { source: "sdk" } }]]); +check(customSession.getToolDefinition("read") === collidingCustom, "custom collision definition lookup changed identity"); +const collidingRow = new ToolExecutionComponent("read", "custom-read-call", {}, { showImages: false }, collidingCustom, renderUi, process.cwd()); +collidingRow.markExecutionStarted(); collidingRow.setArgsComplete(); collidingRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); +check(collidingRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid a built-in-named custom tool"); +const baseOverrideCustom = { ...custom, name: "bash" }; +const baseOverrideSession = Object.create(AgentSession.prototype); +baseOverrideSession._baseToolsOverride = { bash: baseOverrideCustom }; +baseOverrideSession._toolDefinitions = new Map([["bash", { definition: baseOverrideCustom, sourceInfo: { source: "builtin" } }]]); +check(baseOverrideSession.getToolDefinition("bash") === baseOverrideCustom, "SDK base override lookup changed identity"); +const baseOverrideRow = new ToolExecutionComponent("bash", "sdk-bash-call", {}, { showImages: false }, baseOverrideCustom, renderUi, process.cwd()); +baseOverrideRow.markExecutionStarted(); baseOverrideRow.setArgsComplete(); baseOverrideRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); +check(baseOverrideRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid an SDK base-tool override"); +const session = [{ type: "message", message: { role: "user", content: "REAL_USER_PROMPT" } }, { type: "message", message: { role: "assistant", content: "VISIBLE_ASSISTANT_TEXT" } }]; +const before = JSON.stringify(session); +check(JSON.stringify(session) === before, "presentation test changed session data"); +await command.handler("", ctx); +assistant.setHiddenThinkingLabel("Thinking..."); +check(!visibility.calmPresentationIsActive(), "Calm did not deactivate"); +check(assistant.render(100).join("\\n").includes("Thinking..."), "Calm off did not restore collapsed thinking label"); +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm rendering contract failed: $out" + [ -z "$out" ] || fail "Pi Calm rendering contract printed output: $out" + pass "Pi Calm hides only collapsed thinking and known built-in shells, retains custom content, and restores stock export/share rendering" +} + +test_working_ship_and_lifecycle() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! have_pi_package; then + echo "skip: node or installed Pi package not found for working-ship contract" + return 0 + fi + + fixture="$TMP_ROOT/working-ship" + mkdir -p "$fixture/agent" + build_node_fixture "$fixture" + out=$(cd "$fixture" && EXT="$fixture/calm/index.ts" AGENT_DIR="$fixture/agent" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +process.env.PI_CODING_AGENT_DIR = process.env.AGENT_DIR; +const root = process.env.PI_PACKAGE_DIR; +const [{ visibleWidth }, { initTheme }] = await Promise.all([ + import("@earendil-works/pi-tui"), + import(pathToFileURL(`${root}/dist/modes/interactive/theme/theme.js`).href), +]); +initTheme("dark"); +const ship = await import(pathToFileURL(`${process.cwd()}/calm/lib/working-ship.ts`).href); +const extension = await import(pathToFileURL(process.env.EXT).href); +const check = (condition, message) => { if (!condition) throw new Error(message); }; +const strip = (line) => line.replace(/\x1b\[[0-9;]*m/g, ""); +const { createCalmWorkingShipAnimation, createCalmWorkingShipWidget, CALM_WORKING_SHIP_TICK_MS, CALM_WORKING_SHIP_TICKS_PER_MOVE, CALM_WORKING_SHIP_WIDGET_KEY } = ship; +check(CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE === 880, "boat cadence changed from 880ms per column"); +const animation = createCalmWorkingShipAnimation(); +for (let width = 1; width <= 100; width += 1) { + for (let frame = 0; frame < 12; frame += 1) { + const lines = animation.render(width); + check(lines.length === (width >= 4 ? 2 : 1), `width ${width} produced wrong row count`); + for (const line of lines) check(visibleWidth(line) <= width, `width ${width} wraps`); + check(visibleWidth(lines.at(-1)) === width, `width ${width} has incomplete water`); + animation.tick(); + } +} +animation.reset(); animation.render(40); +const origin = animation.position(); +for (let tick = 0; tick < CALM_WORKING_SHIP_TICKS_PER_MOVE - 1; tick += 1) animation.tick(); +check(animation.position() === origin && animation.waterPhase() !== 0, "water did not ripple independently before boat movement"); +animation.tick(); check(animation.position() === origin + 1, "boat did not move on its slower cadence"); +while (animation.position() < 36) animation.tick(); +const shrunk = animation.render(12); +check(animation.position() === 8 && animation.direction() === -1 && visibleWidth(shrunk[1]) === 12, "wide-to-narrow resize did not clamp and reverse safely"); +const frozen = { position: animation.position(), direction: animation.direction(), phase: animation.waterPhase() }; +const tui = { requests: 0, requestRender() { this.requests += 1; } }; +const widget = createCalmWorkingShipWidget(tui, animation); +widget.render(12); widget.dispose(); +check(animation.position() === frozen.position && animation.direction() === frozen.direction && animation.waterPhase() === frozen.phase, "disposing a widget advanced frozen animation state"); +const resumed = createCalmWorkingShipWidget(tui, animation); +resumed.render(12); +check(animation.position() === frozen.position && animation.direction() === frozen.direction, "resuming changed frozen position or heading"); +resumed.dispose(); + +const tools = []; const handlers = new Map(); let command; +const pi = { on(event, handler) { const values = handlers.get(event) ?? []; values.push(handler); handlers.set(event, values); }, registerCommand(name, value) { if (name === "calm") command = value; }, registerTool(tool) { tools.push(tool); } }; +extension.default(pi); +const widgets = new Map(); const changes = []; let expanded = true; +const ui = { + getEditorText: () => "", getToolsExpanded: () => expanded, onTerminalInput: () => () => {}, setHiddenThinkingLabel() {}, + setToolsExpanded(value) { expanded = value; }, + setWorkingVisible(value) { changes.push(["working", value]); }, + setWidget(key, value) { changes.push(["widget", key, value === undefined ? "clear" : "set"]); if (value === undefined) { widgets.get(key)?.dispose?.(); widgets.delete(key); } else widgets.set(key, value(tui)); }, +}; +const ctx = { ui }; +const fire = async (event) => { for (const handler of handlers.get(event) ?? []) await handler({}, ctx); }; +await fire("session_start"); changes.length = 0; +await fire("agent_start"); await fire("agent_settled"); +check(changes.length === 0, "Calm off changed Pi's stock working row"); +await command.handler("", ctx); check(expanded, "Calm toggle changed Ctrl+O expansion state"); +await fire("agent_start"); +check(changes.some((change) => change[0] === "widget" && change[1] === CALM_WORKING_SHIP_WIDGET_KEY && change[2] === "set"), "Calm did not install its working widget"); +check(changes.some((change) => change[0] === "working" && change[1] === false), "Calm did not hide stock working row during a run"); +changes.length = 0; await fire("session_shutdown"); +check(changes.some((change) => change[0] === "widget" && change[2] === "clear"), "extension shutdown did not clear working widget"); +check(changes.some((change) => change[0] === "working" && change[1] === true), "extension shutdown did not restore stock working row"); +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm working ship and lifecycle contract failed: $out" + [ -z "$out" ] || fail "Pi Calm working ship test printed output: $out" + pass "Pi Calm working ship has fixed geometry and cadence, survives resize and freeze/resume, and restores stock lifecycle behavior" +} + +test_collapsed_thinking_degradation() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! have_pi_package; then + echo "skip: node or installed Pi package not found for adapter degradation" + return 0 + fi + + fixture="$TMP_ROOT/degradation" + build_node_fixture "$fixture" + out=$(cd "$fixture" && EXT="$fixture/calm/index.ts" node --input-type=module 2>&1 <<'JS' +import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import { pathToFileURL } from "node:url"; +const original = AssistantMessageComponent.prototype.updateContent; +const diagnostics = []; +const previousError = console.error; +console.error = (...args) => diagnostics.push(args.join(" ")); +delete AssistantMessageComponent.prototype.updateContent; +let command; +const handlers = new Map(); +try { + const extension = await import(`${pathToFileURL(process.env.EXT).href}?degraded=${Date.now()}`); + extension.default({ + on(event, handler) { handlers.set(event, handler); }, + registerCommand(name, value) { if (name === "calm") command = value; }, + registerTool() {}, + }); +} finally { + AssistantMessageComponent.prototype.updateContent = original; + console.error = previousError; +} +if (!command || !handlers.has("session_start")) throw new Error("a missing collapsed-thinking seam stopped the rest of Calm from registering"); +if (!diagnostics.some((line) => line.includes("collapsed-thinking") && /unavailable.*skipping/i.test(line))) { + throw new Error(`missing clear collapsed-thinking degradation diagnostic: ${JSON.stringify(diagnostics)}`); +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm collapsed-thinking degradation contract failed: $out" + [ -z "$out" ] || fail "Pi Calm collapsed-thinking degradation test printed output: $out" + pass "Pi Calm degrades only its exported-class collapsed-thinking adapter with a clear diagnostic" +} + +test_real_pi_tui_smoke() { + local fixture agent project socket pane i + if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then + echo "skip: pi or tmux not found for isolated real TUI smoke" + return 0 + fi + [ "$(pi --version 2>/dev/null || true)" = "0.82.0" ] \ + || fail "real Pi smoke requires the installed Pi 0.82.0 proof target" + + fixture="$TMP_ROOT/tui-smoke" + agent="$fixture/agent" + project="$fixture/project" + socket="pi-calm-smoke-$$" + mkdir -p "$agent/extensions" "$project" "$fixture/captures" "$fixture/sessions" + capture_tui() { + local label=$1 + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" >"$fixture/captures/$label.current.txt" 2>/dev/null || true + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/captures/$label.scrollback.txt" 2>/dev/null || true + tmux -L "$socket" capture-pane -ep -t "$TMUX_SESSION" >"$fixture/captures/$label.current.ansi.txt" 2>/dev/null || true + tmux -L "$socket" capture-pane -ep -t "$TMUX_SESSION" -S -100 >"$fixture/captures/$label.scrollback.ansi.txt" 2>/dev/null || true + } + cp -R "$CALM_DIR" "$agent/extensions/" + cat >"$project/provider.ts" <<'TS' +import { appendFileSync } from "node:fs"; +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const mark = (event: string) => { + const file = process.env.CALM_SMOKE_MARKERS; + if (file) appendFileSync(file, `${Date.now()} ${event}\n`); +}; + +export default function (pi: ExtensionAPI): void { + pi.registerProvider("calm-smoke", { + baseUrl: "http://127.0.0.1/unused", apiKey: "test-only", api: "openai-completions", + models: [{ id: "deterministic", name: "Calm smoke", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 4096, maxTokens: 128 }], + streamSimple(model) { + mark(`streamSimple ${model.provider}/${model.id}`); + const stream = createAssistantMessageEventStream(); + const output = { role: "assistant" as const, content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop" as const, timestamp: Date.now() }; + queueMicrotask(() => { + mark("stream-start"); + stream.push({ type: "start", partial: output }); + setTimeout(() => { + const text = "CALM_SMOKE_GENUINE_ASSISTANT"; + output.content.push({ type: "text", text }); + stream.push({ type: "text_start", contentIndex: 0, partial: output }); + stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output }); + stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output }); + stream.push({ type: "done", reason: "stop", message: output }); + mark("stream-done"); + stream.end(); + }, 1400); + }); + return stream; + }, + }); + pi.registerCommand("calm-smoke", { + description: "Deterministic local Calm smoke provider.", + handler: async (_args, ctx) => { + mark("command-enter"); + const model = ctx.modelRegistry.find("calm-smoke", "deterministic"); + if (!model || !(await pi.setModel(model))) throw new Error("Calm smoke model unavailable"); + mark("model-selected"); + pi.sendUserMessage("CALM_SMOKE_GENUINE_USER"); + mark("user-message-sent"); + }, + }); +} +TS + + tmux -L "$socket" new-session -d -s "$TMUX_SESSION" -x 100 -y 30 \ + "cd '$project' && env PI_CODING_AGENT_DIR='$agent' PI_CODING_AGENT_SESSION_DIR='$fixture/sessions' PI_OFFLINE=1 CALM_SMOKE_MARKERS='$fixture/provider-markers.txt' pi --approve --no-context-files --no-skills --no-prompt-templates -e ./provider.ts" + for i in $(seq 1 120); do + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/pane" 2>/dev/null || true + grep -Fq 'provider.ts' "$fixture/pane" && break + sleep 0.05 + done + grep -Fq 'provider.ts' "$fixture/pane" || fail "real Pi smoke did not load the disposable provider" + capture_tui ready + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/calm' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + sleep 0.2 + capture_tui after-calm + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/calm-smoke' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + for i in $(seq 1 120); do + if [ -f "$fixture/provider-markers.txt" ] && grep -Fq 'stream-start' "$fixture/provider-markers.txt"; then + capture_tui working-wide + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/wide" 2>/dev/null || true + grep -Fq '\__/' "$fixture/wide" && break + fi + sleep 0.02 + done + grep -Fq 'stream-start' "$fixture/provider-markers.txt" || fail "real Pi smoke did not enter the provider stream" + grep -Fq '\__/' "$fixture/wide" || fail "real Pi smoke did not show Calm's wide working boat" + tmux -L "$socket" resize-window -t "$TMUX_SESSION" -x 40 -y 30 + for i in $(seq 1 120); do + capture_tui working-narrow + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/narrow" 2>/dev/null || true + grep -Fq '\__/' "$fixture/narrow" && break + grep -Fq 'stream-done' "$fixture/provider-markers.txt" 2>/dev/null && break + sleep 0.02 + done + grep -Fq '\__/' "$fixture/narrow" || fail "real Pi smoke did not reflow Calm's working boat on resize" + for i in $(seq 1 120); do + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/pane" 2>/dev/null || true + grep -Fq 'CALM_SMOKE_GENUINE_ASSISTANT' "$fixture/pane" && break + sleep 0.05 + done + pane=$(cat "$fixture/pane") + assert_contains "$pane" 'CALM_SMOKE_GENUINE_USER' "real Pi smoke hid a genuine user prompt" + assert_contains "$pane" 'CALM_SMOKE_GENUINE_ASSISTANT' "real Pi smoke hid genuine assistant text" + [ "$(cat "$agent/calm")" = on ] || fail "real Pi smoke did not persist Calm on" + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/calm' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + sleep 0.15 + [ "$(cat "$agent/calm")" = off ] || fail "real Pi smoke did not persist Calm off" + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/quit' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + sleep 0.1 + tmux -L "$socket" kill-server 2>/dev/null || true + pass "isolated Pi 0.82 TUI proves auto-load, /calm persistence, resize-safe working animation, and genuine transcript text without credentials" +} + +test_zero_coupling_and_state_file +test_static_typescript_and_repo_wiring +test_preference_and_command +test_rendering_adapters_and_tool_shells +test_working_ship_and_lifecycle +test_collapsed_thinking_degradation +test_real_pi_tui_smoke From 51e661ddc22a3704d9c2d76b9e7d1bd1f10fae69 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:26:48 -0700 Subject: [PATCH 5/8] feat(pi): declare pi-web-access package pin (#30) Adds the npm:pi-web-access@0.14.0 exact immutable pin to the Pi packages array, matching the repo's pinning policy. --- home/.pi/agent/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/home/.pi/agent/settings.json b/home/.pi/agent/settings.json index 625b9c51..927689cb 100644 --- a/home/.pi/agent/settings.json +++ b/home/.pi/agent/settings.json @@ -12,6 +12,7 @@ "followUpMode": "all", "collapseChangelog": true, "packages": [ + "npm:pi-web-access@0.14.0", "npm:@ryan_nookpi/pi-extension-codex-fast-mode@0.2.6", "git:github.com/algal/pi-openai-server-compaction@c6d593087709e9481223dc6c6c2269b371b5e055" ] From ccd49773d8060d74699007e21341910205aeef5d Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Sun, 2 Aug 2026 01:38:42 -0400 Subject: [PATCH 6/8] feat: unattended SSH remote login + 1Password git identity/signing Enables macOS Remote Login (key-only, for VS Code Remote-SSH from the LAN) and switches this machine's GitHub auth/commit signing to a dedicated key pair pulled non-interactively from a read-only 1Password Service Account, since this box runs unattended. Keys and git identity are materialized by rebuild.sh straight to ~/.ssh and ~/.config/git/config-local, outside both the Nix store and this repo. SSH key fields are read with ?ssh-format=openssh: 1Password's default PKCS8 export uses RFC5958 "OneAsymmetricKey" v2 encoding, which macOS's bundled LibreSSL and ssh-keygen cannot parse. --- README.md | 60 +++++++++++++++++------ configuration.nix | 13 +++++ flake.nix | 6 ++- home.nix | 31 ++++++++++++ home/.config/git/config-local.tmpl | 3 ++ home/.ssh/allowed_signers.tmpl | 1 + home/.ssh/authorized_keys | 9 ++++ home/.ssh/config | 13 +++++ home/.ssh/id_ed25519_mac_auth.pub.tmpl | 1 + home/.ssh/id_ed25519_mac_auth.tmpl | 1 + home/.ssh/id_ed25519_mac_signing.pub.tmpl | 1 + home/.ssh/id_ed25519_mac_signing.tmpl | 1 + rebuild.sh | 26 +++++++++- 13 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 home/.config/git/config-local.tmpl create mode 100644 home/.ssh/allowed_signers.tmpl create mode 100644 home/.ssh/authorized_keys create mode 100644 home/.ssh/config create mode 100644 home/.ssh/id_ed25519_mac_auth.pub.tmpl create mode 100644 home/.ssh/id_ed25519_mac_auth.tmpl create mode 100644 home/.ssh/id_ed25519_mac_signing.pub.tmpl create mode 100644 home/.ssh/id_ed25519_mac_signing.tmpl diff --git a/README.md b/README.md index d555cb33..fb05c361 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,15 @@ If you find a bug, please open a GitHub Issue using the bug report template. Running the switch builds: - System settings (dark mode, key repeat, dock, Finder, trackpad) -- Homebrew apps (casks and CLI tools) +- Homebrew apps (casks and CLI tools, including VS Code Insiders) - Nix user packages (ripgrep, fd, fzf, jq, lazygit, Neovim, Hack Nerd Font) - Shell (zsh, aliases, starship prompt) - Editor (Neovim config with the rose-pine moon theme) - Terminal (WezTerm config with the rose-pine moon theme and dimmed unfocused windows) - Agent configs (Claude, Codex, opencode all share one AGENTS.md) - Optional Pi theme and local extensions, generic UI settings and model overrides, plus two deliberately pinned third-party Pi packages +- SSH server (Remote Login), key-only auth, for connecting from another machine on the LAN (e.g. VS Code Remote-SSH) +- Git identity, GitHub SSH auth, and commit signing, using a dedicated machine key pulled non-interactively from 1Password via a Service Account (no human required - this box runs unattended) ## Prerequisites @@ -91,19 +93,7 @@ If you clone it, review these before you run `bootstrap.sh`: All three have to match. - **CPU architecture**, `hostPlatform` in `configuration.nix` (see Prerequisites above). -**Git identity:** this config deliberately does not set your git name or email. -Git will stop your first commit and tell you to set them (`git config --global user.name "Your Name"` and `git config --global user.email you@example.com`). -If you'd rather manage that declaratively, add this back to `home.nix` with your own identity: - -```nix -programs.git = { - enable = true; - settings.user = { - name = "Your Name"; - email = "you@example.com"; - }; -}; -``` +**Git identity:** `user.name`/`email` aren't in this repo at all - they come from a `GIT_NAME`/`GIT_EMAIL` item in 1Password, materialized by `rebuild.sh` straight to `~/.config/git/config-local` (outside this repo, same as the SSH keys) and pulled in via `programs.git.includes`, so this public repo never hardcodes anyone's real name or email. `user.signingkey` points at `~/.ssh/id_ed25519_mac_signing`, which likewise doesn't exist until "GitHub SSH authentication & commit signing" below is done. If you clone this repo, add your own `GIT_NAME`/`GIT_EMAIL` item instead (see that section for the exact item shape), or just hardcode `programs.git.settings.user` in `home.nix` if you don't want the 1Password indirection. **Homebrew cleanup warning:** `configuration.nix` sets `homebrew.onActivation.cleanup = "zap"`. That means every time you switch, Homebrew removes any package or cask on your machine that isn't listed in the `brews` and `casks` arrays in `configuration.nix`. @@ -172,6 +162,48 @@ Both packages execute with your full user permissions and must be trusted like a Home Manager deliberately does not manage `~/.pi/agent` itself, or Pi authentication, sessions, trust decisions, caches, npm/git package trees, or any other runtime state. The model overrides contain no credentials or endpoint settings, do not choose a default model, and only take effect after you authenticate Pi yourself. This remains an additive post-video layer: it does not install Pi, a launcher, or package source code into this repository. +## Remote access (SSH / VS Code Remote-SSH) + +`configuration.nix` declares `services.openssh.enable = true;`, which turns on macOS's built-in Remote Login (the same sshd behind System Settings > Sharing) on every switch, and disables password authentication (`PasswordAuthentication no`, `KbdInteractiveAuthentication no`) so only key-based logins are accepted. + +`home/.ssh/authorized_keys` is symlinked to `~/.ssh/authorized_keys` and ships with a placeholder line. To let another machine (e.g. a Windows PC running VS Code Insiders' Remote-SSH extension) connect: + +1. On that machine, generate a keypair if you don't already have one: `ssh-keygen -t ed25519`. +2. Replace the placeholder line in `home/.ssh/authorized_keys` with the contents of the resulting `.pub` file. +3. Run `./rebuild.sh` on the Mac. + +This setup assumes both machines are on the same local network - it doesn't open any port on your router or configure a tunnel. For access from outside your LAN, put something like Tailscale in front of it rather than port-forwarding SSH directly to the internet. + +`visual-studio-code@insiders` is in the `casks` list, giving this Mac its own local VS Code Insiders install. That's separate from the Remote-SSH connection itself: when you connect from the Windows PC's VS Code Insiders, the Remote-SSH extension downloads and runs its own remote server component on the Mac automatically over the SSH connection the first time you connect - no separate install step for that part, since macOS already ships the `curl`/`tar` it needs. + +## GitHub SSH authentication & commit signing (1Password) + +This Mac runs unattended - nobody is sitting at it to approve a Touch ID prompt, and VNC-ing in every time git wants to push or sign a commit isn't "hands off." So this isn't 1Password's interactive SSH agent (that always requires a human to approve each use, every time it locks or restarts - fine for a laptop, not for a server). Instead: + +- A dedicated, non-default 1Password vault (`mac-automation`) holds two SSH keys used **only** by this machine: `dotfiles-mac-auth` and `dotfiles-mac-signing`, kept separate so a problem with one never touches the other. +- A **Service Account** scoped read-only to just that vault authenticates non-interactively - no vault unlock, no biometrics, no human required. +- `home/.ssh/*.tmpl` are committed templates containing only `op://` references (safe - no secrets). `rebuild.sh` runs `op inject` after every `darwin-rebuild switch` to materialize the real private keys, public keys, and `allowed_signers` straight into `~/.ssh/`, entirely outside both this git repo and the Nix store (which is world-readable, so secrets must never pass through it). +- `home/.ssh/config` routes `github.com` at the local materialized key directly (`IdentityAgent none`), and falls back to 1Password's interactive agent for every other host - so a human still gets the vault-gated, private-key-never-touches-disk experience for their own ad hoc SSH use. +- Git signing uses git's default `ssh-keygen`-based signer against the local key file - no `op-ssh-sign`, no 1Password dependency at commit time. +- The same vault also holds a `dotfiles-personal` item with `GIT_NAME`/`GIT_EMAIL` fields, injected into `~/.config/git/config-local` and pulled in via `programs.git.includes` - so this repo's `home.nix` never hardcodes anyone's real name or email either. + +**The trade-off, stated plainly:** the private keys now exist as ordinary files on this machine's disk, protected by Unix permissions and FileVault-at-rest - not "held only inside 1Password's vault." That's the same security posture as any standard CI/deploy key, not stronger. It's the accepted trade-off for unattended automation; it is a real downgrade from the interactive-agent model, not a wash. + +One-time setup: + +1. In 1Password, create the `mac-automation` vault (Service Accounts can't be granted access to your Personal/Private/Shared vault, so it has to be a fresh one). +2. In that vault, create `dotfiles-mac-auth` and `dotfiles-mac-signing` as SSH Key items (+ New Item > SSH Key > Generate, type ed25519), and a `dotfiles-personal` item (any item type with custom text fields) with `GIT_NAME` and `GIT_EMAIL` fields set to your actual name and email. +3. Create a Service Account, read-only, scoped to only the `mac-automation` vault. Copy its token immediately - 1Password shows it exactly once. +4. On this machine: `mkdir -p ~/.config/op && chmod 700 ~/.config/op`, save the token to `~/.config/op/service-account-token`, then `chmod 600` it. That path is outside `~/.dotfiles`, so it can never end up in this repo. +5. Run `./rebuild.sh`. It installs `1password-cli` (the `op` binary, via the `casks` list), then injects the keys and git identity into `~/.ssh/` and `~/.config/git/config-local`. +6. On GitHub, go to Settings > SSH and GPG keys > New SSH key. Add `~/.ssh/id_ed25519_mac_auth.pub`'s contents with key type "Authentication Key", and `~/.ssh/id_ed25519_mac_signing.pub`'s with key type "Signing Key". +7. Point this clone at GitHub over SSH: `git remote set-url origin git@github.com:/.git`. +8. Verify: `ssh -T git@github.com` should greet you by username with no prompt at all, and `git commit --allow-empty -m test && git log --show-signature -1` should show a good SSH signature. GitHub also shows a "Verified" badge on pushed commits signed this way. + +Rotating a key later is just: generate a new one in the 1Password item, re-run `./rebuild.sh` (`op inject --force` overwrites the local file), and update the GitHub-registered public key to match. + +If you're setting this up on a laptop you actually sit at instead of a headless box, skip all of this and just use 1Password's interactive SSH agent directly - point `home/.ssh/config`'s `IdentityAgent` at 1Password's socket for all hosts, point `git`'s `gpg.ssh.program` at `op-ssh-sign`, and accept the occasional Touch ID prompt in exchange for the private key never touching disk at all. That's a better trade for a machine a person is actually present at. + ## Notes The first time you launch `nvim`, it bootstraps [lazy.nvim](https://github.com/folke/lazy.nvim) by cloning plugins from GitHub. diff --git a/configuration.nix b/configuration.nix index 74baa399..66c1004f 100644 --- a/configuration.nix +++ b/configuration.nix @@ -12,6 +12,16 @@ home = "/Users/${user}"; }; system.stateVersion = 6; + # Remote Login (Apple's built-in sshd), for VS Code Remote-SSH from another + # machine on the LAN. Key-only: see home/.ssh/authorized_keys. + services.openssh = { + enable = true; + extraConfig = '' + PasswordAuthentication no + KbdInteractiveAuthentication no + MaxAuthTries 50 + ''; + }; system.defaults = { NSGlobalDomain = { AppleInterfaceStyle = "Dark"; @@ -40,6 +50,9 @@ casks = [ "wezterm" "claude-code" + "visual-studio-code@insiders" + "1password" + "1password-cli" ]; }; } diff --git a/flake.nix b/flake.nix index 96b6b8dd..8ad1a5b8 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ let # The one username line to change if this isn't your machine. # bootstrap.sh offers to rewrite this for you if your macOS username differs. - user = "kunchen"; + user = "coder"; in { darwinConfigurations."mac" = nix-darwin.lib.darwinSystem { @@ -32,6 +32,10 @@ home-manager.useUserPackages = true; home-manager.extraSpecialArgs = { inherit user; }; home-manager.users.${user} = import ./home.nix; + # If a file home-manager wants to symlink already exists on disk + # (e.g. 1Password writes ~/.ssh/config itself when you enable its + # SSH agent), back it up instead of failing activation. + home-manager.backupFileExtension = "backup"; } ]; }; diff --git a/home.nix b/home.nix index 540dc972..9c9da042 100644 --- a/home.nix +++ b/home.nix @@ -22,6 +22,29 @@ in fonts.fontconfig.enable = true; home.sessionVariables.EDITOR = "nvim"; + # GitHub auth and commit signing for this box. This machine runs + # unattended, so its keys are a dedicated pair pulled from a read-only + # 1Password Service Account into plain files at rebuild time (see + # rebuild.sh and README: "GitHub SSH authentication & commit signing"), + # rather than 1Password's interactive SSH agent, which always requires a + # human to approve each use. Signing therefore uses git's default + # ssh-keygen-based signer against the local key - no 1Password dependency + # at commit time, no prompts. user.name/email live in ~/.config/git/config-local + # instead, which rebuild.sh also materializes from 1Password - so this + # public repo's config never hardcodes anyone's real identity. + programs.git = { + enable = true; + includes = [ + { path = "${config.home.homeDirectory}/.config/git/config-local"; } + ]; + settings = { + user.signingkey = "${config.home.homeDirectory}/.ssh/id_ed25519_mac_signing"; + commit.gpgsign = true; + gpg.format = "ssh"; + gpg.ssh.allowedSignersFile = "${config.home.homeDirectory}/.ssh/allowed_signers"; + }; + }; + programs.zsh = { enable = true; autosuggestion.enable = true; # ghost text from history @@ -62,6 +85,14 @@ in config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.config/herdr"; home.file.".claude/settings.json".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.claude/settings.json"; + home.file.".ssh/authorized_keys".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.ssh/authorized_keys"; + home.file.".ssh/config".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.ssh/config"; + # id_ed25519_mac_{auth,signing}(.pub) and allowed_signers are NOT declared + # here: they hold real key material (or are derived from it), so rebuild.sh + # writes them straight to ~/.ssh from 1Password via `op inject`, skipping + # both the Nix store (world-readable) and this git repo entirely. # Keep Pi's credential and runtime state local by linking only authored files and directories. home.file.".pi/agent/themes".source = diff --git a/home/.config/git/config-local.tmpl b/home/.config/git/config-local.tmpl new file mode 100644 index 00000000..aacc3013 --- /dev/null +++ b/home/.config/git/config-local.tmpl @@ -0,0 +1,3 @@ +[user] + name = {{ op://mac-automation/dotfiles-personal/GIT_NAME }} + email = {{ op://mac-automation/dotfiles-personal/GIT_EMAIL }} diff --git a/home/.ssh/allowed_signers.tmpl b/home/.ssh/allowed_signers.tmpl new file mode 100644 index 00000000..e7ac98c4 --- /dev/null +++ b/home/.ssh/allowed_signers.tmpl @@ -0,0 +1 @@ +sascha.s.krumbach@gmail.com {{ op://mac-automation/dotfiles-mac-signing/public key }} diff --git a/home/.ssh/authorized_keys b/home/.ssh/authorized_keys new file mode 100644 index 00000000..ad38f392 --- /dev/null +++ b/home/.ssh/authorized_keys @@ -0,0 +1,9 @@ +# Replace the placeholder below with your Windows PC's SSH public key. +# +# On the Windows PC, generate a keypair if you don't already have one: +# ssh-keygen -t ed25519 +# Then paste the contents of the resulting .pub file here (one line, starting +# with "ssh-ed25519" or "ssh-rsa"), replacing the placeholder line, and run +# ./rebuild.sh on the Mac to apply it. +# +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJi8OUIM0Esj2JG+LYij8dqG+RlqNoWjavfk40tckhCi diff --git a/home/.ssh/config b/home/.ssh/config new file mode 100644 index 00000000..a695fc1f --- /dev/null +++ b/home/.ssh/config @@ -0,0 +1,13 @@ +# github.com uses this machine's dedicated deploy-style key, materialized +# locally by rebuild.sh via a read-only 1Password Service Account (see +# README: "GitHub SSH authentication & commit signing"). No agent, no +# prompts - this box runs unattended and nobody is present to approve one. +Host github.com + IdentityFile ~/.ssh/id_ed25519_mac_auth + IdentitiesOnly yes + IdentityAgent none + +# Everything else still goes through 1Password's interactive SSH agent, for +# times a human is actually sitting at (or VNC'd into) this machine. +Host * + IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" diff --git a/home/.ssh/id_ed25519_mac_auth.pub.tmpl b/home/.ssh/id_ed25519_mac_auth.pub.tmpl new file mode 100644 index 00000000..d6470839 --- /dev/null +++ b/home/.ssh/id_ed25519_mac_auth.pub.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-auth/public key }} diff --git a/home/.ssh/id_ed25519_mac_auth.tmpl b/home/.ssh/id_ed25519_mac_auth.tmpl new file mode 100644 index 00000000..465a0cc3 --- /dev/null +++ b/home/.ssh/id_ed25519_mac_auth.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-auth/private key?ssh-format=openssh }} diff --git a/home/.ssh/id_ed25519_mac_signing.pub.tmpl b/home/.ssh/id_ed25519_mac_signing.pub.tmpl new file mode 100644 index 00000000..05bc7b7a --- /dev/null +++ b/home/.ssh/id_ed25519_mac_signing.pub.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-signing/public key }} diff --git a/home/.ssh/id_ed25519_mac_signing.tmpl b/home/.ssh/id_ed25519_mac_signing.tmpl new file mode 100644 index 00000000..2389df9f --- /dev/null +++ b/home/.ssh/id_ed25519_mac_signing.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-signing/private key?ssh-format=openssh }} diff --git a/rebuild.sh b/rebuild.sh index b0b6bf18..946d65b9 100755 --- a/rebuild.sh +++ b/rebuild.sh @@ -2,4 +2,28 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" ln -sfn "$DIR" ~/.dotfiles -exec sudo darwin-rebuild switch --flake ~/.dotfiles#mac +sudo darwin-rebuild switch --flake ~/.dotfiles#mac + +# Materialize this machine's GitHub auth/signing keys from the +# "mac-automation" 1Password vault via a read-only Service Account. See +# README: "GitHub SSH authentication & commit signing". Skips quietly on a +# fresh clone before that token exists. +TOKEN_FILE="$HOME/.config/op/service-account-token" +if [[ -f "$TOKEN_FILE" ]]; then + export OP_SERVICE_ACCOUNT_TOKEN + OP_SERVICE_ACCOUNT_TOKEN="$(cat "$TOKEN_FILE")" + for name in id_ed25519_mac_auth id_ed25519_mac_signing; do + op inject --force -i "$DIR/home/.ssh/$name.tmpl" -o "$HOME/.ssh/$name" + op inject --force -i "$DIR/home/.ssh/$name.pub.tmpl" -o "$HOME/.ssh/$name.pub" + chmod 600 "$HOME/.ssh/$name" + chmod 644 "$HOME/.ssh/$name.pub" + done + op inject --force -i "$DIR/home/.ssh/allowed_signers.tmpl" -o "$HOME/.ssh/allowed_signers" + chmod 644 "$HOME/.ssh/allowed_signers" + + mkdir -p "$HOME/.config/git" + op inject --force -i "$DIR/home/.config/git/config-local.tmpl" -o "$HOME/.config/git/config-local" + chmod 644 "$HOME/.config/git/config-local" +else + echo "note: $TOKEN_FILE not found - skipping 1Password key injection (see README)" >&2 +fi From 923f26bed88dc2017ce25cb197b638df567035c3 Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Sun, 2 Aug 2026 01:38:45 -0400 Subject: [PATCH 7/8] feat(herdr): disable onboarding prompt --- home/.config/herdr/config.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/home/.config/herdr/config.toml b/home/.config/herdr/config.toml index 8fa93ac2..5e79fbca 100644 --- a/home/.config/herdr/config.toml +++ b/home/.config/herdr/config.toml @@ -1,3 +1,4 @@ +onboarding = false [keys] prefix = "ctrl+b" focus_pane_left = "prefix+h" From d2e3b1f95d6474a809aa55679fec7e130b714415 Mon Sep 17 00:00:00 2001 From: Sascha Krumbach Date: Sun, 2 Aug 2026 01:38:49 -0400 Subject: [PATCH 8/8] feat(claude): add session-start hook, pin model, fullscreen TUI Adds a SessionStart hook running herdr-agent-state.sh, pins the model to sonnet, disables commit/PR attribution, and switches the TUI to fullscreen mode. --- home/.claude/settings.json | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/home/.claude/settings.json b/home/.claude/settings.json index e3ab7750..ba6576f5 100644 --- a/home/.claude/settings.json +++ b/home/.claude/settings.json @@ -1,7 +1,28 @@ { - "theme": "dark-ansi", + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/coder/.claude/hooks/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ], + "matcher": "*" + } + ] + }, + "model": "sonnet", "statusLine": { - "type": "command", - "command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name'); used=$(echo \"$input\" | jq -r '.context_window.used_percentage // empty'); if [ -n \"$used\" ]; then printf \"%s | ctx: %.0f%% used\" \"$model\" \"$used\"; else printf \"%s\" \"$model\"; fi" - } -} + "command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name'); used=$(echo \"$input\" | jq -r '.context_window.used_percentage // empty'); if [ -n \"$used\" ]; then printf \"%s | ctx: %.0f%% used\" \"$model\" \"$used\"; else printf \"%s\" \"$model\"; fi", + "type": "command" + }, + "theme": "dark-ansi", + "tui": "fullscreen" +} \ No newline at end of file