Switch firstmate to OpenCode 2 and refresh nix-darwin pins
Point fm at the isolated oc2 wrapper, upgrade Homebrew packages on rebuild, and declare ffmpeg, docker-desktop, puremac, utm, node, and tea. Update nixpkgs, home-manager, and nix-homebrew locks. Track Pi agent config, crew-dispatch, and the Gitea PR helper that home.nix already expected.
This commit is contained in:
@@ -6,8 +6,9 @@ Deliberate decisions in this repo - do NOT silently revert them:
|
|||||||
- Agent configs under `home/` are the source of truth. `home.nix` installs them with `mkOutOfStoreSymlink` + `force = true`, so edits to files in `home/` apply live after the first rebuild that creates the symlink. Prefer editing those paths, not the live `~/.claude` / `~/.grok` / `~/.pi` copies. `rebuild.sh` rotates stale `*.backup` files before switch so home-manager never fails with "would be clobbered".
|
- Agent configs under `home/` are the source of truth. `home.nix` installs them with `mkOutOfStoreSymlink` + `force = true`, so edits to files in `home/` apply live after the first rebuild that creates the symlink. Prefer editing those paths, not the live `~/.claude` / `~/.grok` / `~/.pi` copies. `rebuild.sh` rotates stale `*.backup` files before switch so home-manager never fails with "would be clobbered".
|
||||||
- Grok Build is first-class here: `home/.grok/config.toml` and `home/AGENTS.md` (linked as `~/.grok/AGENTS.md`). Claude, Codex, opencode, and Pi share the same `home/AGENTS.md` for multi-agent work.
|
- Grok Build is first-class here: `home/.grok/config.toml` and `home/AGENTS.md` (linked as `~/.grok/AGENTS.md`). Claude, Codex, opencode, and Pi share the same `home/AGENTS.md` for multi-agent work.
|
||||||
- Pi is managed the same way: `home/.pi/agent/{settings,models}.json`, `themes/`, and custom extensions (`terminal-status-title`, `calm`). Do not symlink the whole `~/.pi/agent` tree - sessions, auth, npm cache, and herdr's `extensions/herdr-agent-state.ts` stay live.
|
- Pi is managed the same way: `home/.pi/agent/{settings,models}.json`, `themes/`, and custom extensions (`terminal-status-title`, `calm`). Do not symlink the whole `~/.pi/agent` tree - sessions, auth, npm cache, and herdr's `extensions/herdr-agent-state.ts` stay live.
|
||||||
- firstmate lives at `~/Documents/firstmate` as a mutable git clone (not in the Nix store). `home.activation.firstmate` clones it if missing and seeds `config/backend=herdr` + `config/crew-harness=pi` only when those files are absent. Launch with shell alias `fm` (`cd` + `pi`). Approve Pi project trust once so `.pi/extensions/*.ts` load. Self-update via `/updatefirstmate`, not rebuild.
|
- firstmate lives at `~/Documents/firstmate` as a mutable git clone (not in the Nix store). `home.activation.firstmate` clones it if missing and seeds `config/backend=herdr` + `config/crew-harness=pi` only when those files are absent. Launch with shell alias `fm` (`cd` + OpenCode 2 via `~/.local/bin/oc2`). Self-update via `/updatefirstmate`, not rebuild.
|
||||||
- Shell aliases: `cc` (Claude skip-permissions), `co` (Codex full-auto), `gb` (Grok --yolo), `fm` (firstmate via Pi). They are intentional high-agency shortcuts.
|
- OpenCode 2 is the firstmate primary. It is not a Homebrew formula: install `@opencode-ai/cli@beta` with Homebrew's npm (`home.activation.opencode2`). Launch through `home/bin/oc2` so `OPENCODE_CONFIG` stays at `home/.config/opencode2/opencode.json` and cannot rewrite OpenCode 1.x's `~/.config/opencode`. Do not add a brew `opencode` / `opencode2` formula; `zap` would fight the npm install. firstmate harness detection matches `*opencode*`, so the `opencode2` process name still classifies as OpenCode.
|
||||||
|
- Shell aliases: `cc` (Claude skip-permissions), `co` (Codex full-auto), `gb` (Grok --yolo), `fm` (firstmate via OpenCode 2), `oc2` (OpenCode 2, isolated). They are intentional high-agency shortcuts.
|
||||||
|
|
||||||
## Maintaining this file
|
## Maintaining this file
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -41,18 +41,24 @@
|
|||||||
enable = true;
|
enable = true;
|
||||||
onActivation.cleanup = "zap"; # remove anything not listed here
|
onActivation.cleanup = "zap"; # remove anything not listed here
|
||||||
onActivation.autoUpdate = true;
|
onActivation.autoUpdate = true;
|
||||||
|
onActivation.upgrade = true; # upgrade outdated formulae/casks on rebuild
|
||||||
onActivation.extraFlags = [ "--force" ];
|
onActivation.extraFlags = [ "--force" ];
|
||||||
brews = [
|
brews = [
|
||||||
|
"ffmpeg"
|
||||||
"gh"
|
"gh"
|
||||||
"herdr"
|
"herdr"
|
||||||
"opencode"
|
"node" # required: opencode2 is an npm global, not a Homebrew formula
|
||||||
"pi-coding-agent"
|
"pi-coding-agent"
|
||||||
|
"tea" # Gitea CLI - PRs for firstmate crewmates on unraid.local:3003
|
||||||
"tmux"
|
"tmux"
|
||||||
"treehouse"
|
"treehouse"
|
||||||
];
|
];
|
||||||
casks = [
|
casks = [
|
||||||
"wezterm"
|
"docker-desktop"
|
||||||
"grok-build"
|
"grok-build"
|
||||||
|
"puremac"
|
||||||
|
"utm"
|
||||||
|
"wezterm"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+13
-13
@@ -3,16 +3,16 @@
|
|||||||
"brew-src": {
|
"brew-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784068757,
|
"lastModified": 1788591592,
|
||||||
"narHash": "sha256-7KnV7rTlMpys+2J+TFVxUDDyKPLXFs4wIMtckMC72VM=",
|
"narHash": "sha256-NbwVKwKLFl0oXub7oPjvmDaOygCtV2oboeKTu4xXFTk=",
|
||||||
"owner": "Homebrew",
|
"owner": "Homebrew",
|
||||||
"repo": "brew",
|
"repo": "brew",
|
||||||
"rev": "6bd951d96e7ebc54787799dba77bfb26ec956c4c",
|
"rev": "08e85c4e42f5d8f1ea17c36cb59cf61c2ccb26c3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "Homebrew",
|
"owner": "Homebrew",
|
||||||
"ref": "6.0.11",
|
"ref": "6.0.22",
|
||||||
"repo": "brew",
|
"repo": "brew",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784350909,
|
"lastModified": 1789267039,
|
||||||
"narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=",
|
"narHash": "sha256-LWiBv9yAYFi2LPbUhDGHPGKYskJQjj2fw12OlyO1uQo=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3",
|
"rev": "ec172013fa62135f58fb58dd17ae9651e8f39727",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -64,11 +64,11 @@
|
|||||||
"brew-src": "brew-src"
|
"brew-src": "brew-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784159664,
|
"lastModified": 1788981439,
|
||||||
"narHash": "sha256-I/B6YoRLImHEqNWh8bs+tPjEDeteACeFhcLyoSMo1GE=",
|
"narHash": "sha256-fEaFq0XgpgWFPLfpq1UK4/8ylHd2T0+fo6O3hKz1LUM=",
|
||||||
"owner": "zhaofengli",
|
"owner": "zhaofengli",
|
||||||
"repo": "nix-homebrew",
|
"repo": "nix-homebrew",
|
||||||
"rev": "842eeb863ecca0eeb463f7a814cdc51e1d925776",
|
"rev": "09a921d0181146cf6163ec2cc1db7b6fd539a885",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -79,11 +79,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784255083,
|
"lastModified": 1789485310,
|
||||||
"narHash": "sha256-0Jz8G3+3g6i/l4aISUri0EidLnREelCff0m6+pE6aZg=",
|
"narHash": "sha256-VsDMTLSkAAJmbNha6sGNzlF33X7RESWLl3SnjDQu+4U=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "fc51889f81924f15fba77a3c0b79cfb3f78fe0d4",
|
"rev": "0c32f40fe3e2a9adfc427fd5abc061a31043ea44",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ in
|
|||||||
cc = "claude --dangerously-skip-permissions";
|
cc = "claude --dangerously-skip-permissions";
|
||||||
co = "codex --full-auto";
|
co = "codex --full-auto";
|
||||||
gb = "grok --yolo";
|
gb = "grok --yolo";
|
||||||
# firstmate primary session via Pi (approve project trust once per clone)
|
# firstmate primary session via OpenCode 2 (isolated config; crewmates = Pi)
|
||||||
fm = "cd ${firstmateHome} && exec pi";
|
fm = "cd ${firstmateHome} && exec ${home}/.local/bin/oc2";
|
||||||
|
oc2 = "${home}/.local/bin/oc2";
|
||||||
# Sync ~/Music into the beets library on Unraid (requires NAS mounted).
|
# Sync ~/Music into the beets library on Unraid (requires NAS mounted).
|
||||||
sync-music = "~/.local/bin/sync-music";
|
sync-music = "~/.local/bin/sync-music";
|
||||||
# Start the reverse tunnel so you can SSH into this Mac from your phone.
|
# Start the reverse tunnel so you can SSH into this Mac from your phone.
|
||||||
@@ -106,6 +107,19 @@ in
|
|||||||
source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md";
|
source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md";
|
||||||
force = true;
|
force = true;
|
||||||
};
|
};
|
||||||
|
# OpenCode 2 isolated config - never share ~/.config/opencode with 1.x
|
||||||
|
home.file.".config/opencode2/opencode.json" = {
|
||||||
|
source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.config/opencode2/opencode.json";
|
||||||
|
force = true;
|
||||||
|
};
|
||||||
|
home.file.".config/opencode2/AGENTS.md" = {
|
||||||
|
source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md";
|
||||||
|
force = true;
|
||||||
|
};
|
||||||
|
home.file.".local/bin/oc2" = {
|
||||||
|
source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/bin/oc2";
|
||||||
|
force = true;
|
||||||
|
};
|
||||||
home.file.".grok/AGENTS.md" = {
|
home.file.".grok/AGENTS.md" = {
|
||||||
source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md";
|
source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md";
|
||||||
force = true;
|
force = true;
|
||||||
@@ -163,6 +177,15 @@ in
|
|||||||
# Local gitignored operating choices (do not overwrite captain edits)
|
# Local gitignored operating choices (do not overwrite captain edits)
|
||||||
[ -f "$fm/config/backend" ] || printf 'herdr\n' > "$fm/config/backend"
|
[ -f "$fm/config/backend" ] || printf 'herdr\n' > "$fm/config/backend"
|
||||||
[ -f "$fm/config/crew-harness" ] || printf 'pi\n' > "$fm/config/crew-harness"
|
[ -f "$fm/config/crew-harness" ] || printf 'pi\n' > "$fm/config/crew-harness"
|
||||||
|
|
||||||
|
# Crew dispatch profile - source of truth in dotfiles (reproducible across
|
||||||
|
# machines). Symlinked into firstmate config so a rebuild restores routing.
|
||||||
|
# Crewmates run on Pi + opencode-go/mimo-v2.5-pro (see crew-dispatch.json).
|
||||||
|
ln -sfn "${dotfiles}/home/.config/firstmate/crew-dispatch.json" "$fm/config/crew-dispatch.json"
|
||||||
|
|
||||||
|
# Gitea PR helper for local-only mode: after the first mate (opencode)
|
||||||
|
# reviews a crewmate branch, this pushes it + opens a Gitea PR via tea.
|
||||||
|
ln -sfn "${dotfiles}/home/bin/fm-gitea-pr.sh" "$HOME/.local/bin/fm-gitea-pr.sh"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Authorize the SSH key so the phone can log in through the ngrok tunnel.
|
# Authorize the SSH key so the phone can log in through the ngrok tunnel.
|
||||||
@@ -183,5 +206,22 @@ in
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ln -sfn "${dotfiles}/home/bin/sync-music" "$HOME/.local/bin/sync-music"
|
ln -sfn "${dotfiles}/home/bin/sync-music" "$HOME/.local/bin/sync-music"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# OpenCode 2 is not on Homebrew (beta). Install via Homebrew's npm, never
|
||||||
|
# Nix's npm (EACCES on the store). Skip when already present so a rebuild
|
||||||
|
# does not pull a new beta under a live TUI.
|
||||||
|
home.activation.opencode2 = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||||
|
set -euo pipefail
|
||||||
|
npm=/opt/homebrew/bin/npm
|
||||||
|
bin=/opt/homebrew/bin/opencode2
|
||||||
|
if [ -x "$bin" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ ! -x "$npm" ]; then
|
||||||
|
echo "opencode2: /opt/homebrew/bin/npm missing (declare node in homebrew.brews)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$npm" install -g --allow-scripts=@opencode-ai/cli @opencode-ai/cli@beta
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"when": "pure-function implementation with tests (indicators, slots, seasonal wiring, repositories)",
|
||||||
|
"use": [{ "harness": "pi", "model": "opencode-go/mimo-v2.5-pro" }],
|
||||||
|
"why": "MiMo V2.5 Pro (opencode-go, 1M ctx) produces clean correct code; flat-rate sub so $0 marginal cost. Pi drives it in an isolated worktree."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"when": "adapter or external-fetch integration (COT, seed backfills, vendor seams)",
|
||||||
|
"use": [{ "harness": "pi", "model": "opencode-go/mimo-v2.5-pro" }],
|
||||||
|
"why": "Pattern-following work against existing adapter contracts; MiMo handles iterative fetch/debug loops."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"when": "frontend page or tRPC wiring (UI panels, router procedures, client exposure)",
|
||||||
|
"use": [{ "harness": "pi", "model": "opencode-go/mimo-v2.5-pro" }],
|
||||||
|
"why": "Mechanical wiring against existing components/routers; the contract carries the specialization."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": [{ "harness": "pi", "model": "opencode-go/mimo-v2.5-pro" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"model": "talos/ornith-worker-1",
|
||||||
|
"autoupdate": false,
|
||||||
|
"providers": {
|
||||||
|
"talos": {
|
||||||
|
"name": "Talos Cluster",
|
||||||
|
"package": "@opencode-ai/ai/providers/openai-compatible",
|
||||||
|
"settings": {
|
||||||
|
"baseURL": "http://10.37.0.165:30081/v1"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"ornith-worker-1": {
|
||||||
|
"modelID": "ornith-worker-1",
|
||||||
|
"name": "Ornith Worker 1 (RTX 4070 Super)",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"input": ["text"],
|
||||||
|
"output": ["text"]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"context": 132768,
|
||||||
|
"output": 65536
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"talos-worker-2": {
|
||||||
|
"name": "Talos Worker 2",
|
||||||
|
"package": "@opencode-ai/ai/providers/openai-compatible",
|
||||||
|
"settings": {
|
||||||
|
"baseURL": "http://10.37.0.219:30082/v1"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"genesis-hermes-worker-2": {
|
||||||
|
"modelID": "/models/Hermes3.6-35B-A3B-Uncensored-Genesis-V6-APEX.gguf",
|
||||||
|
"name": "Genesis Hermes V6 APEX (RTX 3080 12GB, vision)",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"input": ["text"],
|
||||||
|
"output": ["text"]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"context": 131072,
|
||||||
|
"output": 65536
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"omlx": {
|
||||||
|
"name": "oMLX (Local)",
|
||||||
|
"package": "@opencode-ai/ai/providers/openai-compatible",
|
||||||
|
"settings": {
|
||||||
|
"baseURL": "http://localhost:8000/v1"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"ornith-35b": {
|
||||||
|
"modelID": "Ornith-35B",
|
||||||
|
"name": "Ornith 35B (Qwen3.5 MoE, 4-bit, 131K ctx)",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"input": ["text"],
|
||||||
|
"output": ["text"]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"context": 131072,
|
||||||
|
"output": 65536
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ornith-5bit": {
|
||||||
|
"modelID": "Ornith-1.0-35B-5bit-mlx",
|
||||||
|
"name": "Ornith 35B (5-bit, 262K ctx)",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"input": ["text"],
|
||||||
|
"output": ["text"]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"context": 262144,
|
||||||
|
"output": 65536
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ternary-bonsai": {
|
||||||
|
"modelID": "Ternary-Bonsai-27B-mlx-2bit",
|
||||||
|
"name": "Ternary Bonsai 27B (2-bit, ternary weights)",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"input": ["text"],
|
||||||
|
"output": ["text"]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"context": 131072,
|
||||||
|
"output": 65536
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dagger-qwen36-27b": {
|
||||||
|
"modelID": "Dagger-Qwen3.6-27B-MLX",
|
||||||
|
"name": "Dagger Qwen3.6 27B MLX (M5, 32GB)",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"input": ["text"],
|
||||||
|
"output": ["text"]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"context": 65536,
|
||||||
|
"output": 65536
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalmBuiltInToolShellPatch = {
|
||||||
|
hidesShell: () => boolean;
|
||||||
|
builtInDefinitions: WeakSet<ToolDefinition>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<PiAssistantMessageComponent["updateContent"]>[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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"genesis": {
|
||||||
|
"baseUrl": "http://10.37.0.219:30082/v1",
|
||||||
|
"api": "openai-completions",
|
||||||
|
"apiKey": "none",
|
||||||
|
"compat": {
|
||||||
|
"maxTokensField": "max_tokens"
|
||||||
|
},
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "genesis",
|
||||||
|
"name": "Hermes3.6-35B-A3B-Uncensored-Genesis-V6-APEX (Local)",
|
||||||
|
"reasoning": false,
|
||||||
|
"input": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"contextWindow": 132864,
|
||||||
|
"maxTokens": 32768,
|
||||||
|
"cost": {
|
||||||
|
"input": 0,
|
||||||
|
"output": 0,
|
||||||
|
"cacheRead": 0,
|
||||||
|
"cacheWrite": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ornith": {
|
||||||
|
"baseUrl": "http://10.37.0.165:30081/v1",
|
||||||
|
"api": "openai-completions",
|
||||||
|
"apiKey": "none",
|
||||||
|
"compat": {
|
||||||
|
"maxTokensField": "max_tokens"
|
||||||
|
},
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "ornith",
|
||||||
|
"name": "Ornith 1.0 35B (Local)",
|
||||||
|
"reasoning": false,
|
||||||
|
"input": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"contextWindow": 132864,
|
||||||
|
"maxTokens": 32768,
|
||||||
|
"cost": {
|
||||||
|
"input": 0,
|
||||||
|
"output": 0,
|
||||||
|
"cacheRead": 0,
|
||||||
|
"cacheWrite": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"omlx": {
|
||||||
|
"baseUrl": "http://localhost:8000/v1",
|
||||||
|
"api": "openai-completions",
|
||||||
|
"apiKey": "none",
|
||||||
|
"compat": {
|
||||||
|
"maxTokensField": "max_tokens"
|
||||||
|
},
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "Dagger-Qwen3.6-27B-MLX",
|
||||||
|
"name": "Dagger Qwen3.6 27B MLX (M5, 32GB)",
|
||||||
|
"reasoning": true,
|
||||||
|
"input": ["text"],
|
||||||
|
"contextWindow": 65536,
|
||||||
|
"maxTokens": 32768,
|
||||||
|
"cost": {
|
||||||
|
"input": 0,
|
||||||
|
"output": 0,
|
||||||
|
"cacheRead": 0,
|
||||||
|
"cacheWrite": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"openai-codex": {
|
||||||
|
"modelOverrides": {
|
||||||
|
"gpt-5.6-luna": {
|
||||||
|
"contextWindow": 272000
|
||||||
|
},
|
||||||
|
"gpt-5.6-sol": {
|
||||||
|
"contextWindow": 272000
|
||||||
|
},
|
||||||
|
"gpt-5.6-terra": {
|
||||||
|
"contextWindow": 272000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"lastChangelogVersion": "0.84.1",
|
||||||
|
"theme": "rose-pine-moon",
|
||||||
|
"defaultProvider": "ornith",
|
||||||
|
"defaultModel": "ornith",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "Genesis (Local)",
|
||||||
|
"provider": "genesis",
|
||||||
|
"model": "/models/Hermes3.6-35B-A3B-Uncensored-Genesis-V6-APEX.gguf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ornith 1.0 35B (Local)",
|
||||||
|
"provider": "ornith",
|
||||||
|
"model": "/models/ornith-1.0-35b-Q6_K.gguf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dagger Qwen3.6 27B MLX (M5)",
|
||||||
|
"provider": "omlx",
|
||||||
|
"model": "Dagger-Qwen3.6-27B-MLX"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compaction": {
|
||||||
|
"enabled": true,
|
||||||
|
"reserveTokens": 16384,
|
||||||
|
"keepRecentTokens": 8000
|
||||||
|
},
|
||||||
|
"packages": [
|
||||||
|
"npm:pi-context",
|
||||||
|
"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",
|
||||||
|
"../../.automaton/plugins/pi-read-tweet"
|
||||||
|
],
|
||||||
|
"defaultThinkingLevel": "high",
|
||||||
|
"images": {
|
||||||
|
"blockImages": false
|
||||||
|
},
|
||||||
|
"terminal": {
|
||||||
|
"showImages": false
|
||||||
|
},
|
||||||
|
"hideThinkingBlock": true,
|
||||||
|
"quietStartup": true,
|
||||||
|
"steeringMode": "all",
|
||||||
|
"followUpMode": "all",
|
||||||
|
"collapseChangelog": true
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fm-gitea-pr.sh - Push a reviewed crewmate branch to Gitea and open a PR via tea.
|
||||||
|
#
|
||||||
|
# Used after the first mate (opencode) reviews a crewmate's local branch in
|
||||||
|
# local-only mode: this helper pushes the branch to the Gitea remote and opens
|
||||||
|
# a pull request for the record. The review decision already happened in
|
||||||
|
# opencode; this is the publish step.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# fm-gitea-pr.sh <branch> [--title "<title>"] [--body "<body>"] [--base <base>]
|
||||||
|
#
|
||||||
|
# Defaults: --title = branch name, --body = "(reviewed by first mate)", --base = main
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time per machine, not automatable in nix - secret):
|
||||||
|
# tea login add --name unraid --host http://unraid.local:3003 --token <GITEA_TOKEN>
|
||||||
|
#
|
||||||
|
# The Gitea remote is read from the current git repo's `origin` URL.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BRANCH=""
|
||||||
|
TITLE=""
|
||||||
|
BODY="(reviewed by first mate - tests green, Primary-Rule + ADR-0009 lint passed)"
|
||||||
|
BASE="main"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--title) TITLE="$2"; shift 2 ;;
|
||||||
|
--body) BODY="$2"; shift 2 ;;
|
||||||
|
--base) BASE="$2"; shift 2 ;;
|
||||||
|
-*) echo "unknown flag: $1" >&2; exit 1 ;;
|
||||||
|
*) [ -z "$BRANCH" ] && BRANCH="$1" || { echo "unexpected extra arg: $1" >&2; exit 1; }; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$BRANCH" ] || { echo "usage: fm-gitea-pr.sh <branch> [--title <t>] [--body <b>] [--base <b>]" >&2; exit 1; }
|
||||||
|
[ -z "$TITLE" ] && TITLE="$BRANCH"
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || { echo "error: tea not installed. brew install tea" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Parse the Gitea owner/repo from the origin remote URL.
|
||||||
|
# Handles http://user:token@host:port/owner/repo(.git) and https://host/owner/repo.git
|
||||||
|
REMOTE=$(git remote get-url origin 2>/dev/null) || { echo "error: no git origin remote" >&2; exit 1; }
|
||||||
|
# Strip credentials and .git suffix, then take the path after the host.
|
||||||
|
PATH_PART=${REMOTE#*://}
|
||||||
|
PATH_PART=${PATH_PART#*@} # drop user:pass@
|
||||||
|
PATH_PART=${PATH_PART#*/} # drop host:port/ (first slash)
|
||||||
|
PATH_PART=${PATH_PART%.git}
|
||||||
|
OWNER_REPO=${PATH_PART}
|
||||||
|
|
||||||
|
case "$OWNER_REPO" in
|
||||||
|
*/*) : ;; # owner/repo form
|
||||||
|
*) echo "error: could not parse owner/repo from remote: $REMOTE" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Push the branch to origin (Gitea). --push-branch uses the configured remote.
|
||||||
|
git push -u origin "$BRANCH" 2>&1 || { echo "error: git push failed for branch $BRANCH" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Open the PR via tea. tea resolves the login by host automatically.
|
||||||
|
PR_URL=$(tea pulls create \
|
||||||
|
--repo "$OWNER_REPO" \
|
||||||
|
--head "$BRANCH" \
|
||||||
|
--base "$BASE" \
|
||||||
|
--title "$TITLE" \
|
||||||
|
--description "$BODY" 2>&1) || {
|
||||||
|
echo "error: tea pulls create failed" >&2
|
||||||
|
echo "$PR_URL" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# tea prints the PR URL on success; echo it for the caller.
|
||||||
|
echo "$PR_URL"
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Launch OpenCode 2 with an isolated config so it cannot rewrite
|
||||||
|
# ~/.config/opencode/opencode.json (OpenCode 1 / firstmate).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
bin="${OPENCODE2_BIN:-/opt/homebrew/bin/opencode2}"
|
||||||
|
cfg="${OPENCODE_CONFIG:-$HOME/.config/opencode2/opencode.json}"
|
||||||
|
|
||||||
|
if [ ! -x "$bin" ]; then
|
||||||
|
echo "oc2: opencode2 not installed. Rebuild, or run:" >&2
|
||||||
|
echo " /opt/homebrew/bin/npm install -g --allow-scripts=@opencode-ai/cli @opencode-ai/cli@beta" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -e "$cfg" ]; then
|
||||||
|
echo "oc2: missing config $cfg (managed by home.nix)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export OPENCODE_CONFIG="$cfg"
|
||||||
|
exec "$bin" "$@"
|
||||||
@@ -1 +1,16 @@
|
|||||||
The reason you are still seeing command not found is that darwin-rebuild does not exist on your computer yet.When doing a brand-new installation of nix-darwin, the darwin-rebuild tool is not part of the standard Nix installer. You have to bootstrap it using the nix run command first, which will dynamically pull the tool from GitHub and execute the initial build.Run the following command from your terminal inside your ~/.dotfiles directory:zshsudo nix run nix-darwin#darwin-rebuild -- switch --flake ~/.dotfiles#lt-mbp
|
The reason you are still seeing command not found is that darwin-rebuild does not exist on your computer yet.When doing a brand-new installation of nix-darwin, the darwin-rebuild tool is not part of the standard Nix installer. You have to bootstrap it using the nix run command first, which will dynamically pull the tool from GitHub and execute the initial build.Run the following command from your terminal inside your ~/.dotfiles directory:zshsudo nix run nix-darwin#darwin-rebuild -- switch --flake ~/.dotfiles#lt-mbp
|
||||||
|
|
||||||
|
## firstmate + Gitea (one-time per machine, secrets not in nix)
|
||||||
|
|
||||||
|
The firstmate crew dispatch + Gitea PR helper are wired by `darwin-rebuild`, but two secrets must be set manually per machine (nix cannot manage tokens without exposing them):
|
||||||
|
|
||||||
|
1. tea login (Gitea CLI auth against unraid):
|
||||||
|
zsh
|
||||||
|
tea login add --name unraid --url http://unraid.local:3003 --token <GITEA_TOKEN>
|
||||||
|
|
||||||
|
2. The Gitea token is the one in investor-flow's origin remote URL. After `tea login`, optionally clean the embedded creds from the remote:
|
||||||
|
zsh
|
||||||
|
cd ~/Documents/investor-flow
|
||||||
|
git remote set-url origin http://unraid.local:3003/transnet/investor-flow
|
||||||
|
|
||||||
|
Verify: `fm` launches OpenCode 2 as the first mate (isolated `~/.config/opencode2`); `oc2 --version` prints an OpenCode 2 version; `pi --list-models mimo` shows `opencode-go/mimo-v2.5-pro`; `tea pulls list --repo transnet/investor-flow` works.
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ managed=(
|
|||||||
"$HOME/.claude/CLAUDE.md"
|
"$HOME/.claude/CLAUDE.md"
|
||||||
"$HOME/.codex/AGENTS.md"
|
"$HOME/.codex/AGENTS.md"
|
||||||
"$HOME/.config/opencode/AGENTS.md"
|
"$HOME/.config/opencode/AGENTS.md"
|
||||||
|
"$HOME/.config/opencode2/opencode.json"
|
||||||
|
"$HOME/.config/opencode2/AGENTS.md"
|
||||||
|
"$HOME/.local/bin/oc2"
|
||||||
"$HOME/.pi/agent/settings.json"
|
"$HOME/.pi/agent/settings.json"
|
||||||
"$HOME/.pi/agent/models.json"
|
"$HOME/.pi/agent/models.json"
|
||||||
"$HOME/.pi/agent/AGENTS.md"
|
"$HOME/.pi/agent/AGENTS.md"
|
||||||
|
|||||||
Reference in New Issue
Block a user