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"