fix(pi): add snap node compatibility diagnostics and documentation

- Added detailed error logging to detect snap node compatibility issues
- When Pi process exits with code 1, log helpful diagnostic info including node path
- Add hint to check for snap node and reinstall via apt/nvm
- Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide
- Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes
- Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
2026-03-04 01:45:36 +00:00
parent 72d1341cbc
commit ef13f96d36
34 changed files with 2394 additions and 1466 deletions
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
apps: [
{
name: 'officer',
script: 'bun',
args: 'start',
watch: false,
},
],
};
+5 -1
View File
@@ -26,7 +26,11 @@
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/setup.sh"
"setup": "bash scripts/setup.sh",
"start:sidecar": "sudo systemctl start officer-pty-sidecar",
"stop:sidecar": "sudo systemctl stop officer-pty-sidecar",
"restart:sidecar": "sudo systemctl restart officer-pty-sidecar",
"logs:sidecar": "journalctl -u officer-pty-sidecar -f"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
+181
View File
@@ -0,0 +1,181 @@
#!/bin/bash
# One-time migration: provision Linux users for existing database members.
# Reads users from PostgreSQL, skips Super Admins (they use the service account),
# and creates Linux users + seeds shell configs for everyone else.
#
# Usage: bash scripts/provision-existing-users.sh
# Requires: sudoers entry from setup.sh, POSTGRES_URL in .env
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; }
skip() { echo -e " - $1 (skipped)"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_DIR/.env"
TEMPLATE_DIR="$PROJECT_DIR/src/servers/api/terminal/templates"
# Load .env
if [ ! -f "$ENV_FILE" ]; then
fail ".env not found at $ENV_FILE"
exit 1
fi
source <(grep -E '^[A-Z_]+=.*' "$ENV_FILE" | sed 's/^/export /')
# Resolve DATA_PATH
DATA_PATH="${DATA_PATH:-$PROJECT_DIR/data}"
# Parse POSTGRES_URL for psql
if [ -z "${POSTGRES_URL:-}" ]; then
fail "POSTGRES_URL not set in .env"
exit 1
fi
# Extract components from postgresql://user:pass@host:port/dbname
PG_USER=$(echo "$POSTGRES_URL" | sed -n 's|.*://\([^:]*\):.*|\1|p')
PG_PASS=$(echo "$POSTGRES_URL" | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p')
PG_HOST=$(echo "$POSTGRES_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
PG_PORT=$(echo "$POSTGRES_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
PG_DB=$(echo "$POSTGRES_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
echo ""
echo "═══════════════════════════════════════════"
echo " Provision existing users"
echo "═══════════════════════════════════════════"
echo ""
echo "DATA_PATH: $DATA_PATH"
echo "Database: $PG_DB @ $PG_HOST:$PG_PORT"
echo ""
# Query non-Super Admin active users
USERS=$(PGPASSWORD="$PG_PASS" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -t -A -F '|' \
-c "SELECT email, COALESCE(username, '') FROM users WHERE role != 'Super Admin' AND status = 'Active';" 2>&1)
if [ $? -ne 0 ]; then
fail "Failed to query database: $USERS"
exit 1
fi
if [ -z "$USERS" ]; then
echo "No non-admin active users found. Nothing to do."
exit 0
fi
echo "── Found users ──"
echo "$USERS" | while IFS='|' read -r email username; do
echo " $email (username: ${username:-<from email>})"
done
echo ""
echo "── Provisioning ──"
while IFS='|' read -r email username; do
# Derive shell username (same logic as toShellUsername in data-path.ts)
if [ -n "$username" ]; then
shell_user=$(echo "$username" | sed 's/@.*$//' | sed 's/[^a-zA-Z0-9._-]/_/g' | tr '[:upper:]' '[:lower:]' | cut -c1-32)
else
shell_user=$(echo "$email" | sed 's/@.*$//' | sed 's/[^a-zA-Z0-9._-]/_/g' | tr '[:upper:]' '[:lower:]' | cut -c1-32)
fi
USER_ROOT="$DATA_PATH/$email"
HOME_DIR="$USER_ROOT/home"
echo ""
echo " [$email$shell_user]"
# Ensure data dirs exist (sudo in case dir is owned by a previous provisioning run)
sudo mkdir -p "$USER_ROOT" "$HOME_DIR"
# Create Linux user if needed
if id "$shell_user" &>/dev/null; then
skip "Linux user $shell_user already exists"
else
if sudo useradd -d "$HOME_DIR" -s /bin/zsh -M "$shell_user"; then
ok "Created Linux user $shell_user"
else
fail "Failed to create Linux user $shell_user"
continue
fi
fi
# Seed shell configs (only if not already present)
if [ ! -f "$HOME_DIR/.zshenv" ] && [ -f "$TEMPLATE_DIR/.zshenv" ]; then
sudo cp "$TEMPLATE_DIR/.zshenv" "$HOME_DIR/.zshenv"
ok "Seeded .zshenv"
else
skip ".zshenv"
fi
if [ ! -f "$HOME_DIR/.zshrc" ] && [ -f "$TEMPLATE_DIR/.zshrc" ]; then
sudo cp "$TEMPLATE_DIR/.zshrc" "$HOME_DIR/.zshrc"
ok "Seeded .zshrc"
else
skip ".zshrc"
fi
if [ ! -f "$HOME_DIR/.tmux.conf" ] && [ -f "$TEMPLATE_DIR/.tmux.conf" ]; then
sudo cp "$TEMPLATE_DIR/.tmux.conf" "$HOME_DIR/.tmux.conf"
ok "Seeded .tmux.conf"
else
skip ".tmux.conf"
fi
sudo mkdir -p "$HOME_DIR/.config"
if [ ! -f "$HOME_DIR/.config/starship-officer.toml" ] && [ -f "$TEMPLATE_DIR/starship-officer.toml" ]; then
sudo cp "$TEMPLATE_DIR/starship-officer.toml" "$HOME_DIR/.config/starship-officer.toml"
ok "Seeded starship config"
else
skip "starship config"
fi
# Oh My Zsh
if [ ! -d "$HOME_DIR/.oh-my-zsh" ]; then
if [ -d "$HOME/.oh-my-zsh" ]; then
sudo cp -r "$HOME/.oh-my-zsh" "$HOME_DIR/.oh-my-zsh"
ok "Copied oh-my-zsh from host"
fi
else
skip "oh-my-zsh"
fi
# LazyVim
sudo mkdir -p "$HOME_DIR/.config"
if [ ! -d "$HOME_DIR/.config/nvim" ]; then
if [ -d "$HOME/.config/nvim" ]; then
sudo cp -r "$HOME/.config/nvim" "$HOME_DIR/.config/nvim"
ok "Copied nvim config from host"
fi
else
skip "nvim config"
fi
# Ensure dirs
sudo mkdir -p "$HOME_DIR/.local/bin"
sudo mkdir -p "$HOME_DIR/.pi/agent/sessions"
# Set ownership and permissions last
sudo chown -R "$shell_user:$shell_user" "$USER_ROOT"
sudo chmod 770 "$USER_ROOT"
# Add service user to this user's group so server jobs can access user data
SERVICE_USER="${SUDO_USER:-$(whoami)}"
if [ "$SERVICE_USER" != "$shell_user" ]; then
sudo usermod -aG "$shell_user" "$SERVICE_USER"
ok "Added $SERVICE_USER to group $shell_user"
fi
ok "Provisioning complete"
done <<< "$USERS"
echo ""
echo "═══════════════════════════════════════════"
echo " Done!"
echo "═══════════════════════════════════════════"
echo ""
+1 -8
View File
@@ -5,14 +5,7 @@ set -euo pipefail
# Run as the user who will own the VNC session (not root).
# Usage: ./scripts/setup-desktop.sh <vnc-password> [resolution]
if [ $# -lt 1 ]; then
echo "Usage: $0 <vnc-password> [resolution]"
echo " vnc-password: 8 characters max (VNC protocol limitation)"
echo " resolution: optional, default 1920x1080"
exit 1
fi
VNC_PASS="$1"
VNC_PASS="${1:-$(head -c 6 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 8)}"
RESOLUTION="${2:-1920x1080}"
USER_NAME="$(whoami)"
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Officer PTY Sidecar — systemd service setup
# Creates and enables a systemd service for the terminal PTY sidecar.
# Usage: bash scripts/setup-pty-sidecar.sh
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
skip() { echo -e " - $1 (already set up)"; }
SERVICE_NAME="officer-pty-sidecar"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
SERVICE_USER="$(whoami)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
NODE_BIN="$(which node)"
echo ""
echo "── PTY Sidecar (systemd service) ──"
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
skip "$SERVICE_NAME service already running"
echo " Use 'bun run restart:sidecar' to restart"
exit 0
fi
sudo tee "$SERVICE_FILE" > /dev/null << EOF
[Unit]
Description=Officer PTY Sidecar
After=network.target
[Service]
Type=simple
User=$SERVICE_USER
WorkingDirectory=$PROJECT_DIR
Environment=TERMINAL_PTY_PORT=5338
ExecStart=$NODE_BIN $PROJECT_DIR/src/servers/api/terminal/pty-sidecar.mjs
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now "$SERVICE_NAME"
if systemctl is-active --quiet "$SERVICE_NAME"; then
ok "$SERVICE_NAME service installed and running"
else
warn "$SERVICE_NAME service failed to start — check 'journalctl -u $SERVICE_NAME'"
fi
+391 -34
View File
@@ -55,8 +55,12 @@ if has git; then skip "git"; else CORE_PKGS+=(git); fi
if has zip; then skip "zip"; else CORE_PKGS+=(zip); fi
if has unzip; then skip "unzip"; else CORE_PKGS+=(unzip); fi
# curl / wget (usually present but just in case)
# curl / wget
if has curl; then skip "curl"; else CORE_PKGS+=(curl); fi
if has wget; then skip "wget"; else CORE_PKGS+=(wget); fi
# zsh
if has zsh; then skip "zsh"; else CORE_PKGS+=(zsh); fi
# psmisc (fuser) and procps (pgrep)
if has fuser; then skip "fuser (psmisc)"; else
@@ -82,11 +86,116 @@ if has script; then skip "script (bsdutils)"; else
esac
fi
# build tools (make, gcc, g++) — needed for native npm modules like node-pty
if has make && has gcc; then skip "build tools (make, gcc, g++)"; else
case $PM in
apt) CORE_PKGS+=(build-essential) ;;
pacman) CORE_PKGS+=(base-devel) ;;
brew) warn "Install Xcode command line tools: xcode-select --install" ;;
esac
fi
# python3 + pip + venv
if has python3; then skip "python3"; else
case $PM in
apt) CORE_PKGS+=(python3 python3-pip python3-venv) ;;
pacman) CORE_PKGS+=(python python-pip) ;;
brew) CORE_PKGS+=(python3) ;;
esac
fi
# ensure pip/venv even if python3 already exists (apt splits them)
if has python3 && [ "$PM" = "apt" ]; then
if ! dpkg -s python3-pip &>/dev/null 2>&1; then CORE_PKGS+=(python3-pip); fi
if ! dpkg -s python3-venv &>/dev/null 2>&1; then CORE_PKGS+=(python3-venv); fi
fi
# shell utilities
for tool in tree btop tmux jq htop lsof duf; do
if has "$tool"; then skip "$tool"; else CORE_PKGS+=("$tool"); fi
done
# sqlite3
if has sqlite3; then skip "sqlite3"; else
case $PM in
apt) CORE_PKGS+=(sqlite3) ;;
pacman) CORE_PKGS+=(sqlite) ;;
brew) CORE_PKGS+=(sqlite) ;;
esac
fi
# ripgrep
if has rg; then skip "ripgrep"; else
case $PM in
apt) CORE_PKGS+=(ripgrep) ;;
pacman) CORE_PKGS+=(ripgrep) ;;
brew) CORE_PKGS+=(ripgrep) ;;
esac
fi
# fd-find
if has fd || has fdfind; then skip "fd-find"; else
case $PM in
apt) CORE_PKGS+=(fd-find) ;;
pacman) CORE_PKGS+=(fd) ;;
brew) CORE_PKGS+=(fd) ;;
esac
fi
# net-tools, less, file, man-db
case $PM in
apt)
for pkg in net-tools less file man-db; do
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
done
;;
pacman)
for pkg in net-tools less file man-db; do
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
done
;;
brew)
skip "net-tools, less, file, man (built-in on macOS)"
;;
esac
# locales
case $PM in
apt)
if dpkg -s locales &>/dev/null 2>&1; then skip "locales"; else CORE_PKGS+=(locales); fi
;;
esac
# ca-certificates
case $PM in
apt)
if dpkg -s ca-certificates &>/dev/null 2>&1; then skip "ca-certificates"; else CORE_PKGS+=(ca-certificates); fi
;;
esac
if [ ${#CORE_PKGS[@]} -gt 0 ]; then
install_pkg "${CORE_PKGS[@]}"
ok "Installed: ${CORE_PKGS[*]}"
fi
# locale generation (ensure en_US.UTF-8)
case $PM in
apt)
if ! locale -a 2>/dev/null | grep -q "en_US.utf8"; then
sudo sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen
sudo locale-gen
ok "Generated en_US.UTF-8 locale"
else
skip "en_US.UTF-8 locale"
fi
;;
esac
# symlink fdfind → fd (apt installs as fdfind)
if has fdfind && ! has fd; then
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd
ok "Symlinked fdfind → fd"
fi
# ─── 2. archive extras (optional but useful) ──────────────────────────────────
echo ""
echo "── Archive utilities (optional) ──"
@@ -127,30 +236,26 @@ else
ok "ffmpeg installed"
fi
# ─── 4. docker ─────────────────────────────────────────────────────────────────
# ─── 4. sudoers for officer service user ──────────────────────────────────────
echo ""
echo "── Docker ──"
echo "── Sudoers (Linux user isolation) ──"
if has docker; then
skip "docker ($(docker --version 2>/dev/null | awk '{print $3}' | tr -d ','))"
SERVICE_USER="$(whoami)"
SUDOERS_FILE="/etc/sudoers.d/officer-service"
if [ -f "$SUDOERS_FILE" ] && grep -q "$SERVICE_USER" "$SUDOERS_FILE" 2>/dev/null; then
skip "sudoers entry for $SERVICE_USER"
else
case $PM in
apt)
warn "Installing docker.io from apt"
sudo apt install -y docker.io
sudo usermod -aG docker "$USER"
warn "You may need to log out/in for docker group to take effect"
;;
pacman)
sudo pacman -S --noconfirm docker
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"
apt|pacman)
echo "$SERVICE_USER ALL=(ALL) NOPASSWD: ALL" | sudo tee "$SUDOERS_FILE" > /dev/null
sudo chmod 0440 "$SUDOERS_FILE"
ok "Created sudoers entry for $SERVICE_USER at $SUDOERS_FILE"
;;
brew)
warn "Install Docker Desktop from https://www.docker.com/products/docker-desktop"
warn "Sudoers setup is Linux-only — skipping on macOS"
;;
esac
if has docker; then ok "docker installed"; else warn "docker not found — install manually"; fi
fi
# ─── 5. Node.js 22 ────────────────────────────────────────────────────────────
@@ -172,6 +277,45 @@ else
warn " nvm install 22"
fi
# ─── npm global prefix ─────────────────────────────────────────────────────────
# Set npm global prefix to a user-writable directory so npm install -g
# never requires sudo. This also lets the server install packages at runtime.
echo ""
echo "── npm global prefix ──"
if has npm; then
NPM_PREFIX=$(npm config get prefix 2>/dev/null)
NPM_GLOBAL="$HOME/.npm-global"
if [ "$NPM_PREFIX" = "$NPM_GLOBAL" ]; then
skip "npm prefix already set to $NPM_GLOBAL"
else
mkdir -p "$NPM_GLOBAL"
npm config set prefix "$NPM_GLOBAL"
export PATH="$NPM_GLOBAL/bin:$PATH"
ok "Set npm global prefix to $NPM_GLOBAL"
# Migrate existing global packages from system prefix if any
if [ -d "$NPM_PREFIX/lib/node_modules/@mariozechner" ] || [ -d "$NPM_PREFIX/lib/node_modules/@anthropic-ai" ]; then
warn "Removing stale system-level npm packages from $NPM_PREFIX (will reinstall to $NPM_GLOBAL)"
sudo rm -rf "$NPM_PREFIX/lib/node_modules/@mariozechner" "$NPM_PREFIX/lib/node_modules/@anthropic-ai" 2>/dev/null
sudo rm -f "$NPM_PREFIX/bin/pi" "$NPM_PREFIX/bin/claude" 2>/dev/null
fi
fi
# Ensure ~/.npm-global/bin is in shell profiles
for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
if [ -f "$profile" ] && ! grep -q '.npm-global/bin' "$profile"; then
echo '' >> "$profile"
echo '# npm global packages' >> "$profile"
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$profile"
ok "Added ~/.npm-global/bin to $(basename "$profile")"
fi
done
else
warn "npm not found — skipping prefix setup"
fi
# ─── 6. Bun ───────────────────────────────────────────────────────────────────
echo ""
echo "── Bun ──"
@@ -189,14 +333,45 @@ fi
echo ""
echo "── Go ──"
GOLANG_VERSION=1.23.6
if has go; then
skip "go ($(go version 2>/dev/null | awk '{print $3}'))"
else
install_pkg golang-go 2>/dev/null || install_pkg go 2>/dev/null || install_pkg golang 2>/dev/null
case $PM in
apt|pacman)
echo " Installing Go ${GOLANG_VERSION} from official tarball..."
ARCH=$(uname -m)
case $ARCH in
x86_64) GO_ARCH=amd64 ;;
aarch64) GO_ARCH=arm64 ;;
*) GO_ARCH=amd64 ;;
esac
curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz
sudo tar -C /usr/local -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
export PATH="/usr/local/go/bin:$PATH"
;;
brew)
brew install go
;;
esac
if has go; then ok "go installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi
fi
# ─── 8. PulseAudio (headless audio for cliamp) ────────────────────────────────
# ─── 8. Rust ──────────────────────────────────────────────────────────────────
echo ""
echo "── Rust ──"
if has rustc && has cargo; then
skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))"
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
export PATH="$HOME/.cargo/bin:$PATH"
if has rustc; then ok "rust installed"; else warn "rust install failed"; fi
fi
# ─── 9. PulseAudio (headless audio for cliamp) ────────────────────────────────
echo ""
echo "── PulseAudio (headless audio) ──"
@@ -235,7 +410,7 @@ if [ ${#PULSE_PKGS[@]} -gt 0 ]; then
ok "Installed: ${PULSE_PKGS[*]}"
fi
# ─── 9. cliamp (music player) ────────────────────────────────────────────────
# ─── 10. cliamp (music player) ────────────────────────────────────────────────
echo ""
echo "── cliamp ──"
@@ -261,7 +436,114 @@ else
fi
fi
# ─── 10. yt-dlp (optional — video/audio download) ─────────────────────────────
# ─── 11. Neovim ──────────────────────────────────────────────────────────────
echo ""
echo "── Neovim ──"
if has nvim; then
skip "neovim ($(nvim --version 2>/dev/null | head -1))"
else
case $PM in
apt)
echo " Installing Neovim from GitHub releases..."
ARCH=$(uname -m)
case $ARCH in
x86_64) NVIM_ARCH=x86_64 ;;
aarch64) NVIM_ARCH=aarch64 ;;
*) NVIM_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" -o /tmp/nvim.tar.gz
sudo tar -C /opt -xzf /tmp/nvim.tar.gz
sudo ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim
rm /tmp/nvim.tar.gz
;;
pacman) install_pkg neovim ;;
brew) install_pkg neovim ;;
esac
if has nvim; then ok "neovim installed"; else warn "neovim install failed"; fi
fi
# LazyVim starter config
if [ -d "$HOME/.config/nvim" ]; then
skip "nvim config (already exists at ~/.config/nvim)"
else
echo " Installing LazyVim starter config..."
git clone --depth 1 https://github.com/LazyVim/starter "$HOME/.config/nvim"
rm -rf "$HOME/.config/nvim/.git"
ok "LazyVim starter installed at ~/.config/nvim"
fi
# ─── 12. Terminal tools ──────────────────────────────────────────────────────
echo ""
echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──"
# Starship prompt
if has starship; then
skip "starship"
else
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
fi
# Oh-My-Zsh
if [ -d "$HOME/.oh-my-zsh" ]; then
skip "oh-my-zsh (already at ~/.oh-my-zsh)"
else
git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git "$HOME/.oh-my-zsh"
ok "oh-my-zsh installed at ~/.oh-my-zsh"
fi
# eza
EZA_VERSION=0.18.15
if has eza; then
skip "eza"
else
case $PM in
apt)
ARCH=$(uname -m)
case $ARCH in
x86_64) EZA_ARCH=x86_64 ;;
aarch64) EZA_ARCH=aarch64 ;;
*) EZA_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${EZA_ARCH}-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz
tar -xzf /tmp/eza.tar.gz -C /tmp
sudo mv /tmp/eza /usr/local/bin/eza
sudo chmod +x /usr/local/bin/eza
rm -rf /tmp/eza.tar.gz
;;
pacman) install_pkg eza ;;
brew) install_pkg eza ;;
esac
if has eza; then ok "eza installed"; else warn "eza install failed"; fi
fi
# lazygit
LAZYGIT_VERSION=0.44.1
if has lazygit; then
skip "lazygit"
else
case $PM in
apt)
ARCH=$(uname -m)
case $ARCH in
x86_64) LG_ARCH=x86_64 ;;
aarch64) LG_ARCH=arm64 ;;
*) LG_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${LG_ARCH}.tar.gz" -o /tmp/lazygit.tar.gz
tar -xzf /tmp/lazygit.tar.gz -C /tmp
sudo mv /tmp/lazygit /usr/local/bin/lazygit
sudo chmod +x /usr/local/bin/lazygit
rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
;;
pacman) install_pkg lazygit ;;
brew) install_pkg lazygit ;;
esac
if has lazygit; then ok "lazygit installed"; else warn "lazygit install failed"; fi
fi
# ─── 13. yt-dlp (optional — video/audio download) ────────────────────────────
echo ""
echo "── yt-dlp (optional) ──"
@@ -275,22 +557,58 @@ else
esac
fi
# ─── 11. npm dependencies ─────────────────────────────────────────────────────
# ─── 14. npm global packages ─────────────────────────────────────────────────
echo ""
echo "── npm global packages ──"
if has npm; then
# Pi (coding agent)
if has pi; then
skip "pi (@mariozechner/pi-coding-agent)"
else
npm install -g @mariozechner/pi-coding-agent
if has pi; then ok "pi installed"; else warn "pi install failed"; fi
fi
# Fix ~/.pi ownership (previous installs via sudo may have created root-owned dirs)
if [ -d "$HOME/.pi" ]; then
if find "$HOME/.pi" -not -user "$USER" -print -quit 2>/dev/null | grep -q .; then
sudo chown -R "$USER:$(id -gn)" "$HOME/.pi"
ok "Fixed ~/.pi ownership"
fi
fi
# Patch Pi compaction bug: calculateContextTokens crashes when usage is undefined
PI_COMPACTION="$(npm root -g 2>/dev/null)/@mariozechner/pi-coding-agent/dist/core/compaction/compaction.js"
if [ -f "$PI_COMPACTION" ]; then
if grep -q "if (!usage) return 0;" "$PI_COMPACTION" 2>/dev/null; then
skip "pi compaction patch (already applied)"
else
sed -i '/^export function calculateContextTokens(usage) {$/a\ if (!usage) return 0;' "$PI_COMPACTION"
ok "Applied pi compaction bug patch"
fi
fi
# Claude Code
if has claude; then
skip "claude (@anthropic-ai/claude-code)"
else
npm install -g @anthropic-ai/claude-code
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
fi
# pm2 (process manager)
if has pm2; then
skip "pm2"
else
npm install -g pm2
if has pm2; then ok "pm2 installed"; else warn "pm2 install failed"; fi
fi
else
warn "npm not found — skipping global package installs"
fi
# ─── 12. bun install (project dependencies) ──────────────────────────────────
# ─── 15. bun install (project dependencies) ──────────────────────────────────
echo ""
echo "── Project dependencies ──"
@@ -305,6 +623,26 @@ else
warn "Skipping bun install (bun not found or not in project dir)"
fi
# ─── 16. remote desktop (XFCE + VNC) ────────────────────────────────────────
echo ""
echo "── Remote Desktop (XFCE + VNC) ──"
if systemctl is-active --quiet officer-vnc 2>/dev/null; then
skip "remote desktop (officer-vnc service already running)"
else
case $PM in
apt)
bash "$SCRIPT_DIR/setup-desktop.sh"
;;
*)
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
;;
esac
fi
# ─── 17. PTY sidecar (systemd service) ──────────────────────────────────────
bash "$SCRIPT_DIR/setup-pty-sidecar.sh"
# ─── verification ─────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
@@ -321,19 +659,45 @@ check git
check node
check bun
check npm
check docker
check ffmpeg
check zip
check script
check python3
check make
check gcc
echo ""
echo "Dev tools:"
check go
check rustc
check cargo
check nvim
check zsh
check starship
check lazygit
check eza
check rg
check fd
check jq
check htop
check tmux
check tree
check btop
check sqlite3
echo ""
echo "Audio (cliamp):"
check go
check pulseaudio
check parec
check pactl
check cliamp
echo ""
echo "AI agents:"
check pi
check claude
check pm2
echo ""
echo "Optional:"
check unzip
@@ -342,13 +706,6 @@ check unrar
check pgrep
check fuser
check yt-dlp
check pi
echo ""
echo "Installable from Settings UI:"
for tool in claude opencode; do
if has "$tool"; then ok "$tool"; else echo " - $tool (install from Settings > Applications)"; fi
done
echo ""
echo "═══════════════════════════════════════════"
@@ -358,6 +715,6 @@ echo ""
echo "Notes:"
echo " • PulseAudio null sink starts automatically with the server"
echo " • Make sure ~/go/bin is in your PATH for cliamp"
echo " • Claude, opencode, sharp, whisper-cpp, mlx-audio"
echo " can be installed from the Settings > Applications page"
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
echo ""
@@ -0,0 +1,276 @@
# Automation
## Overview
The `/automation` route is the hub for managing AI agent capabilities: skills, tools, tasks, and processes. Each capability is a markdown file (with YAML frontmatter) stored on the filesystem. There is no relational database for automation data.
The page uses a resizable `WorkspaceView` layout with three panels: a sidebar to pick categories, a right panel for browsing/viewing, and an optional bottom-right chat panel for AI-assisted editing.
```
+------------------+------------------------------------------+
| Sidebar (20%) | Right Panel (80%) |
| | - CapabilityList (browse) |
| 8 categories | - CapabilityDetailView (selected item) |
| | - New* form (creating) |
| +------------------------------------------+
| | AutomationEditChat (when editing, 50%) |
+------------------+------------------------------------------+
```
## Route & Entry Point
Defined in `App.tsx`:
```tsx
<Route path="/automation" element={<Dashboard.Automation />} />
<Route path="/new-automation" element={<Dashboard.NewAutomation />} /> // stub, not implemented
```
Entry component: `index.tsx` (`Automation`). Manages the `WorkspaceView` layout and dynamically adds/removes the chat panel based on `selection.editing`.
## Capability Categories
| Category | Kind | Backend Route | Status |
|----------|------|---------------|--------|
| Skills | `Skill` | `GET/POST/DELETE /api/skills` | Working |
| Tools | `Tool` | `GET/POST/DELETE /api/tools` | Working |
| Tasks | `Task` | `GET/POST/DELETE /api/tasks` | Working |
| Processes | `Process` | `GET/POST/DELETE /api/processes` | Working |
| Pipelines | `Pipeline` | None | Placeholder (404) |
| Workflows | `Workflow` | None | Placeholder (404) |
| Crons | `Cron` | None | Placeholder (404) |
| Services | `Service` | None | Placeholder (404) |
Defined as `capabilityItems` in `AutomationSidebar.tsx`.
## State Management
All cross-panel communication uses a single shared channel:
```ts
usePanelChannel<AutomationSelection>('automation:selected-capability', null)
```
`AutomationSelection` (defined in `AutomationRightPanel.tsx`):
```ts
type AutomationSelection = {
kind: string; // 'Skill' | 'Task' | 'Tool' | 'Process' | etc.
endpoint: string; // '/skills' | '/tasks' | etc.
queryKey: string; // React Query cache key
dirName: string; // '' = list view, non-empty = detail view
isNew?: boolean; // just created, AI chat opens in creation mode
editing?: boolean; // chat panel is open
creating?: boolean; // New* form is shown
description?: string; // passed to AI as initial context
} | null;
```
Layout state persisted via `useDashboardState('screens/automation')`.
## Components
### `AutomationSidebar.tsx`
Left nav with the 8 category buttons. Clicking sets selection to `{ kind, endpoint, queryKey, dirName: '' }` (list view).
### `AutomationRightPanel.tsx`
Routes to one of three views based on selection state:
- `creating === true` -> `New*` form (mapped via `newComponentMap[kind]`)
- `dirName === ''` -> `CapabilityList` (browse items)
- `dirName !== ''` -> `CapabilityDetailView` (detail for specific item)
### `CapabilityList.tsx`
Fetches items from `GET {endpoint}`, renders filterable list. Has `+` button to set `creating: true`.
### `CapabilityDetailView.tsx`
Shows a selected capability's detail (frontmatter + markdown body). Header actions:
- **Back arrow** - returns to list view
- **Run** (tasks only) - parses `inputs:` from frontmatter YAML, opens `TaskRunnerModal`
- **Edit** - toggles `selection.editing` to open/close chat panel
- **Delete** - confirmation dialog, then `DELETE {endpoint}/{dirName}`
Task input parsing (`parseInputs`) supports: `string`, `number`, `boolean`, `select` types.
### `AutomationEditChat.tsx`
Bottom chat panel for AI-assisted editing. Uses `CapabilityChat` from `CapabilityPage.tsx`. Features:
- Delete chat history button (`DELETE {endpoint}/{dirName}/chat`)
- Close button (sets `editing: false`)
- On AI response end, invalidates both individual and list query caches
### `New*.tsx` (8 components)
`NewTask`, `NewSkill`, `NewTool`, `NewProcess`, `NewPipeline`, `NewCron`, `NewService`, `NewWorkflow`. All structurally identical:
1. Name + description form
2. `POST {endpoint}` with `{ name }`
3. On success: transitions to detail view with `isNew: true, editing: true` (opens AI chat in creation mode)
## Shared Components (`CapabilityPage.tsx`)
Located at `../CapabilityPage.tsx`. Exports used by automation:
- **`CapabilityChat`** - AI chat wired to Pi agent via `usePiChat()` + `EmbeddableChat`. Injects `promptFrontmatter` prefix before every message. For `isNew === true`, uses rich creation guides (`buildTaskCreationPrefix`, `buildSkillCreationPrefix`, `buildToolCreationPrefix`).
- **`FrontmatterBlock`** - Collapsible YAML frontmatter display.
- **`CapabilityDetail`** type - `{ dirName, name, description, scope, body, rawFrontmatter, filePath, chatSessionId }`.
- **`CapabilityPage`** - Legacy standalone two-panel page (used by `/skills`, `/tasks`, `/processes` routes, not by `/automation`).
## Backend
All four working routers (`skills`, `tools`, `tasks`, `processes`) follow the exact same pattern. Registered on the protected router in `hono.ts`.
### API Endpoints (per capability type)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/{type}` | List all items (native + global + user merged) |
| `GET` | `/{type}/:name` | Get detail: frontmatter, body, rawYaml, filePath, chatSessionId |
| `POST` | `/{type}` | Create new item (dir + stub `.md`) |
| `DELETE` | `/{type}/:name` | Delete item directory |
| `GET` | `/{type}/:name/chat` | Get saved chat messages |
| `PUT` | `/{type}/:name/chat` | Save chat messages + session meta |
| `DELETE` | `/{type}/:name/chat` | Delete chat directory |
Route files:
- `src/servers/api/skills/skills.ts`
- `src/servers/api/tools/tools.ts`
- `src/servers/api/tasks/tasks.ts` (also parses `trigger:` block from frontmatter)
- `src/servers/api/processes/processes.ts`
### Task Logs
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/task-logs` | List all log metadata (newest first) |
| `GET` | `/task-logs/:filename` | Get full log with messages |
Route file: `src/servers/api/task-logs/task-logs.ts`
## Storage Model
Everything is stored on the filesystem. No database tables.
```
DATA_PATH/
skills/ <- global scope
{skill-name}/
SKILL.md <- frontmatter + markdown body
chat/
meta.json <- { id: sessionId }
messages.json <- ChatMessage[]
tasks/ <- global scope
{task-name}/TASK.md
tools/ <- global scope
{tool-name}/TOOL.md
processes/ <- global scope
{process-name}/PROCESS.md
{user-email}/ <- user scope
skills/
tasks/
tools/
processes/
logs/tasks/ <- task execution logs
{timestamp}-{dirName}.json
SEED_PATH/ <- native scope (read-only, shipped with app)
skills/ (sharp, whisper-cpp, google-mail-api, ffmpeg, mutagen, fizzy-cli, mlxaudio)
tasks/ (convert-to-mp3, sync-gmail-inbox, tiktok-trends, transcribe-audio-file, ...)
tools/ (apify, browser, email-db, ffmpeg, gmail, ocr, web-fetch, web-search, ...)
```
Path helpers: `src/servers/data-path.ts` (`getNativeSkillsDir`, `getGlobalSkillsDir`, `getUserSkillsDir`, etc.)
### Scope & Permissions
Three tiers with override resolution: **user > global > native**.
- `native`: seed directory, read-only, shipped with the app
- `global`: shared data directory, writable by Super Admin
- `user`: per-user directory, writable by the owning user
Regular users (`Member`) can only create/delete `user`-scoped items. `Super Admin` can also create/delete `global` items.
## Markdown File Formats
### TASK.md
```yaml
---
name: Task Name
description: What the task does.
version: 1
author: pastilhas
tags: [tag1, tag2]
skills: [skill-name] # optional, skills the agent can consult
tools: [tool_name] # optional, tools the agent can call
trigger: # optional, when omitted: only runnable from Automation page
- type: file
extensions: [mp3, flac]
- type: directory
inputs: # optional, parameters the user fills in before running
- name: param_name
type: string # string | number | boolean | select
required: true
default: some value
---
(Agent instructions in markdown)
```
### SKILL.md
```yaml
---
name: skill-name
description: When to use this skill.
---
(Knowledge base / reference documentation in markdown)
```
### TOOL.md
```yaml
---
name: tool_name
label: Tool Display Name
description: What the tool does.
language: typescript # typescript | bash | python
inputs:
param_name:
type: string # string | number | boolean | enum | object
description: What this parameter is for.
optional: true
sensitive: true
---
(Usage notes, output format, examples in markdown)
```
### PROCESS.md
Same structure as SKILL.md (name + description frontmatter, markdown body).
## Task Execution Flow
1. User clicks "Run" on a task in `CapabilityDetailView`
2. `parseInputs()` extracts `inputs:` from YAML frontmatter
3. If inputs exist: shows input form dialog; if none: runs immediately
4. Opens `TaskRunnerModal` with prompt: `"Read the task instructions at {filePath} and execute them"` (+ input values if any)
5. Modal connects to Pi agent WebSocket via `usePiChat()`
6. Agent reads the `TASK.md`, resolves skill/tool references, executes in the user's sandboxed container
7. `task-logger.ts` (`createTaskLog` -> `appendToLog` -> `finalizeLog`) writes execution log to `DATA_PATH/{email}/logs/tasks/`
## Task Trigger Integration
Tasks with `trigger:` in their frontmatter appear in the file browser context menu:
- `type: file` + `extensions: [mp3]` -> right-click on `.mp3` files shows this task
- `type: directory` -> right-click on directories shows this task
Implemented via `useTasks` hook in the `officerdev` workspace, which provides `getMatchingTasks(fileName, entryType)`.
## Seed Sync (Startup)
On server startup (`bootstrap.ts`):
- `syncSeedSkills()` and `syncSeedTools()` copy/update native seeds to global based on `version:` in frontmatter
- `syncSeedTasks()` exists in code but is **not called** in bootstrap
## Legacy Routes
Standalone pages still exist using the older `CapabilityPage` component:
- `/skills` -> `CapabilityPage` (two-panel, list + detail/chat)
- `/tasks` -> `CapabilityPage`
- `/processes` -> `CapabilityPage`
These share the same API endpoints but use the single-component layout instead of the workspace view.
@@ -44,15 +44,12 @@ const PI_PROVIDERS: { key: string; piId: string }[] = [
{ key: 'MiniMax', piId: 'minimax' },
{ key: 'Hugging Face', piId: 'huggingface' },
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
{ key: 'OpenCode Zen', piId: 'zai' },
{ key: 'OpenCode Zen', piId: 'opencode' },
{ key: 'ZAI', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
type ProbeState =
| { step: 'url' }
| { step: 'probing' }
| { step: 'auth'; probe: ProbeResult }
| { step: 'saving' };
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
export const AIHarnessesSection = () => {
const client = useClient();
@@ -119,8 +116,7 @@ export const AIHarnessesSection = () => {
refetchInterval: 30_000,
});
const getStoredMasked = (piId: string) =>
storedKeys.find((k) => k.provider === piId)?.value ?? '';
const getStoredMasked = (piId: string) => storedKeys.find((k) => k.provider === piId)?.value ?? '';
const saveApiKey = async (piId: string) => {
const value = keyInputs[piId];
@@ -142,7 +138,7 @@ export const AIHarnessesSection = () => {
}
};
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
const disconnectProvider = async (provider: (typeof PI_PROVIDERS)[number]) => {
await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_REMOTE_HEALTH'] });
@@ -160,7 +156,9 @@ export const AIHarnessesSection = () => {
}
};
const handleProbe = async (auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string }) => {
const handleProbe = async (
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string },
) => {
setProbeState({ step: 'probing' });
try {
const result = await client.post<ProbeResult>('/server-settings/pi-mono/local-providers/probe', {
@@ -185,7 +183,8 @@ export const AIHarnessesSection = () => {
};
const handleAuthSubmit = async (probe: ProbeResult) => {
const auth = probe.authType === 'basic'
const auth =
probe.authType === 'basic'
? { type: 'basic' as const, username: authUsername, password: authPassword }
: { type: 'api-key' as const, apiKey: authApiKey };
@@ -372,7 +371,8 @@ export const AIHarnessesSection = () => {
value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && authUsername && authPassword) handleAuthSubmit(probeState.probe);
if (ev.key === 'Enter' && authUsername && authPassword)
handleAuthSubmit(probeState.probe);
}}
/>
<Button
@@ -502,12 +502,15 @@ export const AIHarnessesSection = () => {
)}
</div>
))}
{editingProvider && (() => {
{editingProvider &&
(() => {
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null;
return (
<div key={provider.piId} className="flex items-center gap-2">
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
{provider.key}
</label>
<Input
type="password"
className="h-7 text-xs flex-1"
@@ -30,6 +30,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
zai: 'ZAI',
google: 'Google',
groq: 'Groq',
mistral: 'Mistral',
@@ -52,12 +53,42 @@ const groups: SettingsSectionGroup[] = [
label: 'AI',
icon: Bot,
sections: [
{ key: 'ai-harnesses', icon: Terminal, title: 'Providers', description: 'Remote and local AI providers', content: <AIHarnessesSection /> },
{ key: 'model-visibility', icon: Eye, title: 'Models', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
{ key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: <ChatDefaultsSection /> },
{ key: 'tts', icon: Volume2, title: 'Text to Speech', description: 'TTS provider and voice', content: <TTSSection /> },
{
key: 'ai-harnesses',
icon: Terminal,
title: 'Providers',
description: 'Remote and local AI providers',
content: <AIHarnessesSection />,
},
{
key: 'model-visibility',
icon: Eye,
title: 'Models',
description: 'Enable or disable models',
content: <ModelVisibilitySection />,
},
{
key: 'chat-defaults',
icon: Terminal,
title: 'Chat Defaults',
description: 'Model, prompt, and temperature',
content: <ChatDefaultsSection />,
},
{
key: 'tts',
icon: Volume2,
title: 'Text to Speech',
description: 'TTS provider and voice',
content: <TTSSection />,
},
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> },
{ key: 'ocr', icon: ScanText, title: 'OCR', description: 'Vision model for text extraction', content: <OCRSection /> },
{
key: 'ocr',
icon: ScanText,
title: 'OCR',
description: 'Vision model for text extraction',
content: <OCRSection />,
},
],
},
{
@@ -142,7 +173,10 @@ const SystemTerminalPanel = () => {
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
<button onClick={close} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
<button
onClick={close}
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
@@ -325,7 +359,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
return (
<div className="grid gap-1.5">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span>
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
{label}
</span>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -376,7 +412,7 @@ function ModelVisibilitySection() {
const providers = useMemo(() => providerGroups.map((g) => g.provider), [providerGroups]);
// Auto-select first provider if none selected or stale
const selected = providers.includes(activeProvider) ? activeProvider : providers[0] ?? '';
const selected = providers.includes(activeProvider) ? activeProvider : (providers[0] ?? '');
const currentGroup = providerGroups.find((g) => g.provider === selected);
@@ -430,7 +466,11 @@ function ModelVisibilitySection() {
}, []);
if (providerGroups.length === 0) {
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">No models available. Configure API keys in AI Settings.</p>;
return (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in AI Settings.
</p>
);
}
return (
@@ -463,17 +503,35 @@ function ModelVisibilitySection() {
{/* Enabled section */}
<DropZone label="Enabled" onDrop={enableModel}>
{enabled.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>}
{enabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>
)}
{enabled.map((m) => (
<ModelPill key={m.key} label={m.name} modelKey={m.key} enabled onAction={disableModel} onDragStart={onDragStart} />
<ModelPill
key={m.key}
label={m.name}
modelKey={m.key}
enabled
onAction={disableModel}
onDragStart={onDragStart}
/>
))}
</DropZone>
{/* Disabled section */}
<DropZone label="Disabled" onDrop={disableModel}>
{disabled.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>}
{disabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>
)}
{disabled.map((m) => (
<ModelPill key={m.key} label={m.name} modelKey={m.key} enabled={false} onAction={enableModel} onDragStart={onDragStart} />
<ModelPill
key={m.key}
label={m.name}
modelKey={m.key}
enabled={false}
onAction={enableModel}
onDragStart={onDragStart}
/>
))}
</DropZone>
</div>
+2 -35
View File
@@ -122,7 +122,7 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi'
const url = new URL(req.url);
const sessionId = url.searchParams.get('sessionId') ?? undefined;
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
const sandboxed = user.role !== 'Super Admin';
const cwd = url.searchParams.get('cwd') ?? undefined;
const command = url.searchParams.get('command') ?? undefined;
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
@@ -272,37 +272,4 @@ void initTerminalSidecars();
}
})();
// Ensure pi is installed
(async () => {
try {
const check = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(check.stdout).text();
await check.exited;
if (check.exitCode === 0) {
console.log(`[pi] found: ${output.trim()}`);
return;
}
} catch {
// not found
}
console.log('[pi] not found, installing...');
try {
const install = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], {
stdout: 'pipe',
stderr: 'pipe',
});
const stderr = await new Response(install.stderr).text();
await install.exited;
if (install.exitCode !== 0) {
console.error('[pi] install failed:', stderr.trim());
return;
}
const ver = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const version = await new Response(ver.stdout).text();
await ver.exited;
console.log(`[pi] installed: ${version.trim()}`);
} catch (err) {
console.error('[pi] install failed:', err);
}
})();
// Pi check/install is handled by bootstrap.ts (imported above)
+6
View File
@@ -6,6 +6,7 @@ import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
import { validateUsername } from './validate-username';
import { provisionLinuxUser } from '../users/provision';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
@@ -42,6 +43,11 @@ export const verifyHandler: Handler = async function (ctx) {
const finalUser = await getUserById(userInfo.id);
if (!finalUser) throw errors.NOT_FOUND('User not found');
// Provision Linux user for terminal/Pi/Claude Code isolation
provisionLinuxUser(finalUser.email, finalUser.username ?? '').catch((err) => {
console.error('[verify] failed to provision Linux user:', err);
});
// Issue a token so the user is logged in immediately
const token = await sign({
id: finalUser.id,
@@ -0,0 +1,133 @@
# Snap Node Compatibility Issue
## Problem
When using Officer with **snap node** (`/snap/bin/node`), the Pi harness fails with the following error:
```
[Pi] [INFO] Pi process exited
code=1
```
This occurs on the first chat message, before Pi even processes the command.
## Root Cause
The snap version of Node.js has an incompatibility with how Bun's `spawn()` function sets up piped stdin file descriptors. When Officer tries to spawn a Pi process with `stdin: 'pipe'`, the process immediately exits with code 1, preventing the RPC communication from working.
This does **NOT** happen with:
- System node installed via apt/package manager
- NodeSource node
- Homebrew node (on macOS)
- Any non-snap Node.js installation
## Solution
**Uninstall snap node and install a system-managed version instead:**
### Step 1: Remove snap node
```bash
sudo snap remove node
```
### Step 2: Install Node.js via apt (recommended)
```bash
# Add NodeSource repository for Node 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# Install Node.js
sudo apt-get install -y nodejs
# Verify installation
node --version
which node # Should be /usr/bin/node (NOT /snap/bin/node)
```
### Alternative: Using system apt repository
If NodeSource is unavailable in your region:
```bash
sudo apt-get update
sudo apt-get install -y nodejs npm
```
### Alternative: Using nvm (Node Version Manager)
For more control over Node.js versions:
```bash
# Install nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
# Install Node LTS
nvm install --lts
nvm use --lts
# Verify
which node # Should be ~/.nvm/versions/node/*/bin/node
```
## Verification
After installing a non-snap Node.js:
```bash
# Verify node is not from snap
which node
# Output should NOT contain "/snap/"
# Verify node works
node --version
# Clear npm cache
npm cache clean --force
npm install -g pi-coding-agent
```
Then restart Officer and try the chat functionality again - it should work!
## Troubleshooting
### Still seeing the error after reinstalling Node?
1. **Restart Officer service** (if running as a service):
```bash
sudo systemctl restart officer
# or
bun dev # if running locally
```
2. **Verify Bun can find the correct node**:
```bash
bun run "which node"
which node
# Both should show the same path, not /snap/bin/node
```
3. **Check Pi installation**:
```bash
pi --version
pi --list-models
```
### Error logs to look for
If you still see the error, check Officer logs for:
```
Pi process exited with code 1
nodeVersion: ...
nodeExePath: /snap/bin/node
isSnapNode: true
```
This confirms snap node is the issue.
## Why snap node has this issue
The snap package environment isolates certain system calls and file descriptor handling, which conflicts with Bun's pipe setup mechanism. The snap version of Node.js doesn't properly inherit file descriptor flags when pipes are created by Bun, causing the process to fail on startup.
This is a known incompatibility and not a bug in Officer or Pi itself.
+16 -11
View File
@@ -40,23 +40,30 @@ export async function listPiModels(): Promise<ModelInfo[]> {
}
try {
const proc = Bun.spawn(['pi', '--list-models'], {
// Resolve absolute path to pi binary (PATH may differ under pm2/systemd)
const piBin = (() => {
const r = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
return r.stdout.toString().trim() || 'pi';
})();
// Use spawnSync — Bun.spawn (async) loses stdout under pm2
const proc = Bun.spawnSync({
cmd: [piBin, '--list-models'],
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
});
const output = await new Response(proc.stdout).text();
await proc.exited;
const output = proc.stdout.toString();
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderr.trim() });
return [];
const stderrText = proc.stderr.toString();
logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderrText.trim(), piBin });
return [CLAUDE_CODE_MODEL];
}
const lines = output.trim().split('\n');
if (lines.length < 2) return [];
if (lines.length < 2) return [CLAUDE_CODE_MODEL];
// Parse fixed-width table: provider, model, context, max-out, thinking, images
const header = lines[0]!;
@@ -89,16 +96,14 @@ export async function listPiModels(): Promise<ModelInfo[]> {
const thinking = extractCol(line, 4);
const images = extractCol(line, 5);
// zai and opencode are the same service — prefer opencode, skip zai duplicates
const displayProvider = provider === 'zai' ? 'opencode' : provider;
const dedupeKey = `${displayProvider}/${model}`;
const dedupeKey = `${provider}/${model}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
models.push({
id: `${provider}/${model}`,
name: model,
provider: displayProvider,
provider,
contextWindow: parseSize(context),
maxTokens: parseSize(maxOut),
reasoning: thinking === 'yes',
+145 -160
View File
@@ -1,34 +1,56 @@
import { join, relative } from "path";
import { homedir } from "node:os";
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readSearxngConfig } from "../server-settings/searxng";
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket";
import { getServerIntegration, getUserIntegration } from "officerdb";
import { logger } from "./logger";
import { parseFrontmatter } from "../skills/skills";
import { getRelayPort } from "../browser/relay";
import { registerUserToken } from "../browser/relay-auth";
import { join } from 'path';
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { PiEvent, MessageCost } from './types';
import { readSearxngConfig } from '../server-settings/searxng';
import {
PI_CONFIG_DIR,
DATA_PATH,
getHomeDir,
getGlobalSkillsDir,
getUserSkillsDir,
getGlobalExtensionsDir,
getUserExtensionsDir,
getGlobalToolsDir,
getUserToolsDir,
getNativeResourcesDir,
getGlobalResourcesDir,
toShellUsername,
} from '../../data-path';
import { getServerIntegration, getUserIntegration } from 'officerdb';
import { logger } from './logger';
import { parseFrontmatter } from '../skills/skills';
import { getRelayPort } from '../browser/relay';
import { registerUserToken } from '../browser/relay-auth';
// Resolve pi as [node, cli.js] — Bun.spawn async pipes break with shebang scripts under pm2
const PI_CMD = (() => {
const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
const piBin = whichResult.stdout.toString().trim() || 'pi';
// Follow symlink to get the actual .js file, then invoke via node directly
const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' });
const realPath = readlinkResult.stdout.toString().trim();
const nodeResult = Bun.spawnSync({ cmd: ['which', 'node'], stdout: 'pipe', stderr: 'ignore' });
const nodeBin = nodeResult.stdout.toString().trim() || 'node';
if (realPath && realPath.endsWith('.js')) {
return [nodeBin, realPath];
}
// Fallback: use pi binary directly (works for non-pm2 environments)
return [piBin];
})();
export type PiEventHandler = (event: PiEvent) => void;
type PathOverrides = { global: string; user: string };
function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] {
function collectSkillFlags(email: string): string[] {
const flags: string[] = [];
const pairs: Array<[hostDir: string, outputDir: string]> = [
[getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()],
[getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)],
];
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
for (const [hostDir, outputDir] of pairs) {
if (!existsSync(hostDir)) continue;
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
for (const dir of dirs) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) {
flags.push('--skill', `${outputDir}/${entry.name}`);
if (existsSync(join(dir, entry.name, 'SKILL.md'))) {
flags.push('--skill', `${dir}/${entry.name}`);
}
}
}
@@ -36,19 +58,16 @@ function collectSkillFlags(email: string, containerPaths?: PathOverrides): strin
return flags;
}
function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] {
function collectExtensionFlags(email: string): string[] {
const flags: string[] = [];
const pairs: Array<[hostDir: string, outputDir: string]> = [
[getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()],
[getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)],
];
const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)];
for (const [hostDir, outputDir] of pairs) {
if (!existsSync(hostDir)) continue;
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
for (const dir of dirs) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(hostDir, entry.name, 'index.ts'))) {
flags.push('--extension', `${outputDir}/${entry.name}/index.ts`);
if (existsSync(join(dir, entry.name, 'index.ts'))) {
flags.push('--extension', `${dir}/${entry.name}/index.ts`);
}
}
}
@@ -78,14 +97,22 @@ export function generateResourceSkill(outputDir: string): string | null {
for (const [name, baseDir] of resourceDirs) {
const resourceMd = join(baseDir, name, 'RESOURCE.md');
let mdContent = '';
try { mdContent = readFileSync(resourceMd, 'utf-8'); } catch { continue; }
try {
mdContent = readFileSync(resourceMd, 'utf-8');
} catch {
continue;
}
const { frontmatter } = parseFrontmatter(mdContent);
// Merge native + global config
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
@@ -94,13 +121,15 @@ export function generateResourceSkill(outputDir: string): string | null {
const hasValues = Object.values(config).some((v) => v !== '');
const configLines = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => /key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`);
.map(([k, v]) => (/key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`));
sections.push([
sections.push(
[
`### ${frontmatter.name || name}`,
hasValues ? 'Status: **configured**' : 'Status: not configured',
...configLines,
].join('\n'));
].join('\n'),
);
}
const skillContent = [
@@ -145,8 +174,12 @@ function buildResourcesEnv(): string {
for (const [name] of resourceDirs) {
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
@@ -161,7 +194,6 @@ function buildResourcesEnv(): string {
return JSON.stringify(result);
}
async function getApifyToken(): Promise<string> {
try {
const integration = await getServerIntegration('apify');
@@ -195,22 +227,16 @@ async function resolveApiKeyForModel(model: string): Promise<string | null> {
try {
const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json'));
if (!(await authFile.exists())) return null;
const auth = await authFile.json() as Record<string, { key?: string }>;
const auth = (await authFile.json()) as Record<string, { key?: string }>;
return auth[provider]?.key?.trim() || null;
} catch {
return null;
}
}
type SandboxOptions = {
userId: number;
username: string;
email: string;
homeDir: string;
};
type SpawnPiOptions = {
sessionFile?: string;
username?: string;
};
export async function spawnPi(
@@ -219,35 +245,22 @@ export async function spawnPi(
userId: number,
email: string,
onEvent: PiEventHandler,
sandbox?: SandboxOptions,
options?: SpawnPiOptions,
): Promise<Subprocess> {
let proc: Subprocess;
if (sandbox) {
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
const searxng = await readSearxngConfig();
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = container.dockerId;
const containerHome = `/home/${sandbox.username}`;
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
// Collect skill/extension flags using container-side paths
const skillFlags = collectSkillFlags(sandbox.email, {
global: '/officer/skills',
user: '/officer/user/skills',
});
const extensionFlags = collectExtensionFlags(sandbox.email, {
global: '/officer/extensions',
user: '/officer/user/extensions',
});
// Generate resource context skill (host-side, mounted into container)
const resourceSkillHost = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillHost ? ['--skill', '/officer/generated/available-resources'] : [];
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
const piArgs = [
'pi', '--mode', 'rpc',
'--no-skills', '--no-prompt-templates', '--no-themes',
...PI_CMD,
'--mode',
'rpc',
'--no-skills',
'--no-prompt-templates',
'--no-themes',
...skillFlags,
...extensionFlags,
...resourceSkillFlags,
@@ -255,95 +268,62 @@ export async function spawnPi(
if (model) piArgs.push('--model', model);
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
// Pass API key for the model's provider so the container doesn't need auth.json
const apiKey = await resolveApiKeyForModel(model);
if (apiKey) piArgs.push('--api-key', apiKey);
const resourcesEnv = buildResourcesEnv();
const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId);
const apifyToken = await getApifyToken();
const envFlags = [
'-e', `HOME=${containerHome}`,
'-e', `OFFICER_USER_HOME=${containerHome}`,
'-e', `OFFICER_USER_ROOT=/officer/user`,
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
'-e', `PI_SEARXNG_URL=${searxng.url}`,
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
'-e', `OFFICER_EMAIL_DB=/officer/data/emails.db`,
...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []),
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
];
const rel = relative(sandbox.homeDir, cwd);
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
proc = Bun.spawn([
dockerPath, 'exec', '-i',
'-u', `${sandbox.username}`,
'-w', workdir,
...envFlags,
containerId,
...piArgs,
], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
logger.info('Spawned Pi in container', {
containerId,
model,
skills: skillFlags.filter((f) => f !== '--skill').length,
extensions: extensionFlags.filter((f) => f !== '--extension').length,
});
} else {
const searxng = await readSearxngConfig();
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
// Generate resource context skill
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags];
if (model) args.push('--model', model);
if (options?.sessionFile) args.push('--session', options.sessionFile);
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
}
const homeDir = getHomeDir(email);
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyTokenLocal = await getApifyToken();
const apifyToken = await getApifyToken();
const shellUsername = options?.username ?? toShellUsername('', email);
proc = Bun.spawn(args, {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
HOME: getHomeDir(email),
OFFICER_USER_HOME: getHomeDir(email),
const env: Record<string, string> = {
HOME: homeDir,
OFFICER_USER_HOME: homeDir,
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
PI_TOOLS_DIRS: toolsDirs,
PI_SEARXNG_URL: searxng.url,
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
...(apifyTokenLocal ? { OFFICER_APIFY_TOKEN: apifyTokenLocal } : {}),
TERM: 'xterm-256color',
PATH: process.env.PATH ?? '',
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
...browserRelayEnv,
},
};
const isServiceUser = shellUsername === (process.env.USER ?? '');
// For service user, keep real HOME so Pi finds its config
if (isServiceUser) {
env.HOME = process.env.HOME ?? '';
}
const proc = isServiceUser
? Bun.spawn(piArgs, {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...env },
})
: Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
logger.info('Spawned Pi locally', {
logger.info('Spawned Pi as user', {
username: shellUsername,
model,
skills: skillFlags.filter((f) => f !== '--skill').length,
extensions: extensionFlags.filter((f) => f !== '--extension').length,
});
}
// Read stdout JSON event stream (runs in background)
const stdout = proc.stdout as ReadableStream<Uint8Array>;
@@ -388,12 +368,14 @@ export async function spawnPi(
const stderr = proc.stderr as ReadableStream<Uint8Array>;
const stderrReader = stderr.getReader();
const stderrDecoder = new TextDecoder();
let stderrOutput = '';
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
const text = stderrDecoder.decode(value, { stream: true });
stderrOutput += text;
if (text.trim()) logger.info('Pi stderr', { text: text.trim() });
}
} catch {
@@ -403,7 +385,19 @@ export async function spawnPi(
// Handle process exit
proc.exited.then((code) => {
logger.info('Pi process exited', { code });
if (code === 1) {
// Exit code 1 often indicates a startup issue, possibly snap node + piped stdin incompatibility
logger.error('Pi process exited with code 1', {
nodeVersion: process.version,
nodeExePath: process.execPath,
isSnapNode: process.execPath?.includes('/snap/'),
hint: 'If node is from snap (/snap/bin/node), uninstall snap node and install via apt instead',
});
} else if (code !== 0) {
logger.error('Pi process exited with error code', { code });
} else {
logger.info('Pi process exited normally');
}
});
return proc;
@@ -462,7 +456,11 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
if (typeof result === 'object' && result !== null) {
resultObj = result as Record<string, unknown>;
} else if (typeof result === 'string') {
try { resultObj = JSON.parse(result); } catch { /* not JSON */ }
try {
resultObj = JSON.parse(result);
} catch {
/* not JSON */
}
}
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
@@ -513,21 +511,14 @@ function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): vo
}
}
export function setThinkingLevel(
process: Subprocess,
level: string,
): void {
export function setThinkingLevel(process: Subprocess, level: string): void {
writeRpcCommand(process, {
type: 'set_thinking_level',
level,
});
}
export function sendPrompt(
process: Subprocess,
prompt: string,
requestId: string
): void {
export function sendPrompt(process: Subprocess, prompt: string, requestId: string): void {
writeRpcCommand(process, {
type: 'prompt',
id: requestId,
@@ -535,20 +526,14 @@ export function sendPrompt(
});
}
export function abort(
process: Subprocess,
requestId: string
): void {
export function abort(process: Subprocess, requestId: string): void {
writeRpcCommand(process, {
type: 'abort',
id: requestId,
});
}
export function cancelExtensionUi(
process: Subprocess,
id: unknown
): void {
export function cancelExtensionUi(process: Subprocess, id: unknown): void {
writeRpcCommand(process, {
type: 'extension_ui_response',
id,
+28 -5
View File
@@ -30,6 +30,7 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
providerNames[`officer-local-${lp.id}`] = lp.name;
}
logger.info('Models endpoint', { count: models.length, providers: [...new Set(models.map((m) => m.provider))] });
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
} catch (err) {
logger.error('Failed to list models', { error: String(err) });
@@ -51,7 +52,9 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
const body = await ctx.req.json().catch(() => ({}));
const userHome = getHomeDir(user.email);
const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null;
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
try {
let sessions = await storage.listUserSessions(userHome, contextFilter);
@@ -119,6 +122,14 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
}
});
/**
* PUT /api/pi/sessions/:sessionId/messages
* Client-side message save — no-op, sessions are persisted server-side via WebSocket events
*/
piRestRouter.put('/pi/sessions/:sessionId/messages', async (ctx: Context) => {
return ctx.json({ success: true });
});
/**
* PATCH /api/pi/sessions/:sessionId
* Update session metadata (e.g., rename)
@@ -162,9 +173,14 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
}
}
const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, {
const updatedMeta = await storage.updateSessionMeta(
userHome,
sessionId,
{
title: body.title,
}, groupSlug);
},
groupSlug,
);
return ctx.json({
success: true,
@@ -246,7 +262,9 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
}
const body = await ctx.req.json().catch(() => ({}));
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
const userHome = getHomeDir(user.email);
try {
@@ -260,7 +278,12 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
// Skip sessions that fail to delete
}
}
logger.info('Bulk deleted sessions', { email: user.email, deleted, total: sessions.length, context: contextFilter?.context });
logger.info('Bulk deleted sessions', {
email: user.email,
deleted,
total: sessions.length,
context: contextFilter?.context,
});
return ctx.json({ success: true, deleted });
} catch (err) {
logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) });
+81 -49
View File
@@ -30,6 +30,7 @@ type WSData = {
email: string;
username: string;
role: string;
sandboxed: boolean;
provider: string;
};
@@ -243,7 +244,20 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
async function handleChat(
ws: ServerWebSocket<WSData>,
msg: { prompt: string; displayText?: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string }
msg: {
prompt: string;
displayText?: string;
sessionId?: string;
model?: string;
cwd?: string;
cwdRoot?: string;
sandboxed?: boolean;
groupSlug?: string;
attachmentIds?: string[];
thinking?: string;
context?: string;
contextId?: string;
},
): Promise<void> {
const { email, username, userId } = ws.data;
const sessionId = msg.sessionId || randomUUID();
@@ -276,41 +290,39 @@ async function handleChat(
}
const homeDir = getHomeDir(email);
const sandboxed = msg.sandboxed ?? false;
const cwd = sandboxed
const cwd = ws.data.sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.sandboxed = sandboxed;
session.userId = userId;
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
sendToClient(ws, {
type: 'session:init',
sessionId,
model,
cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
if (!session.piProcess) {
try {
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined;
// If session has history, save to disk and pass --session for context replay
let spawnOptions: { sessionFile?: string } | undefined;
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) {
if (sandboxed) {
const containerHome = `/home/${username}`;
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
const relativePart = hostPath.slice(sessionsPrefix.length);
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
} else {
spawnOptions = { sessionFile: hostPath };
}
spawnOptions = { sessionFile: hostPath, username };
}
}
if (!spawnOptions) spawnOptions = { username };
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions);
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
@@ -321,7 +333,12 @@ async function handleChat(
}
});
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
logger.info('Spawned Pi process for session', {
sessionId,
model,
cwd,
hasSessionFile: !!spawnOptions?.sessionFile,
});
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
@@ -360,34 +377,39 @@ async function handleClaudeCodeChat(
ws: ServerWebSocket<WSData>,
sessionId: string,
model: string,
msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean },
msg: {
prompt: string;
displayText?: string;
groupSlug?: string;
context?: string;
contextId?: string;
cwd?: string;
cwdRoot?: string;
sandboxed?: boolean;
},
): Promise<void> {
const { email, username, userId } = ws.data;
const homeDir = getHomeDir(email);
const sandboxed = msg.sandboxed ?? false;
// Claude Code always operates on the user's data home (not OS home).
// Resolve cwd relative to data directory, then remap for container if sandboxed.
const dataCwd = resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd);
let cwd: string;
if (sandboxed) {
const containerHome = `/home/${username}`;
cwd = dataCwd.startsWith(homeDir)
? `${containerHome}${dataCwd.slice(homeDir.length)}`
: containerHome;
} else {
cwd = dataCwd;
}
const cwd = ws.data.sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.sandboxed = sandboxed;
session.userId = userId;
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
sendToClient(ws, {
type: 'session:init',
sessionId,
model,
cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
// Add user message to session
const userMsg: Message = {
@@ -416,7 +438,6 @@ async function handleClaudeCodeChat(
prompt: msg.prompt,
sessionKey: sessionId,
cwd,
sandboxed,
onEvent,
});
@@ -438,7 +459,7 @@ async function handleClaudeCodeChat(
async function handleResume(
ws: ServerWebSocket<WSData>,
msg: { sessionId: string; cwd?: string; cwdRoot?: string }
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
): Promise<void> {
const { email } = ws.data;
const { sessionId } = msg;
@@ -467,33 +488,40 @@ async function handleResume(
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId });
sendToClient(ws, {
type: 'session:init',
sessionId,
model: session.model,
cwd: session.cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
// Spawn fresh Pi process if needed
if (!session.piProcess) {
try {
const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
// If session has history, save to disk and pass --session for context replay
let spawnOptions: { sessionFile?: string } | undefined;
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) {
if (sandbox) {
const containerHome = `/home/${ws.data.username}`;
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
const relativePart = hostPath.slice(sessionsPrefix.length);
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
} else {
spawnOptions = { sessionFile: hostPath };
}
spawnOptions = { sessionFile: hostPath, username: ws.data.username };
}
}
if (!spawnOptions) spawnOptions = { username: ws.data.username };
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions);
session.piProcess = await piBridge.spawnPi(
session.cwd,
session.model,
session.userId!,
email,
onEvent,
spawnOptions,
);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
@@ -504,7 +532,11 @@ async function handleResume(
}
});
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
logger.info('Spawned fresh Pi process for resumed session', {
sessionId,
model: session.model,
hasSessionFile: !!spawnOptions?.sessionFile,
});
} catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
@@ -536,7 +568,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (session?.piProcess) {
try {
if (session.model === 'claude-code') {
// Claude Code: kill the docker exec process directly
// Claude Code: kill the process directly
session.piProcess.kill();
logger.info('Killed Claude Code process', { sessionId });
} else {
+115 -60
View File
@@ -31,7 +31,9 @@ type ProbeResult = {
};
type PiModelConfig = {
providers: Record<string, {
providers: Record<
string,
{
baseUrl: string;
apiKey?: string;
api: string;
@@ -45,7 +47,8 @@ type PiModelConfig = {
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}[];
_officer?: OfficerMeta;
}>;
}
>;
};
type OfficerMeta = {
@@ -143,7 +146,7 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
if (oaiRes) {
if (oaiRes.status === 401 || oaiRes.status === 403) {
const wwwAuth = oaiRes.headers.get('www-authenticate') ?? '';
const authType = wwwAuth.toLowerCase().includes('basic') ? 'basic' as const : 'api-key' as const;
const authType = wwwAuth.toLowerCase().includes('basic') ? ('basic' as const) : ('api-key' as const);
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType };
}
if (oaiRes.ok) {
@@ -152,7 +155,7 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
if (data.data) {
// LM Studio includes "lm-studio" in model IDs
const isLmStudio = data.data.some((m) => m.id.includes('lm-studio'));
const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const;
const apiType = isLmStudio ? ('lmstudio' as const) : ('openai-compatible' as const);
const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible';
return {
success: true,
@@ -172,7 +175,13 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
const bareRes = await tryFetch('/models');
if (bareRes) {
if (bareRes.status === 401 || bareRes.status === 403) {
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType: 'api-key' };
return {
success: true,
apiType: 'openai-compatible',
name: 'OpenAI-compatible',
needsAuth: true,
authType: 'api-key',
};
}
if (bareRes.ok) {
try {
@@ -212,7 +221,8 @@ async function fetchModelsFromProvider(
const headers: Record<string, string> = {};
if (lp.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${lp.auth.apiKey}`;
else if (lp.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
else if (lp.auth?.type === 'basic')
headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
@@ -223,7 +233,12 @@ async function fetchModelsFromProvider(
const data = await res.json();
if (lp.apiType === 'ollama' && data.models) {
return data.models.map((m: { name: string }) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 }));
return data.models.map((m: { name: string }) => ({
id: m.name,
name: m.name,
contextWindow: 128000,
maxTokens: 4096,
}));
} else if (data.data) {
return data.data.map((m: { id: string }) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 }));
}
@@ -242,9 +257,7 @@ async function addLocalProviderToModelsConfig(lp: LocalProvider): Promise<void>
logger.warn('No models found for local provider', { provider: lp.name });
}
const baseUrl = lp.apiType === 'ollama'
? `${lp.url}/v1`
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const config = await readModelsConfig();
config.providers[`officer-local-${lp.id}`] = {
@@ -297,9 +310,7 @@ async function refreshLocalProviders(): Promise<void> {
};
const models = await fetchModelsFromProvider(lp);
const baseUrl = lp.apiType === 'ollama'
? `${lp.url}/v1`
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
entry.baseUrl = baseUrl;
entry.apiKey = lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none';
@@ -350,8 +361,8 @@ export const PROVIDERS: { key: string; piId: string }[] = [
{ key: 'MiniMax', piId: 'minimax' },
{ key: 'Hugging Face', piId: 'huggingface' },
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
{ key: 'OpenCode', piId: 'opencode' },
{ key: 'OpenCode Zen', piId: 'zai' },
{ key: 'OpenCode Zen', piId: 'opencode' },
{ key: 'ZAI', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
@@ -368,9 +379,10 @@ const maskValue = (value: string) => {
piMonoRouter.get('/api-keys', async (ctx) => {
const auth = await readAuthJson();
const keys = PROVIDERS
.filter((p) => auth[p.piId]?.key?.trim())
.map((p) => ({ provider: p.piId, value: maskValue(auth[p.piId]!.key) }));
const keys = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map((p) => ({
provider: p.piId,
value: maskValue(auth[p.piId]!.key),
}));
return ctx.json({ keys });
});
@@ -403,29 +415,33 @@ piMonoRouter.put('/access-policy', async (ctx) => {
return ctx.json(body);
});
const REMOTE_HEALTH_CONFIG: Record<string, {
const REMOTE_HEALTH_CONFIG: Record<
string,
{
url: string | ((key: string) => string);
headers: (key: string) => Record<string, string>;
}> = {
}
> = {
openai: { url: 'https://api.openai.com/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
anthropic: { url: 'https://api.anthropic.com/v1/models', headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }) },
anthropic: {
url: 'https://api.anthropic.com/v1/models',
headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }),
},
google: { url: (k) => `https://generativelanguage.googleapis.com/v1beta/models?key=${k}`, headers: () => ({}) },
groq: { url: 'https://api.groq.com/openai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
mistral: { url: 'https://api.mistral.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
opencode: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
opencode: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
};
piMonoRouter.get('/api-keys/health', async (ctx) => {
const auth = await readAuthJson();
const results: Record<string, boolean | null> = {};
const checks = PROVIDERS
.filter((p) => auth[p.piId]?.key?.trim())
.map(async (p) => {
const checks = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map(async (p) => {
const config = REMOTE_HEALTH_CONFIG[p.piId];
if (!config) {
results[p.piId] = null;
@@ -450,34 +466,63 @@ piMonoRouter.get('/api-keys/health', async (ctx) => {
return ctx.json(results);
});
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
/**
* Resolve the full path to the `pi` binary.
* Checks PATH first, then falls back to the npm global bin directory
* (which may not be in PATH when the server is managed by pm2).
*/
/**
* Finds the Pi package directory (containing package.json).
* Checks: PATH → npm global prefix → ~/.npm-global fallback.
*/
async function resolvePiPackageDir(): Promise<string | null> {
const candidates: string[] = [];
const getPaths = async () => {
// 1. Try PATH
try {
const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
const proc = Bun.spawn(['which', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = (await new Response(proc.stdout).text()).trim();
await proc.exited;
if (proc.exitCode !== 0) return { path: null, globalPath: null };
const paths = [...new Set(output.trim().split('\n'))];
const path = paths[0] ?? null;
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
return { path, globalPath };
} catch {
return { path: null, globalPath: null };
if (proc.exitCode === 0 && output) {
// Resolve symlink: bin/pi -> ../lib/node_modules/.../dist/cli.js
const resolved = (await Bun.file(output).exists()) ? output : null;
if (resolved) {
// Walk up from bin to find the package
const npmGlobalLib = join(output, '..', '..', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
candidates.push(npmGlobalLib);
}
};
}
} catch {}
// 2. Common locations
const home = process.env.HOME ?? '';
candidates.push(
join(home, '.npm-global', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'),
'/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
);
for (const dir of candidates) {
const pkgFile = join(dir, 'package.json');
if (await Bun.file(pkgFile).exists()) return dir;
}
return null;
}
/** Read Pi version directly from its package.json — avoids shebang/spawn issues. */
async function getPiVersion(): Promise<{ version: string | null; path: string | null }> {
const dir = await resolvePiPackageDir();
if (!dir) return { version: null, path: null };
try {
const pkg = await Bun.file(join(dir, 'package.json')).json();
return { version: pkg.version ?? null, path: dir };
} catch {
return { version: null, path: dir };
}
}
piMonoRouter.get('/version', async (ctx) => {
try {
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null });
}
const { version, path } = await getPiVersion();
return ctx.json({ version, path, globalPath: path });
});
piMonoRouter.post('/install', async (ctx) => {
@@ -486,16 +531,16 @@ piMonoRouter.post('/install', async (ctx) => {
stdout: 'pipe',
stderr: 'pipe',
});
const stderr = await new Response(proc.stderr).text();
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const versionProc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(versionProc.stdout).text();
await versionProc.exited;
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
const { version, path } = await getPiVersion();
if (!version)
return ctx.json({ version: null, path, globalPath: path, error: 'Installed but package.json not found' }, 500);
return ctx.json({ version, path, globalPath: path });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
@@ -505,10 +550,12 @@ piMonoRouter.post('/install', async (ctx) => {
piMonoRouter.get('/local-providers', async (ctx) => {
const providers = await readLocalProviders();
return ctx.json(providers.map((p) => ({
return ctx.json(
providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})));
})),
);
});
piMonoRouter.post('/local-providers/probe', async (ctx) => {
@@ -519,7 +566,12 @@ piMonoRouter.post('/local-providers/probe', async (ctx) => {
});
piMonoRouter.post('/local-providers', async (ctx) => {
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
const body = await ctx.req.json<{
url: string;
name?: string;
apiType: LocalProvider['apiType'];
auth?: LocalProvider['auth'];
}>();
const provider: LocalProvider = {
id: crypto.randomUUID(),
@@ -549,12 +601,14 @@ piMonoRouter.get('/local-providers/health', async (ctx) => {
const providers = await readLocalProviders();
const results: Record<string, boolean> = {};
await Promise.all(providers.map(async (p) => {
await Promise.all(
providers.map(async (p) => {
const base = p.url.replace(/\/+$/, '');
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
const headers: Record<string, string> = {};
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
else if (p.auth?.type === 'basic')
headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
@@ -566,7 +620,8 @@ piMonoRouter.get('/local-providers/health', async (ctx) => {
} finally {
clearTimeout(timer);
}
}));
}),
);
return ctx.json(results);
});
@@ -1,79 +0,0 @@
FROM imbios/bun-node:22-slim
RUN apt-get update \
&& apt-get install -y \
python3 python3-pip python3-venv make gcc g++ zsh git curl wget ca-certificates \
sudo gosu locales \
zip unzip tree btop net-tools tmux \
procps psmisc lsof less file man-db \
ripgrep fd-find jq htop sqlite3 \
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
&& rm nvim-linux-x86_64.tar.gz
ENV PATH="/opt/nvim-linux-x86_64/bin:${PATH}"
RUN git clone --depth 1 https://github.com/LazyVim/starter /opt/lazyvim-starter \
&& rm -rf /opt/lazyvim-starter/.git
WORKDIR /app
COPY pty-sidecar.mjs /app/pty-sidecar.mjs
COPY entrypoint.sh /app/entrypoint.sh
COPY templates /opt/terminal-templates
RUN npm init -y \
&& npm install ws@8.18.1 node-pty@1.1.0
RUN curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
RUN git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git /opt/oh-my-zsh
ENV EZA_VERSION=0.18.15
RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_x86_64-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz \
&& tar -xzf /tmp/eza.tar.gz -C /tmp \
&& mv /tmp/eza /usr/local/bin/eza \
&& chmod +x /usr/local/bin/eza \
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
ENV LAZYGIT_VERSION=0.44.1
RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" -o /tmp/lazygit.tar.gz \
&& tar -xzf /tmp/lazygit.tar.gz -C /tmp \
&& mv /tmp/lazygit /usr/local/bin/lazygit \
&& chmod +x /usr/local/bin/lazygit \
&& rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
ENV GOLANG_VERSION=1.23.6
RUN curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz \
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
&& rm /tmp/go.tar.gz
ENV PATH="/usr/local/go/bin:${PATH}"
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal \
&& chmod -R a+rw $CARGO_HOME
ENV PATH="/usr/local/cargo/bin:${PATH}"
RUN npm install -g @mariozechner/pi-coding-agent @anthropic-ai/claude-code
# Patch Pi compaction bug: calculateContextTokens crashes when usage is undefined
RUN sed -i '/^export function calculateContextTokens(usage) {$/a\ if (!usage) return 0;' \
/usr/local/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/compaction/compaction.js
WORKDIR /tmp
ENV TERMINAL_PTY_PORT=5337
EXPOSE 5337
ENTRYPOINT ["/app/entrypoint.sh"]
-69
View File
@@ -1,69 +0,0 @@
#!/bin/sh
set -e
USERNAME="${TERMINAL_USER:-officer}"
USER_UID="${TERMINAL_UID:-1000}"
USER_GID="${TERMINAL_GID:-1000}"
# Remove any existing user/group with the target UID/GID
EXISTING_USER=$(getent passwd "$USER_UID" | cut -d: -f1)
if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "$USERNAME" ]; then
userdel "$EXISTING_USER" 2>/dev/null || true
fi
EXISTING_GROUP=$(getent group "$USER_GID" | cut -d: -f1)
if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "$USERNAME" ]; then
groupdel "$EXISTING_GROUP" 2>/dev/null || true
fi
# Create group and user
groupadd -g "$USER_GID" "$USERNAME" 2>/dev/null || true
mkdir -p /home/$USERNAME
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/$USERNAME "$USERNAME" 2>/dev/null || true
# Passwordless sudo
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/terminal-user
chmod 0440 /etc/sudoers.d/terminal-user
# Seed LazyVim config if not present
if [ ! -d /home/$USERNAME/.config/nvim ]; then
mkdir -p /home/$USERNAME/.config
cp -r /opt/lazyvim-starter /home/$USERNAME/.config/nvim
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
fi
# Seed shell config files from templates if not present
if [ ! -f /home/$USERNAME/.zshrc ]; then
cp /opt/terminal-templates/.zshrc /home/$USERNAME/.zshrc
chown "$USER_UID:$USER_GID" /home/$USERNAME/.zshrc
fi
if [ ! -f /home/$USERNAME/.tmux.conf ]; then
cp /opt/terminal-templates/.tmux.conf /home/$USERNAME/.tmux.conf
chown "$USER_UID:$USER_GID" /home/$USERNAME/.tmux.conf
fi
if [ ! -f /home/$USERNAME/.config/starship-officer.toml ]; then
mkdir -p /home/$USERNAME/.config
cp /opt/terminal-templates/starship-officer.toml /home/$USERNAME/.config/starship-officer.toml
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
fi
if [ ! -d /home/$USERNAME/.oh-my-zsh ]; then
cp -r /opt/oh-my-zsh /home/$USERNAME/.oh-my-zsh
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.oh-my-zsh
fi
mkdir -p /home/$USERNAME/.local/bin
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.local
# Ensure Pi agent sessions directory exists and is writable
mkdir -p /home/$USERNAME/.pi/agent/sessions
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.pi
# Init git repo in home dir so Claude Code skips the workspace trust prompt
if [ ! -d /home/$USERNAME/.git ]; then
gosu "$USER_UID:$USER_GID" git init /home/$USERNAME >/dev/null 2>&1 || true
fi
# Run sidecar as the user
exec gosu "$USER_UID:$USER_GID" node /app/pty-sidecar.mjs
+27 -20
View File
@@ -1,3 +1,6 @@
// Ignore SIGINT — sudo/pty child processes may propagate it
process.on('SIGINT', () => {});
import http from 'node:http';
import { existsSync } from 'node:fs';
import { cp, mkdir } from 'node:fs/promises';
@@ -14,10 +17,8 @@ const run = (cmd, args, opts = {}) =>
});
const __dirname = dirname(fileURLToPath(import.meta.url));
const isDocker = existsSync('/opt/terminal-templates/.zshrc');
const templateDir = isDocker ? '/opt/terminal-templates' : join(__dirname, 'templates');
const ohMyZshSource = isDocker ? '/opt/oh-my-zsh' : null;
const templateDir = join(__dirname, 'templates');
const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337');
const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1';
@@ -64,19 +65,8 @@ const ensureUserFiles = async (homeDir) => {
const ohMyZshPath = join(homeDir, '.oh-my-zsh');
if (!existsSync(ohMyZshPath)) {
if (ohMyZshSource && existsSync(ohMyZshSource)) {
await cp(ohMyZshSource, ohMyZshPath, { recursive: true });
} else {
await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
}
}
if (!isDocker) {
const starshipBin = join(homeDir, '.local', 'bin', 'starship');
if (!existsSync(starshipBin)) {
await run('sh', ['-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], { env: { ...process.env, HOME: homeDir } });
}
}
};
const appendBuffer = (session, data) => {
@@ -145,16 +135,31 @@ wss.on('connection', (ws) => {
const cwd = msg.cwd ?? process.cwd();
const homeDir = msg.homeDir ?? process.cwd();
const userLabel = msg.userLabel ?? 'officer';
const username = msg.username ?? null;
const cols = msg.cols ?? 80;
const rows = msg.rows ?? 24;
const isHost = !!msg.host;
let spawnCommand;
let spawnArgs;
let ptyEnv;
if (isHost) {
// Host session — spawn shell directly as current user
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(msg.env ?? {}) };
} else if (username) {
// User session — spawn via sudo -u as the target Linux user
spawnCommand = 'sudo';
spawnArgs = ['-u', username, '-i', '/bin/zsh'];
ptyEnv = {
TERM: 'xterm-256color',
};
} else {
const prompt = `${userLabel} in %~ %# `;
const bashPrompt = `${userLabel} \\w \\$ `;
// Fallback — direct spawn with custom env (legacy)
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
try {
await ensureUserFiles(homeDir);
@@ -171,19 +176,21 @@ wss.on('connection', (ws) => {
USER: userLabel,
LOGNAME: userLabel,
OFFICER_TERMINAL_USER: userLabel,
PROMPT: prompt,
PS1: bashPrompt,
TERM: 'xterm-256color',
};
}
// For username sessions, don't set cwd — sudo -u -i will cd to the user's home.
// node-pty does chdir before exec, so it would fail if the service user can't access the dir.
const ptyCwd = username ? undefined : cwd;
let term;
try {
term = pty.spawn(shell.command, shell.args ?? [], {
term = pty.spawn(spawnCommand, spawnArgs, {
name: 'xterm-256color',
cols,
rows,
cwd,
cwd: ptyCwd,
env: ptyEnv,
});
} catch (err) {
@@ -0,0 +1 @@
skip_global_compinit=1
+4 -1
View File
@@ -1,6 +1,9 @@
# If you come from bash you might have to change your $PATH.
export PATH=$HOME/.local/bin:$PATH
# Skip insecure directory check (system zsh dirs may be group-writable)
ZSH_DISABLE_COMPFIX=true
# Path to your Oh My Zsh installation.
export ZSH="$HOME/.oh-my-zsh"
@@ -22,7 +25,7 @@ source $ZSH/oh-my-zsh.sh
# ============================================================================
# STARSHIP PROMPT
# ============================================================================
if [[ -n "$OFFICER_TERMINAL_USER" ]]; then
if [[ -f "$HOME/.config/starship-officer.toml" ]]; then
export STARSHIP_CONFIG="$HOME/.config/starship-officer.toml"
fi
@@ -1,9 +1,10 @@
format = "$env_var:$hostname $directory $character"
format = "$username:$hostname $directory $character"
[env_var]
variable = "OFFICER_TERMINAL_USER"
format = "[$env_value]($style)"
style = "bold #0891B2"
[username]
show_always = true
format = "[$user]($style)"
style_user = "bold #0891B2"
style_root = "bold red"
[hostname]
ssh_only = false
+52 -340
View File
@@ -1,38 +1,30 @@
import type { ServerWebSocket } from 'bun';
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, PI_CONFIG_DIR, toShellUsername } from '@@/data-path';
import { getUsers } from 'officerdb';
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
import { getHomeDir } from '@@/data-path';
const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; };
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
type ShellInfo = { command: string; args: string[]; name: string };
type WSData = {
userId: number;
email: string;
username: string;
role: string;
sandboxed: boolean;
sessionId?: string;
cwd?: string;
cols?: number;
rows?: number;
};
type BridgeSession = {
client: ServerWebSocket<WSData>;
sidecar: WebSocket | null;
dockerId: string;
port: number;
pendingMessages: string[];
};
type ContainerInfo = {
userId: number;
email: string;
dockerId: string;
port: number;
};
const HOST_SIDECAR_PORT = 5338;
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json');
let dockerImageReady = false;
let containersCache: Record<string, ContainerInfo> | null = null;
let hostSidecarProcess: ReturnType<typeof import('bun').spawn> | null = null;
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
@@ -43,14 +35,14 @@ const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
}
};
const connectSidecar = async (port: number): Promise<WebSocket> => {
const connectSidecar = async (): Promise<WebSocket> => {
const delays = [200, 300, 500, 800, 1200, 1600, 2000];
let lastError: Error | null = null;
for (const delay of delays) {
try {
const ws = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
const socket = new WebSocket(`ws://127.0.0.1:${HOST_SIDECAR_PORT}`);
const timeout = setTimeout(() => {
try {
socket.close();
@@ -80,224 +72,9 @@ const connectSidecar = async (port: number): Promise<WebSocket> => {
throw lastError ?? new Error('Terminal sidecar connection failed');
};
const ensureDockerImage = () => {
if (dockerImageReady) return;
const dockerPath = Bun.which('docker');
if (!dockerPath) throw new Error('Docker not found');
const tag = 'officer-terminal-sidecar:v1';
const inspect = Bun.spawnSync({ cmd: [dockerPath, 'image', 'inspect', tag], stdout: 'ignore', stderr: 'ignore' });
if (inspect.exitCode === 0) {
dockerImageReady = true;
return;
}
const dockerfilePath = fileURLToPath(new URL('./Dockerfile.terminal-sidecar', import.meta.url));
const build = Bun.spawnSync({
cmd: [dockerPath, 'build', '-f', dockerfilePath, '-t', tag, '.'],
cwd: fileURLToPath(new URL('./', import.meta.url)),
stdout: 'inherit',
stderr: 'inherit',
});
if (build.exitCode !== 0) throw new Error('Failed to build terminal sandbox image');
dockerImageReady = true;
};
// Check whether a container has all expected volume mounts.
// Tests for multiple mount sources — if any is missing, the container should be recreated.
const containerHasExpectedMounts = (dockerId: string): boolean => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({
cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId],
stdout: 'pipe',
stderr: 'ignore',
});
if (result.exitCode !== 0) return false;
const mounts = result.stdout.toString();
return mounts.includes(getGlobalSkillsDir()) && mounts.includes('/officer/data') && mounts.includes('.claude');
};
const startDockerSidecar = async (port: number, homeDir: string, userId: number, username: string, email: string, contextFile?: string, settingsFile?: string): Promise<{ dockerId: string }> => {
ensureDockerImage();
const dockerPath = Bun.which('docker') ?? 'docker';
const dockerId = `officer-terminal-${userId}`;
const tag = 'officer-terminal-sidecar:v1';
// Remove stale container with same name if it exists
if (dockerContainerExists(dockerId)) {
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
}
let uid = 1000;
let gid = 1000;
const sidecarAlive = async (): Promise<boolean> => {
try {
const stats = statSync(homeDir);
uid = stats.uid;
gid = stats.gid;
} catch {
// fallback to defaults
}
const containerHome = `/home/${username}`;
const run = Bun.spawnSync({
cmd: [
dockerPath,
'run',
'-d',
'--name',
dockerId,
'--restart',
'unless-stopped',
'--network', 'host',
'-e',
`TERMINAL_PTY_PORT=${port}`,
'-e',
'TERMINAL_PTY_HOST=127.0.0.1',
'-e',
`TERMINAL_USER=${username}`,
'-e',
`TERMINAL_UID=${uid}`,
'-e',
`TERMINAL_GID=${gid}`,
'-e',
`OFFICER_EMAIL=${email}`,
'-v', `${homeDir}:${containerHome}`,
'-v', `${getGlobalSkillsDir()}:/officer/skills:ro`,
'-v', `${getGlobalToolsDir()}:/officer/tools:ro`,
'-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`,
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
'-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`,
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
'-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`,
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
'-v', `${join(DATA_PATH, email)}:/officer/data`,
...(existsSync(join(process.env.HOME ?? '', '.claude')) ? ['-v', `${join(process.env.HOME!, '.claude')}:${containerHome}/.claude`] : []),
...(contextFile && existsSync(contextFile) ? ['-v', `${contextFile}:${containerHome}/.claude/CLAUDE.md:ro`] : []),
...(settingsFile && existsSync(settingsFile) ? ['-v', `${settingsFile}:${containerHome}/.claude/settings.json:ro`] : []),
'-w', containerHome,
tag,
],
stdout: 'inherit',
stderr: 'inherit',
});
if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container');
// Wait for entrypoint to finish (user creation, sidecar start)
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (await sidecarAlive(port)) return { dockerId };
}
throw new Error('Terminal sidecar did not start in time');
};
const stopDockerSidecar = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
};
const readDockerLogs = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const logs = Bun.spawnSync({ cmd: [dockerPath, 'logs', '--tail', '200', dockerId], stdout: 'pipe', stderr: 'pipe' });
if (logs.exitCode !== 0) return '';
return logs.stdout.toString().trim();
};
const loadContainerMap = async (): Promise<Record<string, ContainerInfo>> => {
if (containersCache) return containersCache;
const data = await Bun.file(containerMapPath)
.json()
.catch(() => ({}));
containersCache = data as Record<string, ContainerInfo>;
return containersCache;
};
const saveContainerMap = async (map: Record<string, ContainerInfo>) => {
containersCache = map;
await Bun.write(containerMapPath, JSON.stringify(map, null, 2));
};
const getAvailablePort = (map: Record<string, ContainerInfo>, userId: number) => {
const base = 54000;
const used = new Set(Object.values(map).map((item) => item.port));
let port = base + (userId % 1000);
while (used.has(port)) port += 1;
return port;
};
const dockerContainerExists = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-a', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
};
const dockerContainerRunning = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
};
const dockerStart = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({ cmd: [dockerPath, 'start', dockerId], stdout: 'ignore', stderr: 'ignore' });
return result.exitCode === 0;
};
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string, contextFile?: string, settingsFile?: string) => {
// Check if mount sources are stale (e.g. data dir was deleted and Docker recreated them as root)
// Must check BEFORE mkdirSync overwrites them
const skillsDir = getUserSkillsDir(email);
let stale = false;
try {
const s = statSync(skillsDir);
if (s.uid === 0) stale = true;
} catch {
// doesn't exist yet — not stale, will be created below
}
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
mkdirSync(skillsDir, { recursive: true });
mkdirSync(getUserToolsDir(email), { recursive: true });
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
const map = await loadContainerMap();
const existing = map[email];
if (existing && dockerContainerRunning(existing.dockerId)) {
// Recreate if resource mounts are missing or data dir was recreated (stale mounts)
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
console.log(`[terminal] recreating container for ${email} — mounts stale or missing`);
stopDockerSidecar(existing.dockerId);
} else {
console.log(`[terminal] reusing running container ${existing.dockerId} for ${email} on port ${existing.port}`);
return existing;
}
}
if (existing && dockerContainerExists(existing.dockerId)) {
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
stopDockerSidecar(existing.dockerId);
} else if (dockerStart(existing.dockerId)) {
return existing;
} else {
stopDockerSidecar(existing.dockerId);
}
}
const port = existing?.port ?? getAvailablePort(map, userId);
const docker = await startDockerSidecar(port, homeDir, userId, username, email, contextFile, settingsFile);
const next = { userId, email, dockerId: docker.dockerId, port };
map[email] = next;
await saveContainerMap(map);
return next;
};
const sidecarAlive = async (port: number): Promise<boolean> => {
try {
const res = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) });
const res = await fetch(`http://127.0.0.1:${HOST_SIDECAR_PORT}`, { signal: AbortSignal.timeout(500) });
return res.ok;
} catch {
return false;
@@ -334,65 +111,55 @@ const startHostSidecar = async () => {
};
export const ensureHostSidecar = async () => {
const alive = await sidecarAlive(HOST_SIDECAR_PORT);
const alive = await sidecarAlive();
if (!alive) {
await startHostSidecar();
// Wait for it to come up
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 300));
if (await sidecarAlive(HOST_SIDECAR_PORT)) return;
if (await sidecarAlive()) return;
}
throw new Error('Host sidecar failed to start');
}
};
export const initTerminalSidecars = async () => {
await startHostSidecar();
ensureDockerImage();
const users = await getUsers();
for (const user of users) {
const homeDir = getHomeDir(user.email);
mkdirSync(dirname(homeDir), { recursive: true });
mkdirSync(homeDir, { recursive: true });
const shellUsername = toShellUsername(user.username ?? '', user.email);
const contextFile = generateContainerContext(user.email);
const settingsFile = generateClaudeSettings(user.email, shellUsername);
try {
await ensureDockerContainer(user.email, user.id, homeDir, shellUsername, contextFile, settingsFile);
console.log(`[terminal] sidecar ready for ${user.email}`);
} catch (err) {
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
// Sidecar is managed by pm2 — wait for it to be available
for (let i = 0; i < 15; i++) {
if (await sidecarAlive()) {
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`);
return;
}
await new Promise((r) => setTimeout(r, 500));
}
console.warn(`[terminal] host sidecar not detected on port ${HOST_SIDECAR_PORT} — terminals will retry on connect`);
};
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
if (cwd.startsWith('/')) return cwd;
return home;
};
export const terminalWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { email, username, role, sandboxed } = ws.data;
const isHost = !sandboxed && role === 'Super Admin';
if (!sandboxed && role !== 'Super Admin') {
sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n');
return;
}
console.log(
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
);
if (!sandboxed) {
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] };
const session: BridgeSession = { client: ws, sidecar: null, pendingMessages: [] };
sessions.set(ws, session);
let sidecar: WebSocket | null = null;
try {
sidecar = await connectSidecar(HOST_SIDECAR_PORT);
sidecar = await connectSidecar();
console.log('[terminal] sidecar connected');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect host sidecar';
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
console.error('[terminal] sidecar connection failed:', message);
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
sessions.delete(ws);
return;
@@ -412,6 +179,8 @@ export const terminalWebsocket = {
}
});
if (isHost) {
// Super Admin host terminal — spawn as the service user directly
sidecar.send(
JSON.stringify({
type: 'init',
@@ -425,70 +194,25 @@ export const terminalWebsocket = {
rows: ws.data.rows,
}),
);
for (const msg of session.pendingMessages) sidecar.send(msg);
session.pendingMessages = [];
return;
}
const cwd = getHomeDir(email);
const userRoot = dirname(cwd);
mkdirSync(userRoot, { recursive: true });
mkdirSync(cwd, { recursive: true });
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] };
sessions.set(ws, session);
let sidecar: WebSocket | null = null;
let info: ContainerInfo | undefined;
try {
info = await ensureDockerContainer(email, ws.data.userId, cwd, username, generateContainerContext(email), generateClaudeSettings(email, username));
sidecar = await connectSidecar(info.port);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
console.error(`[terminal] sidecar connection failed for ${email}:`, message);
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
if (info) {
const logs = readDockerLogs(info.dockerId);
if (logs) {
sendOutput(ws, `\r\n[Docker logs]\r\n${logs}\r\n`);
}
}
sendOutput(ws, '\r\n[Process exited]\r\n');
if (info) stopDockerSidecar(info.dockerId);
sessions.delete(ws);
return;
}
session.sidecar = sidecar;
session.dockerId = info.dockerId;
session.port = info.port;
sidecar.addEventListener('message', (ev) => {
try {
if (typeof ev.data === 'string') {
ws.send(ev.data);
} else {
ws.send(new TextDecoder().decode(ev.data));
}
} catch {
// ws already closed
}
});
// User terminal — spawn as the target Linux user via sudo -u
const homeDir = getHomeDir(email);
mkdirSync(dirname(homeDir), { recursive: true });
mkdirSync(homeDir, { recursive: true });
const containerHome = `/home/${username}`;
sidecar.send(
JSON.stringify({
type: 'init',
username,
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
shell: containerShell,
cwd: resolveCwd(containerHome, ws.data.cwd),
homeDir: containerHome,
cwd: resolveCwd(homeDir, ws.data.cwd),
homeDir,
userLabel: email,
cols: ws.data.cols,
rows: ws.data.rows,
}),
);
}
for (const msg of session.pendingMessages) sidecar.send(msg);
session.pendingMessages = [];
@@ -531,32 +255,20 @@ export const broadcastPanelRefresh = (email: string) => {
const msg = JSON.stringify({ type: 'panel-refresh' });
for (const [ws, session] of sessions) {
if (ws.data.email === email && session.sidecar) {
try { ws.send(msg); } catch { /* ignore */ }
try {
ws.send(msg);
} catch {
/* ignore */
}
}
}
};
export const stopAllContainers = async () => {
// Stop host sidecar
export const stopAllSidecars = async () => {
if (hostSidecarProcess) {
hostSidecarProcess.kill();
await hostSidecarProcess.exited.catch(() => {});
hostSidecarProcess = null;
console.log('[terminal] host sidecar stopped');
}
// Stop all Docker containers
const map = await loadContainerMap();
const entries = Object.entries(map);
if (entries.length === 0) return;
const dockerPath = Bun.which('docker') ?? 'docker';
for (const [email, info] of entries) {
try {
Bun.spawnSync({ cmd: [dockerPath, 'stop', '-t', '2', info.dockerId], stdout: 'ignore', stderr: 'ignore' });
console.log(`[terminal] stopped container ${info.dockerId} (${email})`);
} catch {
// ignore
}
}
};
+147
View File
@@ -0,0 +1,147 @@
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH, getHomeDir, toShellUsername } from '@@/data-path';
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
const TEMPLATE_DIR = join(import.meta.dir, '../terminal/templates');
const run = (cmd: string[], opts?: { cwd?: string }): boolean => {
const result = Bun.spawnSync({ cmd, stdout: 'ignore', stderr: 'pipe', ...opts });
if (result.exitCode !== 0) {
console.error(`[provision] command failed: ${cmd.join(' ')}`, result.stderr.toString().trim());
}
return result.exitCode === 0;
};
const linuxUserExists = (username: string): boolean => {
const result = Bun.spawnSync({ cmd: ['id', username], stdout: 'ignore', stderr: 'ignore' });
return result.exitCode === 0;
};
const copyTemplate = async (src: string, dest: string) => {
if (existsSync(dest)) return;
const content = await Bun.file(src).text();
await Bun.write(dest, content);
};
export async function provisionLinuxUser(email: string, username: string): Promise<boolean> {
const shellUsername = toShellUsername(username, email);
const homeDir = getHomeDir(email);
const userRoot = join(DATA_PATH, email);
console.log(`[provision] provisioning Linux user ${shellUsername} for ${email}`);
// Ensure data directories exist
mkdirSync(userRoot, { recursive: true });
mkdirSync(homeDir, { recursive: true });
// Create Linux user if not exists
if (!linuxUserExists(shellUsername)) {
const ok = run(['sudo', 'useradd', '-d', homeDir, '-s', '/bin/zsh', '-M', shellUsername]);
if (!ok) {
console.error(`[provision] failed to create Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] created Linux user ${shellUsername}`);
} else {
console.log(`[provision] Linux user ${shellUsername} already exists`);
}
// Set ownership and permissions on user data directory
// chmod 770 so the service user (in the user's group) can read/write for background jobs
run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]);
run(['sudo', 'chmod', '770', userRoot]);
// Add the service user to the new user's group so server jobs can access user data
const serviceUser = process.env.USER ?? '';
if (serviceUser && serviceUser !== shellUsername) {
run(['sudo', 'usermod', '-aG', shellUsername, serviceUser]);
}
// Seed shell config files
await seedShellConfigs(homeDir);
// Generate and write CLAUDE.md + settings.json
const contextFile = generateContainerContext(email);
const claudeDir = join(homeDir, '.claude');
mkdirSync(claudeDir, { recursive: true });
// Copy context to user's .claude dir
const contextContent = await Bun.file(contextFile).text();
await Bun.write(join(claudeDir, 'CLAUDE.md'), contextContent);
const settingsFile = generateClaudeSettings(email, shellUsername);
const settingsContent = await Bun.file(settingsFile).text();
await Bun.write(join(claudeDir, 'settings.json'), settingsContent);
// Fix ownership after seeding
run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]);
console.log(`[provision] provisioning complete for ${shellUsername}`);
return true;
}
async function seedShellConfigs(homeDir: string): Promise<void> {
// .zshenv (must be first — prevents system compinit before oh-my-zsh)
await copyTemplate(join(TEMPLATE_DIR, '.zshenv'), join(homeDir, '.zshenv'));
// .zshrc
await copyTemplate(join(TEMPLATE_DIR, '.zshrc'), join(homeDir, '.zshrc'));
// .tmux.conf
await copyTemplate(join(TEMPLATE_DIR, '.tmux.conf'), join(homeDir, '.tmux.conf'));
// starship config
const configDir = join(homeDir, '.config');
mkdirSync(configDir, { recursive: true });
await copyTemplate(join(TEMPLATE_DIR, 'starship-officer.toml'), join(configDir, 'starship-officer.toml'));
// Oh My Zsh — copy from host install
const ohMyZshDest = join(homeDir, '.oh-my-zsh');
if (!existsSync(ohMyZshDest)) {
const hostOhMyZsh = join(process.env.HOME ?? '', '.oh-my-zsh');
if (existsSync(hostOhMyZsh)) {
Bun.spawnSync({ cmd: ['cp', '-r', hostOhMyZsh, ohMyZshDest], stdout: 'ignore', stderr: 'ignore' });
} else {
Bun.spawnSync({
cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshDest],
stdout: 'ignore',
stderr: 'ignore',
});
}
}
// LazyVim config
const nvimDir = join(homeDir, '.config', 'nvim');
if (!existsSync(nvimDir)) {
const hostNvim = join(process.env.HOME ?? '', '.config', 'nvim');
if (existsSync(hostNvim)) {
Bun.spawnSync({ cmd: ['cp', '-r', hostNvim, nvimDir], stdout: 'ignore', stderr: 'ignore' });
}
}
// Ensure .local/bin exists
mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true });
// Ensure .pi/agent/sessions exists
mkdirSync(join(homeDir, '.pi', 'agent', 'sessions'), { recursive: true });
}
export function deprovisionLinuxUser(email: string, username: string): boolean {
const shellUsername = toShellUsername(username, email);
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
if (!linuxUserExists(shellUsername)) {
console.log(`[provision] Linux user ${shellUsername} does not exist, skipping`);
return true;
}
const ok = run(['sudo', 'userdel', shellUsername]);
if (!ok) {
console.error(`[provision] failed to delete Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] deprovisioned Linux user ${shellUsername}`);
return true;
}
+6 -1
View File
@@ -6,6 +6,7 @@ import { sendMail } from 'emailer';
import * as errors from '@@/custom-errors';
import { originMiddleware } from '@@/_middlewares';
import { updateUserHandler } from './update-user';
import { deprovisionLinuxUser } from './provision';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
@@ -35,7 +36,8 @@ usersRouter.post('/invite', async (ctx) => {
}
const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin');
const assignedRole = (typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number]))
const assignedRole =
typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number])
? (role as (typeof USER_ROLES)[number])
: ('Member' as const);
@@ -101,6 +103,9 @@ usersRouter.delete('/:id', async (ctx) => {
const target = await getUserById(id);
if (!target) throw errors.NOT_FOUND('User not found');
// Deprovision Linux user before deleting from database
deprovisionLinuxUser(target.email, target.username ?? '');
await deleteUser(id);
return ctx.json({ ok: true });
});
+24 -7
View File
@@ -1,4 +1,6 @@
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { DATA_PATH } from './data-path';
import { syncSeedSkills } from './sync-skills';
import { syncSeedTools } from './sync-tools';
@@ -13,14 +15,29 @@ import { startWhatsAppBotIfConfigured } from './channels/whatsapp/bot';
mkdirSync(DATA_PATH, { recursive: true });
async function ensurePiInstalled(): Promise<boolean> {
try {
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
await proc.exited;
return proc.exitCode === 0;
} catch {
return false;
/** Check common locations for the Pi package directory. */
async function findPiPackageDir(): Promise<string | null> {
const candidates = [
join(homedir(), '.npm-global', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'),
'/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
];
for (const dir of candidates) {
if (await Bun.file(join(dir, 'package.json')).exists()) return dir;
}
return null;
}
async function ensurePiInstalled(): Promise<boolean> {
const dir = await findPiPackageDir();
if (!dir) return false;
try {
const pkg = await Bun.file(join(dir, 'package.json')).json();
if (pkg.version) {
console.log(`[bootstrap] Pi found: ${pkg.version}`);
return true;
}
} catch {}
return false;
}
async function installPi(): Promise<boolean> {
+8 -10
View File
@@ -91,7 +91,10 @@ export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndA
const lockPromise = new Promise<void>((resolve) => {
releaseLock = resolve;
});
sessionLocks.set(sessionId, existing.then(() => lockPromise));
sessionLocks.set(
sessionId,
existing.then(() => lockPromise),
);
await existing;
@@ -291,23 +294,18 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<Se
(async () => {
try {
if (!session.piProcess) {
let spawnOptions: { sessionFile?: string } | undefined;
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) {
// Remap host path to container path
const containerHome = `/home/${username}`;
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
const relativePart = hostPath.slice(sessionsPrefix.length);
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
spawnOptions = { sessionFile: hostPath, username };
}
}
if (!spawnOptions) spawnOptions = { username };
const dispatcher = createDispatcher(sessionId);
const sandbox = { userId, username, email, homeDir };
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, sandbox, spawnOptions);
session.sandboxed = true;
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, spawnOptions);
const proc = session.piProcess;
proc.exited.then(() => {
+121 -91
View File
@@ -1,8 +1,16 @@
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { existsSync, symlinkSync, rmdirSync } from 'node:fs';
import { ensureDockerContainer } from '@@/api/terminal/websocket';
import { getHomeDir, DATA_PATH, getNativeToolsDir, getGlobalToolsDir, getUserToolsDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import {
getHomeDir,
DATA_PATH,
getNativeToolsDir,
getGlobalToolsDir,
getUserToolsDir,
getNativeSkillsDir,
getGlobalSkillsDir,
getUserSkillsDir,
toShellUsername,
} from '@@/data-path';
import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/tools/tools';
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
@@ -11,6 +19,12 @@ import type { MessageCost, PiEvent } from '@@/api/pi/types';
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
// Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH
const CLAUDE_BIN = (() => {
const result = Bun.spawnSync({ cmd: ['which', 'claude'], stdout: 'pipe', stderr: 'ignore' });
return result.stdout.toString().trim() || 'claude';
})();
type ClaudeCodeParams = {
userId: number;
email: string;
@@ -85,9 +99,16 @@ async function buildToolsSystemPrompt(email: string): Promise<string | null> {
Array.from(mergedTools.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter, body } = parseToolFrontmatter(raw);
const toolDir = dirname(filePath);
const toolDir = join(filePath, '..');
const hasImpl = await Bun.file(`${toolDir}/index.ts`).exists();
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body, toolDir, hasImpl };
return {
dirName,
name: frontmatter.name || dirName,
description: frontmatter.description,
body,
toolDir,
hasImpl,
};
}),
);
const runnerPath = `${getNativeToolsDir()}/run.ts`;
@@ -125,45 +146,59 @@ async function buildToolsSystemPrompt(email: string): Promise<string | null> {
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
const { userId, email, username, prompt, sessionKey } = params;
const homeDir = getHomeDir(email);
const shellUsername = toShellUsername(username, email);
const toolEnv = await buildHostToolEnv(userId, email);
const container = await ensureDockerContainer(email, userId, homeDir, username);
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = container.dockerId;
const containerHome = `/home/${username}`;
const args = [
dockerPath, 'exec', '-i',
'-u', username,
'-w', containerHome,
'-e', `HOME=${containerHome}`,
containerId,
'claude', '-p', prompt,
'--dangerously-skip-permissions',
'--output-format', 'json',
];
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
const existingSession = claudeCodeSessions.get(sessionKey);
if (existingSession) {
args.push('--resume', existingSession);
claudeArgs.push('--resume', existingSession);
}
logger.info('Claude Code exec', { sessionKey, containerId, resume: existingSession ?? null });
const isServiceUser = shellUsername === (process.env.USER ?? '');
const proc = Bun.spawn(args, {
// For service user, keep real HOME so Claude Code finds its credentials
const env: Record<string, string> = {
...toolEnv,
...(isServiceUser ? { HOME: process.env.HOME ?? '' } : {}),
PATH: process.env.PATH ?? '',
TERM: 'xterm-256color',
};
logger.info('Claude Code exec', {
sessionKey,
username: shellUsername,
isServiceUser,
resume: existingSession ?? null,
});
const proc = isServiceUser
? Bun.spawn(claudeArgs, {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
env: { ...process.env, ...env },
})
: Bun.spawn(
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
{
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
},
);
const timeout = setTimeout(() => {
try { proc.kill(); } catch { /* already dead */ }
try {
proc.kill();
} catch {
/* already dead */
}
}, SEND_TIMEOUT_MS);
try {
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const exitCode = await proc.exited;
clearTimeout(timeout);
@@ -233,7 +268,6 @@ type ClaudeCodeStreamingParams = {
prompt: string;
sessionKey: string;
cwd?: string;
sandboxed?: boolean;
onEvent: (event: PiEvent) => void;
};
@@ -242,12 +276,19 @@ type ClaudeCodeStreamingHandle = {
};
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
const { userId, email, username, prompt, sessionKey, cwd, sandboxed = false, onEvent } = params;
const { userId, email, username, prompt, sessionKey, cwd, onEvent } = params;
const shellUsername = toShellUsername(username, email);
const homeDir = getHomeDir(email);
const workDir = cwd ?? homeDir;
const claudeArgs = [
'claude', '-p', prompt,
CLAUDE_BIN,
'-p',
prompt,
'--dangerously-skip-permissions',
'--output-format', 'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
];
@@ -263,69 +304,51 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
claudeArgs.push('--append-system-prompt', systemPrompt);
}
let proc: ReturnType<typeof Bun.spawn>;
if (sandboxed) {
// Container execution via docker exec
const homeDir = getHomeDir(email);
const container = await ensureDockerContainer(email, userId, homeDir, username);
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = container.dockerId;
const containerHome = `/home/${username}`;
const workDir = cwd ?? containerHome;
const args = [
dockerPath, 'exec',
'-u', username,
'-w', workDir,
'-e', `HOME=${containerHome}`,
containerId,
...claudeArgs,
];
logger.info('Claude Code streaming exec (container)', { sessionKey, containerId, resume: existingSession ?? null });
proc = Bun.spawn(args, {
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
});
} else {
// Host execution — run claude directly
const claudePath = Bun.which('claude') ?? 'claude';
claudeArgs[0] = claudePath;
const workDir = cwd ?? homedir();
logger.info('Claude Code streaming exec (host)', { sessionKey, cwd: workDir, resume: existingSession ?? null });
const { CLAUDECODE: _, ...cleanEnv } = process.env;
const toolEnv = await buildHostToolEnv(userId, email);
const { CLAUDECODE: _, ...cleanEnv } = process.env;
// Ensure Claude Code can find ~/.claude credentials in the user's data home.
// Symlink the host's .claude config into the data home if not already there.
const dataHome = toolEnv.HOME!;
const hostClaudeConfig = join(homedir(), '.claude');
const targetClaudeConfig = join(dataHome, '.claude');
const targetCredentials = join(targetClaudeConfig, '.credentials.json');
if (!existsSync(targetCredentials) && existsSync(hostClaudeConfig)) {
try {
// Remove empty placeholder dir if it exists, then symlink
if (existsSync(targetClaudeConfig)) rmdirSync(targetClaudeConfig);
symlinkSync(hostClaudeConfig, targetClaudeConfig);
} catch { /* race, permission, or non-empty dir */ }
}
const isServiceUser = shellUsername === (process.env.USER ?? '');
proc = Bun.spawn(claudeArgs, {
// For service user, keep real HOME so Claude Code finds its credentials
const env: Record<string, string> = {
...toolEnv,
...(isServiceUser ? { HOME: cleanEnv.HOME ?? '' } : {}),
PATH: cleanEnv.PATH ?? '',
TERM: 'xterm-256color',
};
logger.info('Claude Code streaming exec', {
sessionKey,
username: shellUsername,
isServiceUser,
cwd: workDir,
resume: existingSession ?? null,
});
const proc = isServiceUser
? Bun.spawn(claudeArgs, {
cwd: workDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
env: { ...cleanEnv, ...toolEnv },
});
}
env: { ...cleanEnv, ...env },
})
: Bun.spawn(
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
{
cwd: workDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
},
);
const timeout = setTimeout(() => {
try { proc.kill(); } catch { /* already dead */ }
try {
proc.kill();
} catch {
/* already dead */
}
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
}, SEND_TIMEOUT_MS);
@@ -493,12 +516,19 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
if (!gotResult) {
const exitCode = await proc.exited;
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
logger.info('Claude Code exited without result event', { sessionKey, exitCode, stderr: stderr.trim().slice(0, 500) });
logger.info('Claude Code exited without result event', {
sessionKey,
exitCode,
stderr: stderr.trim().slice(0, 500),
});
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
}
if (exitCode !== 0) {
onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` });
onEvent({
type: 'error',
message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`,
});
} else {
onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } });
}
+28 -14
View File
@@ -1,6 +1,16 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { getGlobalToolsDir, getUserToolsDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalTasksDir, getUserTasksDir, getGlobalResourcesDir, getHomeDir, DATA_PATH } from '@@/data-path';
import {
getGlobalToolsDir,
getUserToolsDir,
getGlobalSkillsDir,
getUserSkillsDir,
getGlobalTasksDir,
getUserTasksDir,
getGlobalResourcesDir,
getHomeDir,
DATA_PATH,
} from '@@/data-path';
type HookEntry = { type: string; command: string };
type HookRule = { matcher?: Record<string, unknown>; hooks: HookEntry[] };
@@ -53,9 +63,15 @@ export function generateContainerContext(email: string): string {
const tasks = dedup([...scanDir(getGlobalTasksDir(), 'TASK.md'), ...scanDir(getUserTasksDir(email), 'TASK.md')]);
const resources = scanDir(getGlobalResourcesDir(), 'RESOURCE.md');
const content = `# Officer — Container Environment
const globalToolsDir = getGlobalToolsDir();
const userToolsDir = getUserToolsDir(email);
const globalSkillsDir = getGlobalSkillsDir();
const userSkillsDir = getUserSkillsDir(email);
const userDataDir = join(DATA_PATH, email);
This is a sandboxed development container managed by the Officer platform.
const content = `# Officer — User Environment
This is an isolated Linux user environment managed by the Officer platform.
## Directory Layout
@@ -64,14 +80,14 @@ This is a sandboxed development container managed by the Officer platform.
| \`~\` | User home directory (read-write) |
| \`~/Projects/\` | User projects |
| \`~/Downloads/\` | Downloaded files |
| \`/officer/tools/\` | Global tools (read-only) |
| \`/officer/user/tools/\` | User tools (read-only) |
| \`/officer/skills/\` | Reference skills (read-only) |
| \`/officer/data/\` | User data (emails.db, attachments, etc.) |
| \`${globalToolsDir}/\` | Global tools |
| \`${userToolsDir}/\` | User tools |
| \`${globalSkillsDir}/\` | Reference skills |
| \`${userDataDir}/\` | User data (emails.db, attachments, etc.) |
## Available Tools
Tools are callable capabilities used by the Officer AI agent (Pi). Each tool has a \`TOOL.md\` with documentation and an \`index.ts\` that exports an \`execute()\` function. Read individual tool docs at \`/officer/tools/<name>/TOOL.md\` or \`/officer/user/tools/<name>/TOOL.md\`.
Tools are callable capabilities used by the Officer AI agent (Pi). Each tool has a \`TOOL.md\` with documentation and an \`index.ts\` that exports an \`execute()\` function. Read individual tool docs at \`${globalToolsDir}/<name>/TOOL.md\` or \`${userToolsDir}/<name>/TOOL.md\`.
${formatList(tools)}
## Available Skills
@@ -91,7 +107,7 @@ Resources are external service integrations (TTS, STT, OCR, etc.) configured in
${formatList(resources)}
## Creating New Tools
Create a directory in \`/officer/user/tools/<tool-name>/\` with two files:
Create a directory in \`${userToolsDir}/<tool-name>/\` with two files:
**TOOL.md** — Frontmatter metadata + markdown documentation:
\`\`\`yaml
@@ -118,8 +134,6 @@ export async function execute(_toolCallId: string, params: Record<string, unknow
}
\`\`\`
Full guide: \`/officer/tools/TOOLS.md\`
## Environment Variables
| Variable | Description |
@@ -167,8 +181,8 @@ export function generateClaudeSettings(email: string, username?: string): string
// Pre-seed trust and skip-permissions in .claude.json so interactive Claude Code skips all prompts
if (username) {
const containerHome = `/home/${username}`;
const claudeJsonPath = join(getHomeDir(email), '.claude.json');
const homeDir = getHomeDir(email);
const claudeJsonPath = join(homeDir, '.claude.json');
let claudeJson: Record<string, unknown> = {};
try {
claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) as Record<string, unknown>;
@@ -176,7 +190,7 @@ export function generateClaudeSettings(email: string, username?: string): string
// no existing config
}
const projects = (claudeJson.projects ?? {}) as Record<string, Record<string, unknown>>;
const projectKey = containerHome;
const projectKey = homeDir;
if (!projects[projectKey]) projects[projectKey] = {};
projects[projectKey]!.hasTrustDialogAccepted = true;
claudeJson.projects = projects;
@@ -85,12 +85,7 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
navigate('/chat', { replace: true });
}}
/>
<EmbeddableChat
chat={chat}
sessionId={sessionId}
initialModel={model ?? undefined}
className="flex-1 min-h-0"
/>
<EmbeddableChat chat={chat} sessionId={sessionId} initialModel={model ?? undefined} className="flex-1 min-h-0" />
</div>
);
}
@@ -132,13 +127,13 @@ function NewChat({ allowHostMode }: { allowHostMode?: boolean }) {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const { user } = useAuth();
const showToggle = allowHostMode && user?.role === 'Super Admin';
const [cwdMode, setCwdMode] = useState<'user' | 'host'>('user');
const isSuperAdmin = user?.role === 'Super Admin';
const chat = usePiChat(undefined, locationState?.model);
const sandboxed = showToggle ? cwdMode === 'user' : true;
const cwd = !sandboxed ? { path: getHostHome() } : locationState?.cwd;
// Super Admin always operates as host — no toggle needed
const sandboxed = !isSuperAdmin;
const cwd = isSuperAdmin ? { path: getHostHome() } : locationState?.cwd;
const initialMessage = locationState?.initialMessage
? {
@@ -157,7 +152,6 @@ function NewChat({ allowHostMode }: { allowHostMode?: boolean }) {
isGenerating={chat.isGenerating}
onDelete={undefined}
/>
{!chat.hasStarted && showToggle && <CwdToggle cwdMode={cwdMode} onChange={setCwdMode} />}
<EmbeddableChat
chat={chat}
sessionId={undefined}
@@ -1,29 +1,13 @@
import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles } from 'lucide-react';
import { useWorkspace } from '../../components/Workspace';
import { useAuth } from 'hooks/useAuth';
import { useTerminalMode } from './useTerminalMode';
export const TerminalHeader = ({ panelId }: { panelId: string }) => {
export const TerminalHeader = () => {
const { cwd } = useWorkspace();
const { user } = useAuth();
const { mode, toggle } = useTerminalMode(panelId);
const isHost = mode === 'host';
const scoped = cwd !== '~';
const Icon = isHost && !scoped ? Monitor : TerminalSquare;
return (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
{user?.role === 'Super Admin' && !scoped && (
<button
type="button"
onClick={toggle}
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-white/10 hover:bg-white/20 transition-colors cursor-pointer shrink-0"
>
{isHost ? 'Host' : 'Home'}
</button>
)}
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
@@ -23,7 +23,16 @@ type UsePiChatOptions = {
};
export function usePiChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) {
const { replaceUrl = true, storage, resourceChatDir, taskInfo, projectScoped, context, contextId, onTurnComplete } = options ?? {};
const {
replaceUrl = true,
storage,
resourceChatDir,
taskInfo,
projectScoped,
context,
contextId,
onTurnComplete,
} = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
@@ -77,9 +86,10 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
function commitStreaming() {
if (!streamingRef.current) return;
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: streamingRef.current }]);
const text = streamingRef.current;
streamingRef.current = '';
setStreamingText('');
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
}
function handleMessage(data: unknown) {
@@ -286,7 +296,10 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
setHasStarted(true);
}
setMessages((prev) => [...prev, { role: 'user', text: displayText ?? text, ...(images?.length ? { images } : {}) }]);
setMessages((prev) => [
...prev,
{ role: 'user', text: displayText ?? text, ...(images?.length ? { images } : {}) },
]);
setIsGenerating(true);
streamingRef.current = '';
setStreamingText('');
+8 -3
View File
@@ -30,7 +30,11 @@ export function usePiModels() {
queryKey: ['PI_MODELS'],
enabled: isAuthenticated,
queryFn: async () => {
const data = await client.get<{ models: ModelOption[]; providerNames?: Record<string, string>; hostHome?: string }>('/pi/models');
const data = await client.get<{
models: ModelOption[];
providerNames?: Record<string, string>;
hostHome?: string;
}>('/pi/models');
if (data.providerNames) {
globalProviderNames = data.providerNames;
@@ -48,13 +52,14 @@ export function usePiModels() {
return models;
}
/** Filter models by system-wide access policy. New providers pass through. */
/** Filter models by system-wide access policy. Super Admin sees all. New providers pass through. */
export function useVisiblePiModels() {
const models = usePiModels();
const { user } = useAuth();
const { policy } = useAccessPolicy();
const allowed = policy.allowedModels;
if (allowed.length === 0) return models;
if (user?.role === 'Super Admin' || allowed.length === 0) return models;
const allowedSet = new Set(allowed);
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));