diff --git a/bin/bercail-desk-agent b/bin/bercail-desk-agent index fc933a8..f951062 100755 --- a/bin/bercail-desk-agent +++ b/bin/bercail-desk-agent @@ -5,10 +5,14 @@ # else bercail's [agent] command. opencode with bstack on gets the terminal's # palette (the system theme) and runs bstack, its default agent. The full TUI, # not `opencode mini`: only the full TUI reports its status (OSC 7501), which -# the desk column's header and Review's auto-open read. +# the desk column's header and Review's auto-open read. With [bstack] +# harness = "pi" it is Pi with the lead's profile, which bercail update writes. set -euo pipefail CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/bercail/config.toml" +PI_LEAD="$HOME/.local/share/bercail/pi/lead.json" +# Every Pi desk loads it: it tells the record which session runs there. +PI_DESK_EXTENSION="$HOME/.local/share/bercail/pi-extensions/bercail-desk.ts" # Tabs off: the TUI then reports only its own session, not the other sessions # open in this folder. CLI_CONFIG='{"theme":{"name":"system","mode":"system"},"highlights":false,"tabs":{"mode":"off"}}' @@ -30,12 +34,21 @@ if [[ "${1:-}" == --prompt ]]; then shift 2 || shift $# fi -bstack=0 +bstack=0 on_pi=0 [[ "$(toml_get bstack enabled)" == true ]] && bstack=1 +[[ "$bstack" -eq 1 && "$(toml_get bstack harness)" == pi ]] && on_pi=1 argv=("$@") if [[ ${#argv[@]} -eq 0 ]]; then - if [[ "$bstack" -eq 1 ]]; then + if [[ "$on_pi" -eq 1 ]]; then + [[ -r "$PI_LEAD" ]] || { + printf 'bercail-desk-agent: %s is missing; run bercail update\n' "$PI_LEAD" >&2 + exit 1 + } + while IFS= read -r -d '' arg; do + argv+=("$arg") + done < <(jq -j '.[] | ., "\u0000"' "$PI_LEAD") + elif [[ "$bstack" -eq 1 ]]; then argv=(opencode) else read -r -a argv <<<"$(toml_get agent command)" @@ -91,7 +104,12 @@ flag_has_value() { | "claude --remote-control-session-name-prefix" \ | "claude --setting-sources" | "claude --settings" | "claude --system-prompt" \ | "claude --system-prompt-snapshot" | "claude --teleport" | "claude --tools" \ - | "claude -w" | "claude --worktree") + | "claude -w" | "claude --worktree" \ + | "pi --provider" | "pi --model" | "pi --api-key" | "pi --system-prompt" \ + | "pi --append-system-prompt" | "pi --mode" | "pi --session-dir" | "pi --name" | "pi -n" \ + | "pi --models" | "pi --tools" | "pi -t" | "pi --exclude-tools" | "pi -xt" \ + | "pi --thinking" | "pi --extension" | "pi -e" | "pi --skill" | "pi --prompt-template" \ + | "pi --theme" | "pi --use-theme" | "pi --tui-mode") return 0 ;; esac return 1 @@ -113,7 +131,7 @@ remember_desk() { arg="$1" shift if [[ "$arg" == -- ]]; then - [[ "$harness" == claude ]] || rest+=("$arg" "$@") + [[ "$harness" == claude || "$harness" == pi ]] || rest+=("$arg" "$@") break fi if [[ "$arg" != -* ]]; then @@ -123,17 +141,17 @@ remember_desk() { "opencode run" | "codex exec") return 0 ;; esac fi - # Claude's free positional is its initial prompt, not a resume option. - [[ "$harness" != claude ]] || continue + # Claude's and Pi's free positionals are their first messages, not resume options. + [[ "$harness" != claude && "$harness" != pi ]] || continue rest+=("$arg") continue fi name="$arg" [[ "$arg" == *=* ]] && name="${arg%%=*}" case "$harness $name" in - "claude -p" | "claude --print" | "cursor-agent -p" | "cursor-agent --print") + "claude -p" | "claude --print" | "cursor-agent -p" | "cursor-agent --print" | "pi -p" | "pi --print") return 0 ;; - "opencode --session" | "opencode -s") + "opencode --session" | "opencode -s" | "pi --session" | "pi --session-id") if [[ "$arg" == *=* ]]; then session="${arg#*=}" else @@ -149,10 +167,11 @@ remember_desk() { shift fi continue ;; - "opencode --prompt") + "opencode --prompt" | "pi --fork") [[ "$arg" == *=* ]] || shift || true continue ;; - "opencode --continue" | "opencode -c" | "claude --continue" | "claude -c" | "claude --fork-session") + "opencode --continue" | "opencode -c" | "claude --continue" | "claude -c" | "claude --fork-session" \ + | "pi --continue" | "pi -c" | "pi --resume" | "pi -r") continue ;; esac if [[ "$arg" != *=* && -n "${1:-}" && "${1}" != -* ]] && flag_has_value "$harness" "$name"; then @@ -203,6 +222,14 @@ case "$(basename "${argv[0]}")" in [[ -z "$prompt" ]] || argv+=(--prompt "$prompt") prompt="" ;; + pi) + # First, so a `--` in the command cannot make it a message. + if [[ -f "$PI_DESK_EXTENSION" && " ${argv[*]} " != *" $PI_DESK_EXTENSION "* ]]; then + argv=("${argv[0]}" -e "$PI_DESK_EXTENSION" "${argv[@]:1}") + fi + # bstack reaches Claude through pi-claude-bridge on the plan, not an API key. + [[ "$on_pi" -eq 0 ]] || unset ANTHROPIC_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN + ;; esac [[ -z "$prompt" ]] || argv+=("$prompt") diff --git a/config/pi/extensions/bercail-desk.ts b/config/pi/extensions/bercail-desk.ts new file mode 100644 index 0000000..42fcc26 --- /dev/null +++ b/config/pi/extensions/bercail-desk.ts @@ -0,0 +1,156 @@ +// The desk's Pi session, written when the session starts. The wrapper owns the +// record and its heartbeat; this only fills session and cwd, and only on the +// record that names this process. An ephemeral session cannot be resumed, so +// it stores an empty session and claims nothing. Every Pi that loads this, +// desk or not, also claims the session it has open. +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" + +// /proc/self/stat field 22. comm (field 2) is wrapped in parentheses and may +// contain spaces, so the count starts after the last ")". +export function startTicks(stat) { + if (typeof stat !== "string") return null + const end = stat.lastIndexOf(")") + if (end < 0) return null + const ticks = stat.slice(end + 2).trim().split(/\s+/)[19] + return typeof ticks === "string" && /^\d+$/.test(ticks) ? ticks : null +} + +export function deskRecordPath(env) { + const id = env && env.HC_TERMINAL_ID + if (!id || /[/\\\0]/.test(id)) return null + const root = env.XDG_STATE_HOME || (env.HOME ? join(env.HOME, ".local/state") : "") + if (!root) return null + return join(root, "bercail/desk-sessions", `${id}.json`) +} + +export function claimPath(env, pid, platform = "linux") { + if (platform !== "linux" || !env || !env.XDG_RUNTIME_DIR) return null + if (!Number.isInteger(pid) || pid <= 0) return null + return join(env.XDG_RUNTIME_DIR, "bercail/open-sessions", `${pid}.json`) +} + +// null: leave the file alone (missing, foreign, stale, or already current). +export function deskNext(record, { address, pid, session, cwd }) { + if (!record || typeof record !== "object" || Array.isArray(record)) return null + if (!address || record.address !== address) return null + if (record.harness !== "pi") return null + if (record.pid !== pid) return null + if (typeof session !== "string") return null + const next = { ...record, session } + if (typeof cwd === "string" && cwd) next.cwd = cwd + if (next.session === record.session && next.cwd === record.cwd) return null + return next +} + +// undefined file: ephemeral, cannot be resumed. A path is stored even before +// Pi has written it. +export function deskFields(sessionFile, sessionId, cwd) { + if (sessionFile === undefined) { + const fields = { session: "" } + if (typeof cwd === "string" && cwd) fields.cwd = cwd + return fields + } + if (typeof sessionId !== "string" || !sessionId) return null + const fields = { session: sessionId } + if (typeof cwd === "string" && cwd) fields.cwd = cwd + return fields +} + +// One session at a time. Ephemeral claims nothing. +export function sessionIDs(sessionFile, sessionId) { + if (sessionFile === undefined || typeof sessionId !== "string" || !sessionId) return [] + return [sessionId] +} + +function readJSON(path) { + try { + const value = JSON.parse(readFileSync(path, "utf8")) + if (!value || typeof value !== "object" || Array.isArray(value)) return null + return value + } catch { + return null + } +} + +export function writeAtomic(path, text, mode) { + mkdirSync(dirname(path), { recursive: true }) + const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}` + let keep = mode + try { + keep = statSync(path).mode + } catch {} + writeFileSync(tmp, text, { mode: 0o600, flag: "wx" }) + try { + chmodSync(tmp, keep ?? 0o600) + renameSync(tmp, path) + } catch (err) { + try { + unlinkSync(tmp) + } catch {} + throw err + } +} + +export function applyDesk(path, gate) { + try { + const record = readJSON(path) + if (!record) return false + const next = deskNext(record, gate) + if (!next) return false + writeAtomic(path, `${JSON.stringify(next)}\n`) + return true + } catch { + return false + } +} + +export function applyClaim(path, doc) { + try { + const prev = readJSON(path) + if ( + prev && + prev.pid === doc.pid && + prev.start_ticks === doc.start_ticks && + JSON.stringify(prev.sessions) === JSON.stringify(doc.sessions) + ) { + return false + } + writeAtomic(path, `${JSON.stringify({ pid: doc.pid, start_ticks: doc.start_ticks, sessions: doc.sessions })}\n`, 0o600) + return true + } catch { + return false + } +} + +function publish(ctx) { + try { + const file = ctx.sessionManager.getSessionFile() + const id = file === undefined ? "" : ctx.sessionManager.getSessionId() + const fields = deskFields(file, id, ctx.cwd) + const claim = claimPath(process.env, process.pid, process.platform) + if (claim && (file === undefined || fields)) { + let ticks = null + try { + ticks = startTicks(readFileSync("/proc/self/stat", "utf8")) + } catch {} + if (ticks) applyClaim(claim, { pid: process.pid, start_ticks: ticks, sessions: sessionIDs(file, id) }) + } + const record = deskRecordPath(process.env) + if (record && fields) { + applyDesk(record, { address: process.env.HC_TERMINAL_ID, pid: process.pid, session: fields.session, cwd: fields.cwd }) + } + } catch {} +} + +// /new and /resume replace the runtime and fire session_start again. Shutdown +// must not clear the session: a reboot still needs it. +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + publish(ctx) + }) + pi.on("session_info_changed", (_event, ctx) => { + publish(ctx) + }) +} diff --git a/docs/bstack.md b/docs/bstack.md index 128f1c9..33aa387 100644 --- a/docs/bstack.md +++ b/docs/bstack.md @@ -14,7 +14,7 @@ dev # the desk starts opencode as the bstack agent ## On Pi -New configs set `harness = "pi"` under `[bstack]`. Set it in an existing `config.toml` and run `bercail bstack setup` to install [Pi](https://pi.dev) (>=1.2.0) and `npm:pi-claude-bridge@0.11.0`. Claude uses your Claude Code login (`claude auth login`), not Pi's `/login anthropic` or per-token Extra Usage. GPT and Grok use `/login openai` and `/login xai` inside Pi. Existing configs without `harness` keep using opencode; `harness = "opencode"` selects it explicitly. Automatic Pi desk launching is a separate follow-up. +New configs set `harness = "pi"` under `[bstack]`. Set it in an existing `config.toml` and run `bercail bstack setup` to install [Pi](https://pi.dev) (>=1.2.0) and `npm:pi-claude-bridge@0.11.0`. Claude uses your Claude Code login (`claude auth login`), not Pi's `/login anthropic` or per-token Extra Usage. GPT and Grok use `/login openai` and `/login xai` inside Pi. Existing configs without `harness` keep using opencode; `harness = "opencode"` selects it explicitly. With bstack on Pi, a desk runs Pi as the lead: `bercail update` writes its command line to `pi/lead.json`, and the desk shows its status and [comes back after a reboot](config.md#desks-after-a-reboot) like an opencode desk. bercail renders the profile to `~/.local/share/bercail/pi/`. Installing the bridge adds its pinned package to Pi's settings, leaving unrelated settings and auth intact. Every role explicitly loads the bridge and the managed `extensions/fallback.ts` with `-e`, and unsets `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, and `ANTHROPIC_AUTH_TOKEN` so fallback to Claude uses your plan. Role boundaries and git rules remain instructions, not enforced tool restrictions. The builder, judge, council, and autopilot run as interactive `hc` sibling agents from templates in `~/.config/herdr-core/templates/`. Pi handoffs use desks or interactive siblings, not one-shot runs. diff --git a/docs/config.md b/docs/config.md index 03a3cef..4d1e241 100644 --- a/docs/config.md +++ b/docs/config.md @@ -36,7 +36,7 @@ After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and b - `~/.local/bin/{bercail,agentic-dev,bercail-desk-agent,bercail-desk-claude-hook}` (a mise or packslip install keeps its own `bercail`), and `~/.local/share/bercail/`. - `~/.agents/skills/{handoff,review,bstack,council,autopilot,how,why,unslop,desk-restore}`. - bstack's Pi profile in `~/.local/share/bercail/pi/` and role templates in `~/.config/herdr-core/templates/`, or its files in `~/.config/opencode` (see [Make it yours](bstack/08-make-it-yours.md#your-own-files)). The Pi harness installs `npm:pi-claude-bridge@0.11.0` into Pi's package settings. -- The local TUI plugin `~/.config/opencode/plugins/bercail-desk/tui.ts`, including when bstack is off. +- The local TUI plugin `~/.config/opencode/plugins/bercail-desk/tui.ts`, and the Pi extension `~/.local/share/bercail/pi-extensions/bercail-desk.ts` that every Pi desk loads with `-e`, including when bstack is off. - `diff_watch_interval_ms` in `~/.config/tuicr/config.toml`, unless you set it already. - On Linux and Omarchy, the extras in [platforms](development.md#platforms). - One `SessionStart` entry in `~/.claude/settings.json`, when `~/.claude` exists (`bercail uninstall` takes it back). @@ -45,8 +45,8 @@ After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and b Herdr brings back spaces, tabs, and each desk's terminal, but as a bare shell: it never reruns a program. So `bercail-desk-agent` writes down what ran there, and the desk comes back on the same session in the same space, with no prompt sent again. -- **The record.** `~/.local/state/bercail/desk-sessions/
.json` holds `session`, `cwd`, `harness`, `issue`, `space`, `args` (resume-safe flags) and `pid`, keyed by the terminal's address. A local opencode TUI plugin records the selected session, even while idle and with bstack off. A watcher touches the file every minute while the desk lives. Claude's session comes from a `SessionStart` hook. Headless runs get no record. +- **The record.** `~/.local/state/bercail/desk-sessions/
.json` holds `session`, `cwd`, `harness`, `issue`, `space`, `args` (resume-safe flags) and `pid`, keyed by the terminal's address. A local opencode TUI plugin records the selected session, even while idle and with bstack off; on a Pi desk the `bercail-desk` extension records Pi's session the same way. A watcher touches the file every minute while the desk lives. Claude's session comes from a `SessionStart` hook. Headless runs get no record. - **What comes back.** A record fresh within ten minutes of shutdown (the last journal line of the previous boot), on a desk at a bare shell, in a folder that still exists, whose session is not already open. Records written since shutdown also qualify for a manual restore after a core stop/start. A desk you quit has finished, so it stays quit. Older records stay shells. -- **Duplicate checks.** Restore checks process arguments, all live Herdr terminals' status records, and the opencode plugin's runtime claims in `$XDG_RUNTIME_DIR/bercail/open-sessions/`. Claims include idle sessions and tabs, and count only while their original process is alive. TUIs started before the plugin was installed may have no claim: check those manually before restoring. +- **Duplicate checks.** Restore checks process arguments, all live Herdr terminals' status records, and the opencode plugin's and Pi extension's runtime claims in `$XDG_RUNTIME_DIR/bercail/open-sessions/`. Claims include idle sessions and tabs, and count only while their original process is alive. TUIs started before the plugin was installed may have no claim: check those manually before restoring. - **When.** Once per boot, through Omarchy's post-boot hook or else a systemd user unit: it waits until you open Herdr, then a notification says how many came back. Omarchy logs to `~/.local/state/bercail/desk-restore.log`; systemd logs to `journalctl --user -u bercail-desk-restore`. After a core restart, or to run it yourself, use `bercail desk restore`; `--dry-run` lists each desk and why. -- **What does not.** An opencode home screen with no selected session has nothing to resume. Codex and cursor-agent desks are not recorded with a session. No boot hook runs on macOS or without Omarchy/systemd; manual restore is still available. For these, and for desks from before records existed, the `desk-restore` skill finds the session by folder and content. Install/update wires the hooks for future sessions, not agents already running. +- **What does not.** An opencode home screen with no selected session has nothing to resume. The lead's `hc` siblings (builder, judge, council) are not desks and stay shells. Codex and cursor-agent desks are not recorded with a session. No boot hook runs on macOS or without Omarchy/systemd; manual restore is still available. For these, and for desks from before records existed, the `desk-restore` skill finds the session by folder and content. Install/update wires the hooks for future sessions, not agents already running. diff --git a/docs/development.md b/docs/development.md index 0010a7b..41d79a1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,7 +16,7 @@ npm run build:site && npx wrangler dev --config site/wrangler.jsonc # the berc CI runs every `tests/*.sh` on each push and PR, and deploys from master when `CLOUDFLARE_API_TOKEN` is set. -Use Node 26 for the plugin unit tests. When opencode is installed, `tests/desk_session_plugin_test.sh` also checks the plugin in disposable TUIs against a private server, without sending a model prompt or changing your sessions. +Use Node 26 for the plugin unit tests. When opencode is installed, `tests/desk_session_plugin_test.sh` also checks the plugin in disposable TUIs against a private server, without sending a model prompt or changing your sessions. When Pi is installed, `tests/desk_session_pi_test.sh` does the same for the Pi desk extension, offline, with a stand-in model. `site/` is bercail.dev: an Astro site served by the `bercail-site` Worker, which also serves `/install.sh` from master. `workers/setup-domain` keeps the legacy setup.simoncrypta.dev host alive for older installs. Both share `workers/setup-domain/src/github.js`. diff --git a/docs/install.md b/docs/install.md index 10241ba..f013596 100644 --- a/docs/install.md +++ b/docs/install.md @@ -63,4 +63,4 @@ dev # this directory's space, with its desk agent # or: t # open the Herdr app ``` -With [bstack](bstack.md) on, a space starts its desk as bstack (opencode with your terminal's colors) on its own, shown in the desk column. Quit it and it stays quit until `Ctrl+Shift+A` or `dev`, even across a reboot; one still running at shutdown [comes back](config.md#desks-after-a-reboot). With bstack off nothing starts on its own; `Ctrl+Shift+A` or `dev` starts `[agent] command` from `~/.config/bercail/config.toml`. Handoff stages do not read either; each stage names its own binary. +With [bstack](bstack.md) on, a space starts its desk as bstack (opencode with your terminal's colors, or Pi with `harness = "pi"`) on its own, shown in the desk column. Quit it and it stays quit until `Ctrl+Shift+A` or `dev`, even across a reboot; one still running at shutdown [comes back](config.md#desks-after-a-reboot). With bstack off nothing starts on its own; `Ctrl+Shift+A` or `dev` starts `[agent] command` from `~/.config/bercail/config.toml`. Handoff stages do not read either; each stage names its own binary. diff --git a/lib/bstack.sh b/lib/bstack.sh index 97b9548..27c9fa8 100644 --- a/lib/bstack.sh +++ b/lib/bstack.sh @@ -393,21 +393,29 @@ _bstack_pi_model_id() { printf '%s' "${1/\#/:}" } +# The pi argv a bstack agent runs, into BSTACK_PI_ARGV. Skills are named because +# settings.json may filter them out. +_bstack_pi_argv() { + local model="$1" brief="${2:-}" id + BSTACK_PI_ARGV=(pi) + [[ -z "$model" ]] || BSTACK_PI_ARGV+=(--model "$(_bstack_pi_model_id "$model")") + BSTACK_PI_ARGV+=(-e "$BSTACK_PI_BRIDGE" -e "$BSTACK_PI_DIR/extensions/fallback.ts") + BSTACK_PI_ARGV+=(--prompt-template "$BSTACK_PI_DIR/prompts" + --append-system-prompt "$BSTACK_PI_DIR/instructions.md") + [[ -z "$brief" ]] || BSTACK_PI_ARGV+=(--append-system-prompt "$BSTACK_PI_DIR/agents/$brief.md") + for id in "${AGENTIC_DEV_SKILL_IDS[@]}"; do + BSTACK_PI_ARGV+=(--skill "$(skill_canonical_dir "$id")") + done +} + # The command line a bstack agent starts with. BERCAIL_ROLE identifies its role; -# empty is the lead. Skills are named because settings.json may filter them out. +# empty is the lead. bstack_pi_command() { - local role="$1" model="$2" brief="${3:-}" id + local role="$1" model="$2" brief="${3:-}" local -a argv=(env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN) [[ -z "$role" ]] || argv+=("BERCAIL_ROLE=$role") - argv+=(pi) - [[ -z "$model" ]] || argv+=(--model "$(_bstack_pi_model_id "$model")") - argv+=(-e "$BSTACK_PI_BRIDGE" -e "$BSTACK_PI_DIR/extensions/fallback.ts") - argv+=(--prompt-template "$BSTACK_PI_DIR/prompts" - --append-system-prompt "$BSTACK_PI_DIR/instructions.md") - [[ -z "$brief" ]] || argv+=(--append-system-prompt "$BSTACK_PI_DIR/agents/$brief.md") - for id in "${AGENTIC_DEV_SKILL_IDS[@]}"; do - argv+=(--skill "$(skill_canonical_dir "$id")") - done + _bstack_pi_argv "$model" "$brief" + argv+=("${BSTACK_PI_ARGV[@]}") printf '%q ' "${argv[@]}" | sed 's/ $//' } @@ -440,6 +448,11 @@ _bstack_deploy_pi() { copy_file "$work/fallback" "$BSTACK_PI_DIR/fallback" _bstack_body "$work/src/agents/bstack-pr-review.md" >"$work/pr-review.md" copy_file "$work/pr-review.md" "$BSTACK_PI_DIR/agents/bstack-pr-review.md" + # The desk's lead, as a JSON argv: bercail-desk-agent runs it with the + # Anthropic env unset. + _bstack_pi_argv "$(bstack_role_model lead)" + jq -n '$ARGS.positional' --args -- "${BSTACK_PI_ARGV[@]}" >"$work/lead.json" + copy_file "$work/lead.json" "$BSTACK_PI_DIR/lead.json" ensure_dir "$BSTACK_PI_TEMPLATES" _bstack_pi_template "$work" bstack-builder builder "$(bstack_role_model build)" bstack-builder \ @@ -594,7 +607,7 @@ _bstack_doctor_pi() { else log " -- Claude auth: no subscription login (claude auth login)" fi - for rel in "${BSTACK_PI_RENDERED[@]}" agents/bstack-pr-review.md extensions/fallback.ts models fallback; do + for rel in "${BSTACK_PI_RENDERED[@]}" agents/bstack-pr-review.md extensions/fallback.ts models fallback lead.json; do if [[ ! -f "$BSTACK_PI_DIR/$rel" ]]; then log " missing bstack $BSTACK_PI_DIR/$rel (bercail update)" missing=1 diff --git a/lib/config.sh b/lib/config.sh index 974b510..f5cc92a 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -546,6 +546,8 @@ deploy_finalize_permissions() { deploy_install_file "bin/bercail-desk-claude-hook" "$LOCAL_BIN/bercail-desk-claude-hook" deploy_install_file "config/opencode/plugins/bercail-desk/tui.ts" \ "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/bercail-desk/tui.ts" + deploy_install_file "config/pi/extensions/bercail-desk.ts" \ + "${AGENTIC_DEV_SHARE_DIR}/pi-extensions/bercail-desk.ts" deploy_install_file "lib/desk.sh" "${AGENTIC_DEV_SHARE_DIR}/lib/desk.sh" if [[ -f "${AGENTIC_DEV_SHARE_DIR}/lib/desk.sh" ]]; then # shellcheck source=/dev/null diff --git a/lib/desk.sh b/lib/desk.sh index 271a8ab..7b3b494 100644 --- a/lib/desk.sh +++ b/lib/desk.sh @@ -6,7 +6,7 @@ # # Record: desk-sessions/
.json {address, session, cwd, harness, issue, # space, args, pid}. Its mtime is a heartbeat while the agent lives. -# A live TUI also writes $XDG_RUNTIME_DIR/bercail/open-sessions/.json +# A live opencode TUI or Pi also writes $XDG_RUNTIME_DIR/bercail/open-sessions/.json # {pid, start_ticks, sessions} so an idle agent (hc records {}) is not started twice. # A record this close to the shutdown was still running then. @@ -70,7 +70,7 @@ desk_restore_plan() { elif $r == null then ["skip", "no record: see the desk-restore skill"] elif $r.space != $t.labels["bercail.space"] then ["skip", "record is for another space"] elif ($r.session // "") == "" then ["skip", "no session yet"] - elif ($r.harness | IN("opencode", "claude") | not) then ["skip", "cannot resume \($r.harness)"] + elif ($r.harness | IN("opencode", "claude", "pi") | not) then ["skip", "cannot resume \($r.harness)"] elif $d.mtime < ($shutdown - $window) then ["skip", "closed before the shutdown"] elif ($d.cwd_exists | not) then ["skip", "folder is gone: \($r.cwd)"] elif $d.in_ps or ($open | index($r.session)) or ($taken | index($r.session)) then ["skip", "session is open elsewhere"] @@ -89,6 +89,8 @@ desk_restore_plan() { # The line typed into the bare shell. exec: quitting the agent then finishes the # terminal as a quit desk does, instead of leaving a live labelled shell. # Same terminal settings as a desk's spawn; no prompt, the session has it. +# Pi takes --session-id: it creates the session when it was never written (Pi +# writes one at its first message), where --session would exit. desk_resume_command() { local record="$1" agent harness session cwd flag arg cmd agent="$LOCAL_BIN/bercail-desk-agent" @@ -96,8 +98,11 @@ desk_resume_command() { harness="$(jq -r .harness <<<"$record")" session="$(jq -r .session <<<"$record")" cwd="$(jq -r .cwd <<<"$record")" - flag=--session - [[ "$harness" == claude ]] && flag=--resume + case "$harness" in + claude) flag=--resume ;; + pi) flag=--session-id ;; + *) flag=--session ;; + esac printf -v cmd 'cd %q && TERM=xterm-256color COLORTERM=truecolor exec %q %q %q %q' \ "$cwd" "$agent" "$harness" "$flag" "$session" # NUL, not lines: a recorded flag may contain a newline, and a line split would diff --git a/skills/desk-restore/SKILL.md b/skills/desk-restore/SKILL.md index 95ed232..f69fdc8 100644 --- a/skills/desk-restore/SKILL.md +++ b/skills/desk-restore/SKILL.md @@ -54,6 +54,9 @@ when nothing fits. # Claude Code: find the folder's transcripts by their recorded cwd rg -l --fixed-strings -- "$(jq -cn --arg cwd "$FOLDER" '{cwd:$cwd}' | sed 's/^{//;s/}$//')" \ ~/.claude/projects --glob '*.jsonl' +# Pi: each session file's first line is its header, with its id and cwd +rg -l --fixed-strings -- "$(jq -cn --arg cwd "$FOLDER" '{cwd:$cwd}' | sed 's/^{//;s/}$//')" \ + ~/.pi/agent/sessions --glob '*.jsonl' # Codex rg -l --fixed-strings -- "$(jq -cn --arg cwd "$FOLDER" '{cwd:$cwd}' | sed 's/^{//;s/}$//')" \ ~/.codex/sessions --glob 'rollout-*.jsonl' @@ -92,6 +95,7 @@ AGENT="$HOME/.local/bin/bercail-desk-agent" # Choose ONE harness and use the verified session, address and folder. argv=(opencode --session "$SESSION") # argv=(claude --resume "$SESSION") +# argv=(pi --session-id "$SESSION") # argv=(codex resume "$SESSION") # argv=(cursor-agent --resume "$SESSION") printf -v command 'cd %q && exec %q' "$FOLDER" "$AGENT" diff --git a/tests/bstack_test.sh b/tests/bstack_test.sh index 206abdc..7aa2f18 100644 --- a/tests/bstack_test.sh +++ b/tests/bstack_test.sh @@ -603,6 +603,13 @@ test_pi_deploy() { grep -rqi 'headless' "$p/prompts" "$p/instructions.md" && fail "Pi has no headless fallback" grep -q -- '--branch NAME --stage bstack --issue ID' "$p/prompts/handoff.md" || fail "handoff opens a desk" cmp "$ROOT/bstack/pi/extensions/fallback.ts" "$p/extensions/fallback.ts" || fail "managed fallback extension" + jq -e --arg p "$p" '.[0:3] == ["pi", "--model", "claude-bridge/claude-opus-5-5:high"] + and (index(["-e", "npm:pi-claude-bridge@0.11.0"]) != null) + and (index(["-e", "\($p)/extensions/fallback.ts"]) != null) + and (index(["--prompt-template", "\($p)/prompts"]) != null) + and (index(["--append-system-prompt", "\($p)/instructions.md"]) != null) + and (index("--skill") != null) and (index("env") == null)' "$p/lead.json" >/dev/null \ + || fail "the desk's lead: $(cat "$p/lead.json")" assert_eq "$(printf 'claude\ngpt\ngrok')" "$(<"$p/fallback")" "default runtime fallback" grep -q 'bercail.role=agent' "$p/instructions.md" || fail "instructions warn off the desk's label" diff --git a/tests/desk_agent_test.sh b/tests/desk_agent_test.sh index a28c9d2..e140cc3 100755 --- a/tests/desk_agent_test.sh +++ b/tests/desk_agent_test.sh @@ -20,7 +20,15 @@ printf ' cli=%s\n' "${OPENCODE_CLI_CONFIG_CONTENT:-none}" AGENT chmod +x "$TMP_DIR/bin/$agent" done -export PATH="$TMP_DIR/bin:$PATH" XDG_CONFIG_HOME="$TMP_DIR/xdg" +cat >"$TMP_DIR/bin/pi" <<'AGENT' +#!/usr/bin/env bash +printf 'pi' +for a in "$@"; do printf ' [%s]' "$a"; done +printf ' key=%s\n' "${ANTHROPIC_API_KEY:-none}" +AGENT +chmod +x "$TMP_DIR/bin/pi" +# HOME holds the Pi lead and the desk extension bercail update writes. +export PATH="$TMP_DIR/bin:$PATH" XDG_CONFIG_HOME="$TMP_DIR/xdg" HOME="$TMP_DIR/home" # BASH_ENV can put the real agents back in front of the fakes. unset OPENCODE_CLI_CONFIG_CONTENT BASH_ENV HC_TERMINAL_ID CONFIG="$XDG_CONFIG_HOME/bercail/config.toml" @@ -51,6 +59,28 @@ rm "$CONFIG" expect "$(desk)" "cursor-agent cli=none" "no config" printf 'PASS: bstack off runs [agent] command (cursor-agent without a config)\n' +# --- bstack on Pi --- + +LEAD="$HOME/.local/share/bercail/pi/lead.json" +EXT="$HOME/.local/share/bercail/pi-extensions/bercail-desk.ts" +printf '[agent]\ncommand = "claude"\n\n[bstack]\nenabled = true\nharness = "pi"\n' >"$CONFIG" +got="$(desk 2>&1)" && fail "a Pi desk without its lead should not start: $got" +[[ "$got" == *"lead.json is missing; run bercail update"* ]] || fail "missing lead says what to run: $got" +mkdir -p "$(dirname "$LEAD")" "$(dirname "$EXT")" +jq -n '$ARGS.positional' --args -- pi --model claude-bridge/opus:high --append-system-prompt "/a b/instructions.md" >"$LEAD" +expect "$(ANTHROPIC_API_KEY=secret desk --prompt 'Do bercail-1')" \ + "pi [--model] [claude-bridge/opus:high] [--append-system-prompt] [/a b/instructions.md] [Do bercail-1] key=none" \ + "bstack on Pi runs the lead, the prompt as its first message, without an API key" +: >"$EXT" +expect "$(desk)" "pi [-e] [$EXT] [--model] [claude-bridge/opus:high] [--append-system-prompt] [/a b/instructions.md] key=none" \ + "the lead loads the desk extension" +expect "$(desk pi -e "$EXT" --verbose)" "pi [-e] [$EXT] [--verbose] key=none" "the desk extension is loaded once" +expect "$(desk pi -- '- a message')" "pi [-e] [$EXT] [--] [- a message] key=none" "the extension comes before --" +printf '[bstack]\nenabled = false\nharness = "pi"\n' >"$CONFIG" +expect "$(ANTHROPIC_API_KEY=mine desk pi)" "pi [-e] [$EXT] key=mine" "bstack off: any Pi desk gets the extension, the env stays" +rm -f "$EXT" +printf 'PASS: bstack on Pi runs the lead from lead.json, with the desk extension and no Anthropic env\n' + # --- the desk's record, for bercail desk restore --- export XDG_STATE_HOME="$TMP_DIR/state" @@ -148,6 +178,19 @@ desk claude --model sonnet -- 'another prompt' >/dev/null expect "$(record .args)" '["--model","sonnet"]' "Claude keeps flag values but not a prompt after --" printf 'PASS: claude replays no initial prompt\n' +got="$(desk --prompt 'Your job is beads issue bercail-yql. Run it.' pi --model m:high -e /x.ts --skill /s 'native')" +[[ "$got" == "pi [--model] [m:high] [-e] [/x.ts] [--skill] [/s] [native] [Your job is beads issue bercail-yql. Run it.] key=none" ]] \ + || fail "a Pi desk's argv does not change: $got" +expect "$(record '[.session, .harness, .issue, .args]')" '["","pi","bercail-yql",["--model","m:high","-e","/x.ts","--skill","/s"]]' \ + "Pi keeps flag values, not its first messages" +desk pi --session-id 0199-a --thinking high -- 'a message' >/dev/null +expect "$(record '[.session, .issue, .args]')" '["0199-a","bercail-yql",["--thinking","high"]]' \ + "Pi's resume names its session and keeps the terminal's issue" +desk pi --session=0199-b -c --resume --fork 0199-c --name desk >/dev/null +expect "$(record '[.session, .args]')" '["0199-b",["--name","desk"]]' \ + "Pi does not replay continue, resume or fork beside its session" +printf 'PASS: Pi records its session and resume-safe flags\n' + settings=$'{\n "permissions": {}\n}' desk claude --settings "$settings" >/dev/null expect "$(record .args)" "$(jq -cn --arg settings "$settings" '["--settings", $settings]')" \ @@ -194,7 +237,8 @@ rm -f "$RECORDS/t9.json" for headless in "opencode run hi" "claude -p hi" "claude --print hi" "codex exec hi" "cursor-agent -p hi" \ "opencode --print-logs run hi" "opencode --log-level debug run hi" "opencode --server http://127.0.0.1:1 run hi" \ "opencode --continue run hi" "codex --no-daemon exec hi" "codex --model gpt exec hi" "codex -m gpt exec" \ - "claude --print=hi" "claude --verbose --print hi" "cursor-agent --print=hi" "cursor-agent --force --print=out"; do + "claude --print=hi" "claude --verbose --print hi" "cursor-agent --print=hi" "cursor-agent --force --print=out" \ + "pi -p hi" "pi --model m --print hi"; do # shellcheck disable=SC2086 desk $headless >/dev/null [[ ! -e "$RECORDS/t9.json" ]] || fail "headless run got a record: $headless" diff --git a/tests/desk_restore_test.sh b/tests/desk_restore_test.sh index a8f204e..40f02ec 100644 --- a/tests/desk_restore_test.sh +++ b/tests/desk_restore_test.sh @@ -99,7 +99,19 @@ expect "$(one "$(record ses_a)" "$fresh" "$bare" true false '["ses_a"]')" \ printf 'PASS: a desk without a session, folder or free session stays a shell\n' expect "$(jq -r '.[0].action' <<<"$(fact t1 "$(record ses_a claude)" "$fresh" "$bare" | jq -s . | desk_restore_plan "$SHUTDOWN")")" resume "claude resumes" -printf 'PASS: claude desks resume too\n' +expect "$(one "$(record 0199-pi pi)" "$fresh" "$bare")" "t1 resume " "pi resumes" +printf 'PASS: claude and pi desks resume too\n' + +# The lead's hc siblings (bstack-builder, judge, council...) are not desks. After +# a reboot they are bare or still recovering; neither is acted on or waited for. +sibling="$(jq -c '.labels = {"bercail.role": "bstack-builder", "bercail.space": "sp1"}' <<<"$bare")" +working="$(jq -c '.labels = {"bercail.role": "bstack-judge", "bercail.space": "sp1"} + | .foreground = {name: "pi", pid: 12} | .records = {"0199-judge": {"app": "pi", "state": "working"}}' <<<"$running")" +got="$(verdict "$(fact t1 "$(record 0199-pi pi)" "$fresh" "$bare")" "$(fact t2 null 0 "$sibling")" \ + "$(fact t3 null 0 "$(jq -c '.reason = "stopped_by_restart" | .foreground = null' <<<"$sibling")")" \ + "$(fact t4 null 0 "$working")")" +expect "$got" "t1 resume " "siblings are not resumed and do not hold the desk back" +printf 'PASS: hc siblings are left alone and do not block their desk\n' # --- what gets typed --- @@ -110,6 +122,9 @@ cmd="$(desk_resume_command "$rec")" [[ "$cmd" != *--prompt* && "$cmd" != *x-1* ]] || fail "the issue prompt is not resent: $cmd" cmd="$(desk_resume_command "$(jq -c '.harness = "claude" | .args = ["--verbose"]' <<<"$rec")")" [[ "$cmd" == *"claude --resume ses_a --verbose" ]] || fail "claude resumes with --resume: $cmd" +cmd="$(desk_resume_command "$(jq -c '.harness = "pi" | .session = "0199-a" | .args = ["--model", "m:high"]' <<<"$rec")")" +[[ "$cmd" == *"bercail-desk-agent pi --session-id 0199-a --model m:high" ]] \ + || fail "pi resumes with --session-id, which also opens a session never written: $cmd" nl="$(printf 'line1\nline2')" cmd="$(desk_resume_command "$(jq -cn --arg a "$nl" '{session: "ses_a", harness: "opencode", cwd: "/w", args: [$a]}')")" [[ "$cmd" == *"\$'line1\\nline2'" ]] || fail "a newline in a flag stays one argument: $cmd" @@ -184,10 +199,10 @@ chmod +x "$TMP_DIR/bin/journalctl" terminal() { # address json jq -c --arg a "$1" '. + {address: $a}' <<<"$2" >"$TERMS/$1.json" } -put_record() { # address session age-before-shutdown +put_record() { # address session age-before-shutdown [harness] mkdir -p "$RECORDS" - jq -n --arg a "$1" --arg s "$2" --arg cwd "$TMP_DIR" \ - '{address: $a, session: $s, harness: "opencode", cwd: $cwd, issue: "x-1", space: "sp1", args: []}' >"$RECORDS/$1.json" + jq -n --arg a "$1" --arg s "$2" --arg cwd "$TMP_DIR" --arg h "${4:-opencode}" \ + '{address: $a, session: $s, harness: $h, cwd: $cwd, issue: "x-1", space: "sp1", args: []}' >"$RECORDS/$1.json" touch -d "@$(($(cat "$TMP_DIR/shutdown") - $3))" "$RECORDS/$1.json" } reset_pass() { @@ -222,6 +237,18 @@ expect "$(wc -l <"$SENT" | tr -d ' ')" 1 "only the fresh desk is typed into" [[ "$out" == *"1 came back"* ]] || fail "restore counts: $out" printf 'PASS: restore types into the fresh desk only and drops dead records\n' +# A reboot with one Pi desk, one opencode desk from before Pi, and the Pi lead's +# sibling still recovering: both desks come back in one pass. +reset_pass +terminal t1 "$bare"; put_record t1 0199-pi 60 pi +terminal t2 "$bare"; put_record t2 ses_old 60 +terminal t3 "$(jq -c '.reason = "stopped_by_restart" | .foreground = null' <<<"$sibling")" +out="$(cmd_desk restore)" +expect "$(sort "$SENT" | sed 's/^\(t[0-9]*\) .*exec [^ ]* /\1 /')" $'t1 pi --session-id 0199-pi\nt2 opencode --session ses_old' \ + "the Pi desk and the opencode desk are typed into" +[[ "$out" == *"2 came back"* && "$out" != *t3* ]] || fail "siblings are not counted or waited for: $out" +printf 'PASS: a Pi desk and an opencode desk come back together\n' + touch "$TMP_DIR/hc-down" if out="$(cmd_desk restore 2>&1)"; then fail "hc down should fail: $out"; fi [[ -f "$RECORDS/t1.json" ]] || fail "hc down must not read as terminals gone" diff --git a/tests/desk_session_pi_live.py b/tests/desk_session_pi_live.py new file mode 100755 index 0000000..667e66d --- /dev/null +++ b/tests/desk_session_pi_live.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +# Disposable Pi TUI: the extension records the session without a subscription. +import json +import os +import pty +import select +import shlex +import signal +import struct +import subprocess +import sys +import termios +import fcntl +import threading +import time +from pathlib import Path + +ROOT = Path(sys.argv[1]) +PI = sys.argv[2] +EXT = ROOT / "config/pi/extensions/bercail-desk.ts" +PROVIDER = ROOT / "tests/desk_session_pi_provider.ts" +TMP = Path("/tmp") +WORK = None +PROCS = [] +FAILS = [] +TOKEN = "desk-pi-ping" + + +def fail(message): + FAILS.append(message) + print(f"FAIL: {message}", file=sys.stderr) + + +def kill_all(): + for proc in PROCS: + try: + os.killpg(proc.pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + time.sleep(0.2) + for proc in PROCS: + try: + os.killpg(proc.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + + +def child_env(base, **extra): + env = os.environ.copy() + drop = { + "HC_TERMINAL_ID", + "HC_SESSION", + "HC_TERMINAL_SOCKET", + "BASH_ENV", + "PI_CODING_AGENT_DIR", + "PI_SESSION_ID", + "PI_SESSION_FILE", + "PI_PROVIDER", + "PI_MODEL", + "PI_REASONING_LEVEL", + "PI_OFFLINE", + } + for key in list(env): + if key.startswith("OPENCODE") or key in drop: + env.pop(key, None) + env.update(base) + env.update(extra) + env["TERM"] = "xterm-256color" + env["COLORTERM"] = "truecolor" + env["PI_OFFLINE"] = "1" + return env + + +def start_ticks(pid): + text = Path(f"/proc/{pid}/stat").read_text() + return text[text.rfind(")") + 2 :].split()[19] + + +def record_text(address, pid, harness): + return ( + json.dumps( + { + "address": address, + "session": "", + "cwd": "/stale", + "harness": harness, + "issue": "bercail-1", + "space": "sp1", + "args": ["--verbose"], + "pid": pid, + } + ) + + "\n" + ) + + +def wait_for(label, pred, timeout, proc=None): + deadline = time.time() + timeout + while time.time() < deadline: + if proc is not None and proc.poll() is not None: + fail(f"{label}: process exited {proc.returncode}") + return False + try: + if pred(): + return True + except Exception: + pass + time.sleep(0.1) + fail(f"timed out: {label}") + return False + + +class Tui: + def __init__(self, env, args, record=None, address=None, harness="pi"): + self.term = WORK / f"terminal-{address or 'bare'}-{len(PROCS)}.log" + self.master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0)) + if record is not None: + wrap = WORK / f"wrap-{address}-{len(PROCS)}.sh" + wrap.write_text( + "#!/bin/bash\n" + "set -euo pipefail\n" + "python3 -c 'import json,os,sys\n" + "p,pid,addr,harness=sys.argv[1:]\n" + "os.makedirs(os.path.dirname(p), exist_ok=True)\n" + "open(p,\"w\").write(json.dumps({\"address\":addr,\"session\":\"\",\"cwd\":\"/stale\"," + "\"harness\":harness,\"issue\":\"bercail-1\",\"space\":\"sp1\",\"args\":[\"--verbose\"]," + "\"pid\":int(pid)})+\"\\n\")\n" + "os.chmod(p, 0o640)\n" + f"' {shlex.quote(str(record))} $$ {shlex.quote(address)} {shlex.quote(harness)}\n" + f"exec {shlex.quote(PI)} \"$@\"\n" + ) + wrap.chmod(0o755) + cmd = [str(wrap), *args] + else: + cmd = [PI, *args] + self.proc = subprocess.Popen( + cmd, + stdin=slave, + stdout=slave, + stderr=subprocess.STDOUT, + env=env, + cwd=str(WORK / "project"), + start_new_session=True, + ) + os.close(slave) + PROCS.append(self.proc) + self.sink = open(self.term, "ab") + threading.Thread(target=self._drain, daemon=True).start() + + def _drain(self): + while True: + try: + ready, _, _ = select.select([self.master], [], [], 0.2) + except (OSError, ValueError): + return + if self.master not in ready: + if self.proc.poll() is not None: + return + continue + try: + data = os.read(self.master, 65536) + except OSError: + return + if not data: + return + self.sink.write(data) + self.sink.flush() + + def send(self, text, delay=0.03): + for ch in text: + os.write(self.master, ch.encode()) + time.sleep(delay) + + def close(self): + try: + os.killpg(self.proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + os.close(self.master) + except OSError: + pass + + +def read_json(path): + return json.loads(Path(path).read_text()) + + +def pi_args(session_dir, extra=None): + args = [ + "--offline", + "-ne", + "-e", + str(PROVIDER), + "-e", + str(EXT), + "--model", + "fake/model", + "--thinking", + "off", + "--session-dir", + str(session_dir), + "--no-skills", + "--no-prompt-templates", + "--no-context-files", + "--no-mcp", + "--no-tools", + ] + if extra: + args.extend(extra) + return args + + +def session_file(session_dir, session_id): + matches = [path for path in Path(session_dir).rglob("*.jsonl") if path.name.endswith(f"_{session_id}.jsonl")] + return matches[0] if len(matches) == 1 else None + + +def dump(tui=None): + if tui is None: + return + try: + text = tui.term.read_text(errors="replace") + except OSError: + return + print("--- terminal ---", file=sys.stderr) + print(text[-2000:], file=sys.stderr) + + +def main(): + global WORK + run = TMP / f"desk-session-pi.{os.getpid()}" + if run.exists(): + raise SystemExit(f"temp exists: {run}") + WORK = run + home = run / "home" + state = run / "state" + runtime = run / "run" + project = run / "project" + sessions = run / "sessions" + for path in (home, state, runtime, project, sessions, run / "data", run / "cache", home / ".config"): + path.mkdir(parents=True) + runtime.chmod(0o700) + (project / "README").write_text("desk pi proof\n") + base = { + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(run / "data"), + "XDG_STATE_HOME": str(state), + "XDG_CACHE_HOME": str(run / "cache"), + "XDG_RUNTIME_DIR": str(runtime), + } + records = state / "bercail/desk-sessions" + record = records / "t9.json" + env = child_env(base, HC_TERMINAL_ID="t9") + + tui = Tui(env, pi_args(sessions), record, "t9", "pi") + claim = runtime / "bercail/open-sessions" / f"{tui.proc.pid}.json" + + def recorded(): + data = read_json(record) + return isinstance(data.get("session"), str) and data["session"] != "" + + if not wait_for("session id recorded at startup", recorded, 30, tui.proc): + dump(tui) + return + data = read_json(record) + session = data["session"] + time.sleep(0.6) + tui.send(TOKEN + "\r") + + def filed(): + path = session_file(sessions, session) + return path is not None and TOKEN in path.read_text(errors="replace") + + if not wait_for("session file named with the recorded id", filed, 30, tui.proc): + dump(tui) + return + data = read_json(record) + if data.get("cwd") != str(project): + fail(f"cwd was not the dir Pi ran in: {data.get('cwd')}") + if ( + data.get("issue") != "bercail-1" + or data.get("args") != ["--verbose"] + or data.get("space") != "sp1" + or data.get("address") != "t9" + or data.get("harness") != "pi" + or data.get("pid") != tui.proc.pid + ): + fail(f"record metadata changed: {data}") + if (record.stat().st_mode & 0o777) != 0o640: + fail(f"record mode changed: {oct(record.stat().st_mode)}") + claimed = read_json(claim) + if claimed.get("pid") != tui.proc.pid or claimed.get("start_ticks") != start_ticks(tui.proc.pid): + fail(f"claim ticks do not match this process: {claimed}") + if claimed.get("sessions") != [session]: + fail(f"claim is not the one session: {claimed}") + if (claim.stat().st_mode & 0o777) != 0o600: + fail(f"claim mode is not 0600: {oct(claim.stat().st_mode)}") + saved = session_file(sessions, session) + if FAILS: + dump(tui) + return + print("PASS: startup recorded the session id, its directory, and the open-session claim") + + tui.send("/quit\r") + try: + tui.proc.wait(timeout=15) + except subprocess.TimeoutExpired: + fail(" /quit did not exit") + dump(tui) + return + if read_json(record).get("session") != session: + fail(f"exit cleared the session: {read_json(record)}") + return + print("PASS: quitting leaves the session to be resumed") + + tui = Tui(env, pi_args(sessions, ["--session-id", session]), record, "t9", "pi") + claim = runtime / "bercail/open-sessions" / f"{tui.proc.pid}.json" + + def resumed(): + data = read_json(record) + # The previous process left the same id behind. This run's wrapper + # rewrites pid first, so the new pid is what proves this process wrote. + return ( + data.get("pid") == tui.proc.pid + and data.get("session") == session + and data.get("cwd") == str(project) + and TOKEN in saved.read_text(errors="replace") + ) + + if not wait_for("resumed session id recorded", resumed, 30, tui.proc): + dump(tui) + return + claimed = read_json(claim) if claim.exists() else None + if not claimed or claimed.get("sessions") != [session] or claimed.get("pid") != tui.proc.pid: + fail(f"resumed claim is not the same session: {claimed}") + else: + print("PASS: --session-id records the same id and keeps the earlier message") + + time.sleep(0.6) + tui.send("/new\r") + + def renewed(): + current = read_json(record).get("session") + return isinstance(current, str) and current not in ("", session) + + if not wait_for("/new moves the recorded session", renewed, 15, tui.proc): + tui.send("\r") + if not wait_for("/new moves the recorded session", renewed, 15, tui.proc): + dump(tui) + return + new_id = read_json(record)["session"] + if read_json(claim).get("sessions") != [new_id]: + fail(f"/new did not move the claim: {read_json(claim)}") + else: + print(f"PASS: /new moved the recorded session to {new_id}") + tui.send("/quit\r") + try: + tui.proc.wait(timeout=15) + except subprocess.TimeoutExpired: + tui.close() + + foreign = records / "t-other.json" + other = Tui(child_env(base, HC_TERMINAL_ID="t-other"), pi_args(sessions), foreign, "t-other", "opencode") + other_claim = runtime / "bercail/open-sessions" / f"{other.proc.pid}.json" + expected = record_text("t-other", other.proc.pid, "opencode") + + def claimed_only(): + return other_claim.exists() and read_json(other_claim).get("pid") == other.proc.pid + + if wait_for("foreign desk still publishes a claim", claimed_only, 30, other.proc): + if foreign.read_text() != expected: + fail(f"an opencode record was overwritten: {foreign.read_text()!r} != {expected!r}") + else: + print("PASS: an opencode record is left byte-for-byte alone") + else: + dump(other) + other.close() + + +if __name__ == "__main__": + try: + main() + except Exception as err: + fail(f"{type(err).__name__}: {err}") + finally: + kill_all() + if FAILS: + if WORK: + print(f"left {WORK}", file=sys.stderr) + raise SystemExit(1) + if WORK: + import shutil + + shutil.rmtree(WORK, ignore_errors=True) diff --git a/tests/desk_session_pi_provider.ts b/tests/desk_session_pi_provider.ts new file mode 100644 index 0000000..7db1186 --- /dev/null +++ b/tests/desk_session_pi_provider.ts @@ -0,0 +1,47 @@ +// Loaded by the installed Pi binary: answers OK, no network calls. +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerProvider("fake", { + api: "desk-session-pi", + baseUrl: "http://invalid.invalid", + apiKey: "offline-test-key", + models: [ + { + id: "model", + name: "fake", + reasoning: false, + input: ["text"], + contextWindow: 8000, + maxTokens: 200, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + streamSimple(model) { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const message = { + role: "assistant", + content: [{ type: "text", text: "OK" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.push({ type: "done", reason: "stop", message }); + stream.end(); + }); + return stream; + }, + }); +} diff --git a/tests/desk_session_pi_test.mjs b/tests/desk_session_pi_test.mjs new file mode 100644 index 0000000..18c6d23 --- /dev/null +++ b/tests/desk_session_pi_test.mjs @@ -0,0 +1,185 @@ +// The desk Pi extension's record and claim rules, with no Pi installed. +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const root = new URL("..", import.meta.url) +const ext = await import(new URL("./config/pi/extensions/bercail-desk.ts", root).href) +const { applyClaim, applyDesk, claimPath, deskFields, deskNext, deskRecordPath, sessionIDs, startTicks, writeAtomic } = + ext + +let failed = 0 +function expect(cond, message) { + if (cond) return + failed += 1 + console.error(`FAIL: ${message}`) +} + +const stat = "12 (my app) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 20 0 1 0 0 424242 rest" +expect(startTicks(stat) === "424242", "field 22 is the start tick, after a comm with spaces") +expect(startTicks("12 (a) b) S " + "0 ".repeat(18) + "7\n") === "7", "the last parenthesis ends comm") +expect(startTicks("nope") === null, "a stat line without comm is ignored") +expect(startTicks(null) === null, "missing stat is ignored") +expect(startTicks("12 (pi) S 1") === null, "a short stat line has no start tick") + +const base = { + address: "t9", + session: "", + cwd: "/old", + harness: "pi", + issue: "bercail-1", + space: "sp1", + args: ["--verbose"], + pid: 42, +} +const gate = { address: "t9", pid: 42 } + +expect(deskNext({ ...base }, { ...gate, session: "ses_a" }).session === "ses_a", "a session id is stored at once") +expect(deskNext({ ...base }, { ...gate, session: "ses_a" }).cwd === "/old", "cwd waits until it is known") +expect( + deskNext({ ...base, session: "ses_a" }, { ...gate, session: "ses_a", cwd: "/w" }).cwd === "/w", + "cwd is stored once the session has a directory", +) +const kept = deskNext({ ...base, session: "ses_old" }, { ...gate, session: "" }) +expect(kept.session === "" && kept.cwd === "/old" && kept.issue === "bercail-1" && kept.args[0] === "--verbose", + "an empty session clears only the session") +expect(deskNext({ ...base, session: "ses_a" }, { ...gate, session: "ses_a" }) === null, "an unchanged record is not rewritten") +expect(deskNext({ ...base, pid: 7 }, { ...gate, session: "ses_a" }) === null, "a stale pid is not this process") +expect(deskNext({ ...base, pid: "42" }, { ...gate, session: "ses_a" }) === null, "a pid that is not the number is stale") +expect(deskNext({ ...base, address: "t8" }, { ...gate, session: "ses_a" }) === null, "another terminal's record is left alone") +expect(deskNext({ ...base, harness: "opencode" }, { ...gate, session: "ses_a" }) === null, "an opencode desk is not this Pi") +expect(deskNext({ ...base, harness: "claude" }, { ...gate, session: "ses_a" }) === null, "a nested claude is not the desk") +expect(deskNext(null, { ...gate, session: "ses_a" }) === null, "a missing record is not created") +expect(deskNext(["nope"], { ...gate, session: "ses_a" }) === null, "a record that is not an object is ignored") +expect(deskNext({ ...base }, { ...gate, session: 1 }) === null, "a session that is not a string is ignored") + +expect(deskFields(undefined, "nope", "/w").session === "" && deskFields(undefined, "nope", "/w").cwd === "/w", + "an ephemeral session stores nothing to resume") +expect(JSON.stringify(sessionIDs(undefined, "nope")) === "[]", "an ephemeral session claims nothing") +expect(deskFields("/tmp/ses.jsonl", "", "/w") === null, "a path without an id is not a selection") +expect(deskFields("/tmp/ses.jsonl", "ses_a", undefined).cwd === undefined, "no cwd until it is known") +expect(JSON.stringify(sessionIDs("/tmp/ses.jsonl", "ses_a")) === JSON.stringify(["ses_a"]), "one session is the claim") + +expect(deskRecordPath({ HC_TERMINAL_ID: "t9", HOME: "/h" }) === "/h/.local/state/bercail/desk-sessions/t9.json", + "the record lives under the state dir, by terminal") +expect(deskRecordPath({ HC_TERMINAL_ID: "t9", XDG_STATE_HOME: "/s" }) === "/s/bercail/desk-sessions/t9.json", + "XDG_STATE_HOME wins") +expect(deskRecordPath({}) === null, "no terminal, no record") +expect(deskRecordPath({ HC_TERMINAL_ID: "../x", HOME: "/h" }) === null, "a terminal id is not a path") +expect(claimPath({ XDG_RUNTIME_DIR: "/run/u" }, 42) === "/run/u/bercail/open-sessions/42.json", "the claim is named by pid") +expect(claimPath({}, 42) === null, "no runtime dir, no claim") +expect(claimPath({ XDG_RUNTIME_DIR: "/run/u" }, 42, "darwin") === null, "not linux, no claim") +expect(claimPath({ XDG_RUNTIME_DIR: "/run/u" }, "42") === null, "a pid that is not a number is not a claim") + +const dir = mkdtempSync(join("/tmp", "desk-pi-unit-")) +const saved = {} +for (const key of ["XDG_STATE_HOME", "XDG_RUNTIME_DIR", "HC_TERMINAL_ID", "HOME"]) saved[key] = process.env[key] +try { + const record = join(dir, "t9.json") + writeFileSync(record, `${JSON.stringify(base)}\n`) + chmodSync(record, 0o640) + expect(applyDesk(record, { ...gate, session: "ses_a" }) === true, "the record is updated") + expect(JSON.parse(readFileSync(record, "utf8")).issue === "bercail-1", "issue and the other fields stay") + expect((statSync(record).mode & 0o777) === 0o640, "the file mode stays") + const before = statSync(record, { bigint: true }).mtimeNs + expect(applyDesk(record, { ...gate, session: "ses_a" }) === false, "a second write of the same session does nothing") + expect(statSync(record, { bigint: true }).mtimeNs === before, "an unchanged record keeps its mtime") + const invalid = readFileSync(record, "utf8") + writeFileSync(record, "not json\n") + chmodSync(record, 0o640) + expect(applyDesk(record, { ...gate, session: "ses_b" }) === false, "invalid json is left alone") + expect(readFileSync(record, "utf8") === "not json\n", "invalid json is not replaced") + writeFileSync(record, "[]\n") + expect(applyDesk(record, { ...gate, session: "ses_b" }) === false, "an array record is left alone") + expect(readFileSync(record, "utf8") === "[]\n", "an array record is not replaced") + expect(applyDesk(join(dir, "missing.json"), { ...gate, session: "ses_a" }) === false, "a missing record is not created") + writeFileSync(record, invalid) + + const claim = join(dir, "42.json") + const doc = { pid: 42, start_ticks: "424242", sessions: ["ses_a"] } + expect(applyClaim(claim, doc) === true, "a claim is written") + expect((statSync(claim).mode & 0o777) === 0o600, "a claim is not world-readable") + const claimMtime = statSync(claim, { bigint: true }).mtimeNs + expect(applyClaim(claim, doc) === false, "the same claim is not rewritten") + expect(statSync(claim, { bigint: true }).mtimeNs === claimMtime, "an unchanged claim keeps its mtime") + expect(JSON.parse(readFileSync(claim, "utf8")).sessions[0] === "ses_a", "the claim lists the open session") + + const modeFile = join(dir, "mode.json") + writeAtomic(modeFile, "x\n", 0o600) + expect((statSync(modeFile).mode & 0o777) === 0o600, "a new file takes the mode it was given") + + const state = join(dir, "state") + const runtime = join(dir, "run") + mkdirSync(state, { recursive: true }) + mkdirSync(runtime, { mode: 0o700 }) + process.env.XDG_STATE_HOME = state + process.env.XDG_RUNTIME_DIR = runtime + process.env.HC_TERMINAL_ID = "t9" + process.env.HOME = join(dir, "home") + const live = join(state, "bercail/desk-sessions/t9.json") + mkdirSync(join(state, "bercail/desk-sessions"), { recursive: true }) + const owned = { ...base, pid: process.pid, harness: "pi", session: "" } + writeFileSync(live, `${JSON.stringify(owned)}\n`) + chmodSync(live, 0o640) + const handlers = {} + ext.default({ + on(name, fn) { + handlers[name] = fn + }, + }) + expect(typeof handlers.session_start === "function" && typeof handlers.session_info_changed === "function", + "session_start and session_info_changed both publish") + expect(handlers.session_shutdown === undefined, "shutdown does not publish") + const ctx = { + cwd: "/work", + sessionManager: { + getSessionFile: () => "/tmp/ses.jsonl", + getSessionId: () => "uuid-1", + }, + } + handlers.session_start({}, ctx) + const started = JSON.parse(readFileSync(live, "utf8")) + expect(started.session === "uuid-1" && started.cwd === "/work" && started.issue === "bercail-1", + "session_start fills session and cwd") + expect((statSync(live).mode & 0o777) === 0o640, "session_start keeps the record mode") + const claimFile = join(runtime, "bercail/open-sessions", `${process.pid}.json`) + expect(JSON.parse(readFileSync(claimFile, "utf8")).sessions[0] === "uuid-1", "session_start claims the session") + expect((statSync(claimFile).mode & 0o777) === 0o600, "the published claim is 0600") + ctx.sessionManager.getSessionId = () => "uuid-2" + handlers.session_info_changed({}, ctx) + expect(JSON.parse(readFileSync(live, "utf8")).session === "uuid-2", "session_info_changed publishes the new id") + expect(JSON.parse(readFileSync(claimFile, "utf8")).sessions[0] === "uuid-2", "session_info_changed updates the claim") + const same = statSync(live, { bigint: true }).mtimeNs + handlers.session_info_changed({}, ctx) + expect(statSync(live, { bigint: true }).mtimeNs === same, "the same session is not rewritten from the event") + ctx.sessionManager.getSessionFile = () => undefined + ctx.sessionManager.getSessionId = () => "nope" + handlers.session_start({}, ctx) + expect(JSON.parse(readFileSync(live, "utf8")).session === "", "an ephemeral session stores an empty id") + expect(JSON.parse(readFileSync(claimFile, "utf8")).sessions.length === 0, "an ephemeral session claims nothing") + let threw = false + try { + handlers.session_start({}, { sessionManager: { getSessionFile() { throw new Error("boom") } } }) + } catch { + threw = true + } + expect(!threw, "a session error does not escape") + expect(JSON.parse(readFileSync(live, "utf8")).session === "", "a session error does not clear a stored empty id twice") + writeFileSync(live, `${JSON.stringify({ ...owned, harness: "opencode", session: "keep" })}\n`) + const foreign = readFileSync(live, "utf8") + ctx.sessionManager.getSessionFile = () => "/tmp/ses.jsonl" + ctx.sessionManager.getSessionId = () => "uuid-3" + handlers.session_start({}, ctx) + expect(readFileSync(live, "utf8") === foreign, "the event leaves an opencode record byte-for-byte") +} finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + rmSync(dir, { recursive: true, force: true }) +} + +if (failed) { + console.error(`${failed} failed`) + process.exit(1) +} +console.log("PASS: session, cwd, ephemeral, and stale record gates") diff --git a/tests/desk_session_pi_test.sh b/tests/desk_session_pi_test.sh new file mode 100755 index 0000000..e434c8a --- /dev/null +++ b/tests/desk_session_pi_test.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# The desk's Pi extension: which session is running, so a reboot can resume it. +# The unit half runs with node alone. The live half needs the installed Pi. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +if ! command -v node >/dev/null 2>&1; then + printf 'SKIP: node not found\n' + exit 0 +fi + +node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types \ + "$ROOT/tests/desk_session_pi_test.mjs" + +# mise's shim reinstalls when HOME changes. Resolve the binary first. +pi="" +if command -v mise >/dev/null 2>&1; then + pi="$(mise which pi 2>/dev/null || true)" +fi +if [[ -z "$pi" || ! -x "$pi" || "$pi" == */mise/shims/* ]]; then + pi="$(command -v pi 2>/dev/null || true)" + [[ -n "$pi" && "$pi" != */mise/shims/* && -x "$pi" ]] || pi="" +fi +if [[ -z "$pi" ]]; then + printf 'SKIP: Pi not found; live proof not run\n' + exit 0 +fi +version="$("$pi" --version 2>/dev/null || true)" +if ! node -e 'const [a,b] = process.argv[1].split(".").map(Number); process.exit(a > 1 || (a === 1 && b >= 2) ? 0 : 1)' "$version"; then + printf 'SKIP: Pi %s is older than 1.2.0; live proof not run\n' "$version" + exit 0 +fi +command -v python3 >/dev/null 2>&1 || fail "python3 is required for the live proof" + +python3 "$ROOT/tests/desk_session_pi_live.py" "$ROOT" "$pi"