Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions bin/bercail-desk-agent
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}'
Expand All @@ -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)"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")

Expand Down
156 changes: 156 additions & 0 deletions config/pi/extensions/bercail-desk.ts
Original file line number Diff line number Diff line change
@@ -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)
})
}
2 changes: 1 addition & 1 deletion docs/bstack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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/<address>.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/<address>.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.
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading