diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b051830..7723723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 26 - name: Run tests run: | failed=0 diff --git a/README.md b/README.md index a159fd6..069f623 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Omarchy, Ubuntu/Debian, macOS. Installer and CLI: `bercail`. ## What you get -- **The desk.** Each space's agent is pinned beside the tabs and keeps running when the window closes. +- **The desk.** Each space's agent is pinned beside the tabs and keeps running when the window closes. Supported sessions [come back after a reboot](docs/config.md#desks-after-a-reboot). - **Review on `done`.** tuicr opens the uncommitted work the agent left you. `Ctrl+Shift+R` opens it anytime. - **Parallel handoff.** One beads issue per job, run headless or in a sibling worktree with its own desk. - **Your keyboard, your mouse.** `Ctrl+Shift` keys like the rest of the Herdr app, plus a file tree you can click. diff --git a/bin/bercail b/bin/bercail index b8cb22b..1a1d463 100755 --- a/bin/bercail +++ b/bin/bercail @@ -78,6 +78,8 @@ load_libs() { source "$lib/harness.sh" # shellcheck source=/dev/null source "$lib/bstack.sh" + # shellcheck source=/dev/null + source "$lib/desk.sh" } cmd_doctor() { @@ -110,6 +112,7 @@ cmd_doctor() { doctor_omarchy_integration || failures=1 doctor_notify || failures=1 doctor_bstack || failures=1 + doctor_desk_restore || failures=1 return "$failures" } @@ -157,6 +160,7 @@ Commands: doctor Check dependencies and integration harness JSON: which of claude, codex, cursor-agent, opencode are on PATH bstack opencode with your Claude/GPT/Grok subscriptions: status | setup | on | off + desk Bring back the desk agents a reboot took: desk restore [--dry-run] notify Agent status webhooks: status | add NAME | repo add|remove NAME | test NAME | run review-pr Review someone else's PR by intent and decisions: review-pr | run | card | ui | open update Re-sync configs, Herdr layout and mod, helper, and skills @@ -193,6 +197,10 @@ main() { load_libs cmd_bstack "$@" ;; + desk) + load_libs + cmd_desk "$@" + ;; notify) load_libs cmd_notify "$@" diff --git a/bin/bercail-desk-agent b/bin/bercail-desk-agent index 03f9e64..fc933a8 100755 --- a/bin/bercail-desk-agent +++ b/bin/bercail-desk-agent @@ -43,6 +43,155 @@ if [[ ${#argv[@]} -eq 0 ]]; then fi fi +# An idle harness writes nothing. Heartbeat the record, using hc events to +# notice its exit without waiting a full minute. Session identity comes from +# the TUI plugin or Claude hook, not hc's turn-only status reports. +watch_desk() { + local pid="$1" record="$2" events + trap '' INT + exec /dev/null 2>&1 + while kill -0 "$pid"; do + exec 3< <(hc events --json --terminal "$HC_TERMINAL_ID") + events=$! + while kill -0 "$pid"; do + touch -c "$record" + if IFS= read -r -t 60 -u 3 _; then + continue + elif (($? <= 128)); then + break + fi + done + kill "$events" + exec 3<&- + kill -0 "$pid" && sleep 5 + done +} + +# 0 when FLAG takes the next argv word. An equals form already holds its value. +# Only listed flags do. A value is not a subcommand (`opencode --prompt run` +# stays a desk); a word after a boolean flag is (`opencode --print-logs run`). +flag_has_value() { + case "$1 $2" in + "opencode --server" | "opencode --completions" | "opencode --log-level" \ + | "codex -c" | "codex --config" | "codex --enable" | "codex --disable" \ + | "codex --remote" | "codex --remote-auth-token-env" | "codex -i" | "codex --image" \ + | "codex -m" | "codex --model" | "codex --local-provider" | "codex -p" | "codex --profile" \ + | "codex -s" | "codex --sandbox" | "codex -C" | "codex --cd" | "codex --add-dir" \ + | "codex -a" | "codex --ask-for-approval" \ + | "claude --add-dir" | "claude --agent" | "claude --agents" \ + | "claude --allowedTools" | "claude --allowed-tools" | "claude --append-system-prompt" \ + | "claude --autocompact" | "claude --betas" | "claude --cloud" | "claude -d" | "claude --debug" \ + | "claude --debug-file" | "claude --disallowedTools" | "claude --disallowed-tools" \ + | "claude --effort" | "claude --environment" | "claude --fallback-model" | "claude --file" \ + | "claude --from-pr" | "claude --input-format" | "claude --json-schema" \ + | "claude --max-budget-usd" | "claude --mcp-config" | "claude --model" | "claude -n" \ + | "claude --name" | "claude --output-format" | "claude --permission-mode" \ + | "claude --permission-prompts" | "claude --plugin-dir" | "claude --plugin-url" \ + | "claude --prompt-suggestions" | "claude --remote-control" \ + | "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") + return 0 ;; + esac + return 1 +} + +# Run before exec: the watcher and the harness share this pid's life. Only a +# labelled desk gets a record, and headless runs have nothing to resume. +# Recorded args are what a resume can replay beside an explicit session: not a +# prompt, not "continue the latest", and not the session flag restore adds. +remember_desk() { + local harness session="" issue="" space record tmp flags="[]" arg name + local dir="${XDG_STATE_HOME:-$HOME/.local/state}/bercail/desk-sessions" + local -a rest=() + local saw_pos=0 + [[ -n "${HC_TERMINAL_ID:-}" ]] && command -v hc >/dev/null && command -v jq >/dev/null || return 0 + harness="$(basename "${argv[0]}")" + set -- "${argv[@]:1}" + while (($#)); do + arg="$1" + shift + if [[ "$arg" == -- ]]; then + [[ "$harness" == claude ]] || rest+=("$arg" "$@") + break + fi + if [[ "$arg" != -* ]]; then + if [[ "$saw_pos" -eq 0 ]]; then + saw_pos=1 + case "$harness $arg" in + "opencode run" | "codex exec") return 0 ;; + esac + fi + # Claude's free positional is its initial prompt, not a resume option. + [[ "$harness" != claude ]] || continue + rest+=("$arg") + continue + fi + name="$arg" + [[ "$arg" == *=* ]] && name="${arg%%=*}" + case "$harness $name" in + "claude -p" | "claude --print" | "cursor-agent -p" | "cursor-agent --print") + return 0 ;; + "opencode --session" | "opencode -s") + if [[ "$arg" == *=* ]]; then + session="${arg#*=}" + else + session="${1:-}" + shift || true + fi + continue ;; + "claude --resume" | "claude -r" | "claude --session-id") + if [[ "$arg" == *=* ]]; then + session="${arg#*=}" + elif [[ -n "${1:-}" && "${1}" != -* ]]; then + session="$1" + shift + fi + continue ;; + "opencode --prompt") + [[ "$arg" == *=* ]] || shift || true + continue ;; + "opencode --continue" | "opencode -c" | "claude --continue" | "claude -c" | "claude --fork-session") + continue ;; + esac + if [[ "$arg" != *=* && -n "${1:-}" && "${1}" != -* ]] && flag_has_value "$harness" "$name"; then + rest+=("$arg" "$1") + shift + continue + fi + rest+=("$arg") + done + space="$(hc get "$HC_TERMINAL_ID" --json 2>/dev/null \ + | jq -r 'select(.labels["bercail.role"] == "agent") | .labels["bercail.space"] // empty')" || return 0 + [[ -n "$space" ]] || return 0 + [[ "$prompt" =~ beads\ issue\ ([A-Za-z0-9_-]+) ]] && issue="${BASH_REMATCH[1]}" + # Private only when we create it. An existing dir keeps its mode, and this + # does not change the process umask the harness inherits. Parents, if missing, + # keep that umask too. + if [[ ! -d "$dir" ]]; then + mkdir -p "$(dirname "$dir")" || return 0 + mkdir -m 0700 "$dir" 2>/dev/null || [[ -d "$dir" ]] || return 0 + fi + record="$dir/$HC_TERMINAL_ID.json" + # A resume has no prompt: the issue stays what it was in this terminal. + [[ -n "$issue" ]] || issue="$(jq -r '.issue // empty' "$record" 2>/dev/null)" || issue="" + [[ ${#rest[@]} -eq 0 ]] || flags="$(jq -cn --args '$ARGS.positional' -- "${rest[@]}")" + # 0600 in a subshell, so the harness keeps the umask it was started with. + tmp="$(umask 077 && mktemp "$dir/.${HC_TERMINAL_ID}.json.XXXXXX")" || return 0 + if ! jq -n --arg address "$HC_TERMINAL_ID" --arg session "$session" --arg cwd "$PWD" \ + --arg harness "$harness" --arg issue "$issue" --arg space "$space" \ + --argjson pid "$$" --argjson args "$flags" \ + '{address: $address, session: $session, cwd: $cwd, harness: $harness, + issue: $issue, space: $space, args: $args, pid: $pid}' >"$tmp" || ! mv "$tmp" "$record"; then + rm -f "$tmp" + return 0 + fi + (set +e; watch_desk "$$" "$record") & +} + +remember_desk || true + case "$(basename "${argv[0]}")" in codex) # hc runs codex without its app-server daemon. diff --git a/bin/bercail-desk-claude-hook b/bin/bercail-desk-claude-hook new file mode 100755 index 0000000..5caf62d --- /dev/null +++ b/bin/bercail-desk-claude-hook @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Claude Code SessionStart hook: writes the session id into this terminal's desk +# record (see bercail-desk-agent), so `bercail desk restore` can resume it. Runs +# on every session start, so it leaves anything but the desk's own Claude alone: +# no record means not a desk, and a `claude -p` the desk agent started is not +# the process the record names. It must never get in Claude's way. +umask 077 +record="${XDG_STATE_HOME:-$HOME/.local/state}/bercail/desk-sessions/${HC_TERMINAL_ID:-none}.json" +[[ -f "$record" ]] || exit 0 +pid="$(jq -r 'select(.harness == "claude") | .pid' "$record" 2>/dev/null)" +[[ -n "$pid" ]] || exit 0 +# A shell-form hook has one shell between us and Claude. A nested `claude -p` +# may also be its child, so ancestry alone is not enough. +if [[ "$PPID" != "$pid" ]]; then + case "$(ps -o comm= -p "$PPID" 2>/dev/null)" in + bash | sh | dash | zsh) + [[ "$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" == "$pid" ]] || exit 0 ;; + *) exit 0 ;; + esac +fi +session="$(jq -r '.session_id // empty' 2>/dev/null)" +[[ -n "$session" ]] || exit 0 +jq --arg id "$session" '.session = $id' "$record" >"$record.tmp.$$" 2>/dev/null \ + && mv "$record.tmp.$$" "$record" +exit 0 diff --git a/config/omarchy/bercail-desk-restore.hook b/config/omarchy/bercail-desk-restore.hook new file mode 100644 index 0000000..8d9884c --- /dev/null +++ b/config/omarchy/bercail-desk-restore.hook @@ -0,0 +1,10 @@ +#!/bin/bash +# Installed by bercail (bercail update keeps it current). Runs once the desktop +# is up: brings back the desk agents that were running at shutdown. It waits for +# Herdr to open, so it goes in the background and never holds up the login. +command -v bercail >/dev/null 2>&1 || exit 0 +log="${XDG_STATE_HOME:-$HOME/.local/state}/bercail/desk-restore.log" +mkdir -p "$(dirname "$log")" +nohup bercail desk restore --boot >"$log" 2>&1 & +disown +exit 0 diff --git a/config/opencode/plugins/bercail-desk/tui.ts b/config/opencode/plugins/bercail-desk/tui.ts new file mode 100644 index 0000000..1f45a00 --- /dev/null +++ b/config/opencode/plugins/bercail-desk/tui.ts @@ -0,0 +1,176 @@ +// The desk's selected session, written as soon as the TUI shows it. The wrapper +// owns the record and its heartbeat; this only fills session and cwd, and only +// on the record that names this process. Home clears the session: closing a +// tab without quitting would otherwise come back after a reboot. Every full +// TUI, desk or not, also claims the sessions it has open. hc's status is empty +// while a TUI is idle, so a scan of those events misses them. +import { Plugin } from "@opencode/plugin/tui" +import { createEffect } from "solid-js" +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 +} + +// The selected session plus every tab, including an idle one that is not selected. +export function sessionIDs(route, tabs) { + const ids = [] + const add = (id) => { + if (typeof id === "string" && id && !ids.includes(id)) ids.push(id) + } + if (route && route.type === "session") add(route.sessionID) + if (Array.isArray(tabs)) { + for (const tab of tabs) add(tab && tab.sessionID) + } + ids.sort() + return ids +} + +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 !== "opencode") 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 +} + +// What the selected route should store. Home forgets the session; anything else +// that is not a session is left alone. +export function deskFields(route, cwd) { + if (!route) return null + if (route.type === "home") return { session: "" } + if (route.type !== "session" || typeof route.sessionID !== "string" || !route.sessionID) return null + const fields = { session: route.sessionID } + if (typeof cwd === "string" && cwd) fields.cwd = cwd + return fields +} + +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 route = ctx.ui.router.current() + let tabs = [] + try { + const list = ctx.ui.tabs.list() + if (Array.isArray(list)) tabs = list + } catch {} + const selected = route && route.type === "session" ? route.sessionID : undefined + let cwd + if (selected) { + try { + cwd = ctx.data.session.get(selected)?.location?.directory + } catch {} + } + const claim = claimPath(process.env, process.pid, process.platform) + if (claim) { + let ticks = null + try { + ticks = startTicks(readFileSync("/proc/self/stat", "utf8")) + } catch {} + if (ticks) applyClaim(claim, { pid: process.pid, start_ticks: ticks, sessions: sessionIDs(route, tabs) }) + } + const record = deskRecordPath(process.env) + const fields = deskFields(route, cwd) + if (record && fields) { + applyDesk(record, { address: process.env.HC_TERMINAL_ID, pid: process.pid, ...fields }) + } + } catch {} +} + +export default Plugin.define({ + id: "bercail.desk", + setup(ctx) { + return ctx.ui.slot({ + append: "app", + render() { + createEffect(() => publish(ctx)) + return null + }, + }) + }, +}) diff --git a/config/systemd/bercail-desk-restore.service b/config/systemd/bercail-desk-restore.service new file mode 100644 index 0000000..5d0b805 --- /dev/null +++ b/config/systemd/bercail-desk-restore.service @@ -0,0 +1,14 @@ +# Installed by bercail (bercail update keeps it current). Brings back the desk +# agents that were running at shutdown, once Herdr opens. +[Unit] +Description=Bring back Bercail desk agents after a reboot + +[Service] +Type=exec +# mise shims: harness tools, and jq when it came from mise. A packaged install +# rewrites ExecStart to the release's bin/bercail; that binary is not copied here. +Environment=PATH=%h/.local/share/mise/shims:%h/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=%h/.local/bin/bercail desk restore --boot + +[Install] +WantedBy=default.target diff --git a/docs/cli.md b/docs/cli.md index 05b9ef9..9a174ae 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,6 +16,7 @@ The `bercail` CLI, the shell helpers, and the skills your agents call by name. ` | `bercail doctor` | Deps, Herdr layout, mod and keys, skills, webhook sinks and follower | | `bercail harness` | JSON: which of claude, codex, cursor-agent, opencode are on PATH (reports, does not choose) | | `bercail bstack setup` / `status` / `on` / `off` | [bstack](bstack.md): opencode on your own plans. No subcommand = `status` | +| `bercail desk restore` / `--dry-run` | Bring back the desk agents a reboot or a core restart took, each on its session in its own space. See [desks after a reboot](config.md#desks-after-a-reboot). `--dry-run` lists each desk and what would happen to it | | `bercail update` | Re-sync configs, Herdr layout and mods, helper, skills, and bstack; restart the notify follower. `--force` (`-f`) also replaces a changed layout and the worktrunk config, and writes the shell block despite conflicts | | `bercail review-pr ` / `run` / `card` / `ui` / `state` / `select` / `checkout` / `rereview` / `open` / `dir` | Review a whole PR stack by intent and decisions: one visible review desk session, cards and a stack summary beside its worktree. See [review](review.md#someone-elses-pr-bercail-review-pr) | | `bercail reconfigure` | Re-read `config.toml`, refresh skills | @@ -48,6 +49,7 @@ Call by name. Skills install to `~/.agents/skills`; bercail adds a link where th | `how` / `why` | How code works now; why it is that way, from git, PRs, and beads. Read-only | | `unslop` | Clean machine-writing tells out of prose and a diff | | `autopilot` | Drive an open PR to green: CI, review threads, conflicts. Pushes; never merges | +| `desk-restore` | Put a desk with no record back on its session by hand, matched by folder and content | autopilot's PR snapshot, `pr-state.sh`, is in [Ship with autopilot](bstack/06-autopilot.md#what-it-does). The review helpers: diff --git a/docs/config.md b/docs/config.md index a7f55f6..56bb602 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,8 +28,20 @@ After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and b - `~/.config/herdr-app/layouts/bercail.json`, `mods/bercail-desk/`, `mods/bercail-agents/` and `mods/bercail-review/` (the app rewrites the layout as you use it, so `bercail update` replaces it only when bercail ships a new one), and the `layout:bercail` section of `keys.json` (see [keys](keys.md)). - `~/.config/bercail/` (`config.toml`, `shell/`, `config-reader.sh`, `config.toml.example`, `notify.d/`), and the marker block in your `~/.bashrc` or `~/.zshrc` that sources the shell helpers. - worktrunk hooks (`~/.config/worktrunk/config.toml`, `herdr-layout.sh`). -- `~/.local/bin/{bercail,agentic-dev,bercail-desk-agent}` (a mise or packslip install keeps its own `bercail`), and `~/.local/share/bercail/`. -- `~/.agents/skills/{handoff,review,bstack,council,autopilot,how,why,unslop}`. +- `~/.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 files in `~/.config/opencode` (see [Make it yours](bstack/08-make-it-yours.md#your-own-files)). +- The local TUI plugin `~/.config/opencode/plugins/bercail-desk/tui.ts`, 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). + +## Desks after a reboot + +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. +- **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. +- **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. diff --git a/docs/development.md b/docs/development.md index 2de8ec7..0010a7b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ ## Hacking on bercail ```bash -shellcheck install.sh lib/*.sh bin/bercail config/shell/bercail.inc.sh config/worktrunk/herdr-layout.sh +shellcheck install.sh lib/*.sh bin/bercail bin/bercail-desk-agent bin/bercail-desk-claude-hook config/shell/bercail.inc.sh config/worktrunk/herdr-layout.sh herdr-app check bercail/mods/bercail-desk herdr-app check bercail/mods/bercail-agents herdr-app check bercail/mods/bercail-review @@ -16,6 +16,8 @@ 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. + `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`. ## dependencies @@ -30,7 +32,9 @@ With bstack on: [opencode](https://github.com/anomalyco/opencode) 2.2.3+ (mise, ## platforms -**Omarchy.** mise first, `omarchy pkg add`, optional `SUPER+ALT+RETURN` → open or focus the Herdr app, and a post-update hook (`~/.config/omarchy/hooks/post-update.d/bercail.hook`, when `omarchy-hook-install` exists): after `omarchy-update`, it fast-forwards the clone bercail was installed from and runs `bercail update` without prompting (each question takes its default, no). It acts only on a git clone, so curl and mise/packslip installs skip it (update those with `bercail update` or `mise up`). It skips with one line when offline, dirty, off master/main, or diverged. Herdr updates itself; `bercail doctor` says when a downloaded Herdr waits on a window restart. +**Omarchy.** mise first, `omarchy pkg add`, optional `SUPER+ALT+RETURN` → open or focus the Herdr app, and a post-update hook (`~/.config/omarchy/hooks/post-update.d/bercail.hook`, when `omarchy-hook-install` exists): after `omarchy-update`, it fast-forwards the clone bercail was installed from and runs `bercail update` without prompting (each question takes its default, no). It acts only on a git clone, so curl and mise/packslip installs skip it (update those with `bercail update` or `mise up`). It skips with one line when offline, dirty, off master/main, or diverged. Herdr updates itself; `bercail doctor` says when a downloaded Herdr waits on a window restart. A post-boot hook (`~/.config/omarchy/hooks/post-boot.d/bercail-desk-restore.hook`) brings back [the desks that were running](config.md#desks-after-a-reboot) once Herdr opens. + +**Linux with a systemd user manager** (not Omarchy). The `bercail-desk-restore.service` user unit does the same. **Linux with fcitx5** (Omarchy included). fcitx5 `Ctrl+Alt+H/J` hint keys cleared. diff --git a/docs/install.md b/docs/install.md index 87fb03d..10241ba 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`. 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) 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/docs/keys.md b/docs/keys.md index 55d2de3..a288a6b 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -12,7 +12,7 @@ Keyboard and mouse both work: `Ctrl+Shift` keys like the rest of the Herdr app, └─────────┴──────────────┴────────────────────────┴─────────────┘ ``` -The desk (bstack when it is on, else your `[agent] command`) sits under the tab bar, beside the current tab's panes. It is an hc terminal, so it keeps running when the window closes or the Shell's program ends. The sidebar's agents list (bercail's `bercail-agents` mod) has a row for each desk; click it to focus that desk. +The desk (bstack when it is on, else your `[agent] command`) sits under the tab bar, beside the current tab's panes. It is an hc terminal, so it keeps running when the window closes or the Shell's program ends. Supported sessions [come back after a reboot](config.md#desks-after-a-reboot). The sidebar's agents list (bercail's `bercail-agents` mod) has a row for each desk; click it to focus that desk. In the bercail layout (`~/.config/herdr-app/keys.json`, section `layout:bercail`): diff --git a/install.sh b/install.sh index 3a11ff3..1e03575 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ bootstrap_remote_libs() { local tmp lib tmp="$(mktemp -d)" mkdir -p "$tmp/lib" - for lib in common detect deps config skills shell-rc uninstall help omarchy notify doctor harness bstack; do + for lib in common detect deps config skills shell-rc uninstall help omarchy notify doctor harness bstack desk; do curl -fsSL "${base%/}/lib/${lib}.sh" -o "$tmp/lib/${lib}.sh" done printf '%s' "$tmp" @@ -62,6 +62,8 @@ source "$ROOT/lib/notify.sh" source "$ROOT/lib/harness.sh" # shellcheck source=lib/bstack.sh source "$ROOT/lib/bstack.sh" +# shellcheck source=lib/desk.sh +source "$ROOT/lib/desk.sh" parse_install_args() { while [[ $# -gt 0 ]]; do diff --git a/lib/common.sh b/lib/common.sh index edb9016..7f867d3 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -16,10 +16,11 @@ HERDR_APP_CONFIG_DIR="${HERDR_APP_CONFIG_DIR:-${HOME}/.config/herdr-app}" WORKTRUNK_CONFIG_DIR="${HOME}/.config/worktrunk" FCITX5_CONFIG_DIR="${HOME}/.config/fcitx5" OMARCHY_POST_UPDATE_HOOK="${HOME}/.config/omarchy/hooks/post-update.d/bercail.hook" +OMARCHY_POST_BOOT_HOOK="${HOME}/.config/omarchy/hooks/post-boot.d/bercail-desk-restore.hook" LOCAL_BIN="${HOME}/.local/bin" # Canonical skill install per Agent Skills (https://agentskills.io): ~/.agents/skills. AGENTS_SKILLS_DIR="${HOME}/.agents/skills" -AGENTIC_DEV_SKILL_IDS=(handoff review bstack council autopilot how why unslop) +AGENTIC_DEV_SKILL_IDS=(handoff review bstack council autopilot how why unslop desk-restore) # First skill kept as a convenience alias (tests, summary, uninstall prompt). AGENTIC_DEV_SKILL_ID="${AGENTIC_DEV_SKILL_IDS[0]}" AGENTIC_DEV_SKILL_DIR="${AGENTS_SKILLS_DIR}/${AGENTIC_DEV_SKILL_ID}" diff --git a/lib/config.sh b/lib/config.sh index f61cb07..8732ea1 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -371,7 +371,7 @@ deploy_lib() { deploy_tree "$src/lib" "${AGENTIC_DEV_SHARE_DIR}/lib" else local libfile - for libfile in common.sh detect.sh deps.sh config.sh skills.sh shell-rc.sh uninstall.sh help.sh omarchy.sh notify.sh doctor.sh harness.sh bstack.sh review.sh review-desk.sh stack.sh; do + for libfile in common.sh detect.sh deps.sh config.sh skills.sh shell-rc.sh uninstall.sh help.sh omarchy.sh notify.sh doctor.sh harness.sh bstack.sh desk.sh review.sh review-desk.sh stack.sh; do deploy_install_file "lib/$libfile" "${AGENTIC_DEV_SHARE_DIR}/lib/$libfile" done fi @@ -536,7 +536,29 @@ migrate_worktrunk_layout_echo() { } deploy_finalize_permissions() { + # Older update callers reload this library before finalizing, but their file + # list and remaining steps still belong to the old release. + 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 "lib/desk.sh" "${AGENTIC_DEV_SHARE_DIR}/lib/desk.sh" + if [[ -f "${AGENTIC_DEV_SHARE_DIR}/lib/desk.sh" ]]; then + # shellcheck source=/dev/null + source "${AGENTIC_DEV_SHARE_DIR}/lib/desk.sh" + fi + if declare -F deploy_desk_restore >/dev/null; then + OMARCHY_POST_BOOT_HOOK="${OMARCHY_POST_BOOT_HOOK:-$HOME/.config/omarchy/hooks/post-boot.d/bercail-desk-restore.hook}" + deploy_desk_restore + fi + if ! _skill_ids | grep -qx desk-restore; then + deploy_skill_tree desk-restore + local agent_skills_dir + if agent_skills_dir="$(skill_agent_extra_global_dirs "$(read_agent_command)")"; then + ensure_skill_symlink "$agent_skills_dir" desk-restore + fi + fi run chmod +x "$LOCAL_BIN/bercail" "$LOCAL_BIN/agentic-dev" "$LOCAL_BIN/bercail-desk-agent" \ + "$LOCAL_BIN/bercail-desk-claude-hook" \ "$WORKTRUNK_CONFIG_DIR/herdr-layout.sh" \ "$AGENTIC_DEV_SHELL_DIR/bercail.sh" \ "$AGENTIC_DEV_SHELL_DIR/bercail.zsh" \ @@ -604,7 +626,6 @@ deploy_configs() { if declare -F deploy_bstack >/dev/null; then deploy_bstack || true fi - if [[ ! -f "$WORKTRUNK_CONFIG_DIR/config.toml" ]] || [[ "$FORCE" -eq 1 ]]; then deploy_install_file "config/worktrunk/config.toml" "$WORKTRUNK_CONFIG_DIR/config.toml" else diff --git a/lib/desk.sh b/lib/desk.sh new file mode 100644 index 0000000..271a8ab --- /dev/null +++ b/lib/desk.sh @@ -0,0 +1,485 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Desks come back after a reboot. A lost hc terminal keeps its address and labels +# but gets a bare shell; bercail-desk-agent leaves a record per terminal saying +# which session ran there, and this resumes it in place. +# +# 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 +# {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. +DESK_RESTORE_WINDOW=600 +# How long a pass waits for hc to finish giving lost terminals their shells. +DESK_RESTORE_RECOVERY_WAIT=120 + +# Closes the lock's fd: an hc that starts the core must not leave it holding the lock. +_desk_hc() { "${HC_BIN:-hc}" "$@" 9>&-; } + +_desk_sessions_dir() { + printf '%s/bercail/desk-sessions' "${XDG_STATE_HOME:-$HOME/.local/state}" +} + +# When the machine last went down: the previous boot's last journal line. With +# no journal, this boot's start, then now. Both are later than the shutdown, so +# they err toward leaving a desk alone. +_desk_shutdown_time() { + local t + t="$(journalctl -b -1 -n 1 -o short-unix --no-pager -q 2>/dev/null | cut -d. -f1)" || true + if [[ "$t" =~ ^[0-9]+$ ]]; then + printf '%s' "$t" + return 0 + fi + t="$(awk '/^btime/ { print $2 }' /proc/stat 2>/dev/null)" || true + printf '%s' "${t:-$(date +%s)}" +} + +# The selection, from facts alone. stdin: JSON array of +# {address, record (null when none), mtime, terminal (hc get; null when gone), +# cwd_exists, in_ps (a process names this session), +# open_sessions (session ids a live claim names)} +# A fact with no record and no desk label is only there so its .records count. +# It is not acted on. Prints {address, action, why, record}, where action is: +# resume type the resume command +# wait hc is still recovering this terminal; ask again shortly +# gone the terminal ended, so the record is garbage +# skip leave it alone, why says why +desk_restore_plan() { + local shutdown="$1" + jq --argjson shutdown "$shutdown" --argjson window "$DESK_RESTORE_WINDOW" ' + # At its prompt: the terminal runs a shell and nothing has started from it. + def bare: + (.foreground.name // "" | IN("bash", "zsh", "fish", "sh", "dash", "ksh")) + and .foreground.pid == .pid and (.reported | not); + def recovering: .reason != null or .foreground == null; + # Running something, so its .records are a session that is open now. A bare + # or recovering terminal still carries the session from before the reboot; + # those keys are stale and must not block the resume. Idle counts when hc + # has kept the keys (it usually reports {}). + def hosting: + . != null and .state == "live" and (recovering | not) and (bare | not); + def actionable: + .record != null or (.terminal.labels["bercail.role"] == "agent"); + def decide($d; $open; $taken): + $d.terminal as $t | $d.record as $r + | if $t == null or $t.state != "live" then ["gone", "terminal ended"] + elif $t.labels["bercail.role"] != "agent" then ["skip", "not a desk"] + elif ($t | recovering) then ["wait", "hc is recovering it"] + elif ($t | bare | not) then ["skip", "already running"] + 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 $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"] + else ["resume", ""] end; + . as $all + | ([$all[] | select(.record.session and (.terminal | hosting)) | .record.session] + + [$all[] | select(.terminal | hosting) | .terminal.records // {} | keys[]] + + [$all[] | .open_sessions // [] | .[]]) as $open + | reduce ($all[] | select(actionable)) as $d ({taken: [], out: []}; + decide($d; $open; .taken) as [$action, $why] + | .out += [{address: $d.address, action: $action, why: $why, record: $d.record}] + | if $action == "resume" then .taken += [$d.record.session] else . end) + | .out' +} + +# 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. +desk_resume_command() { + local record="$1" agent harness session cwd flag arg cmd + agent="$LOCAL_BIN/bercail-desk-agent" + [[ -x "$agent" ]] || agent="bercail-desk-agent" + harness="$(jq -r .harness <<<"$record")" + session="$(jq -r .session <<<"$record")" + cwd="$(jq -r .cwd <<<"$record")" + flag=--session + [[ "$harness" == claude ]] && flag=--resume + 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 + # turn one argument into two. + while IFS= read -r -d '' arg; do + printf -v cmd '%s %q' "$cmd" "$arg" + done < <(jq -j '.args[]? | ., "\u0000"' <<<"$record") + printf '%s' "$cmd" +} + +# Field 22 of /proc/pid/stat: start time in clock ticks. comm is in parentheses +# and may contain spaces, so count from the last ')'. +_desk_start_ticks() { + local stat pid="$1" + [[ "$pid" =~ ^[0-9]+$ && -r "/proc/$pid/stat" ]] || return 1 + stat="$(<"/proc/$pid/stat")" || return 1 + awk '{ print $20 }' <<<"${stat##*)}" +} + +# Sessions a live TUI has claimed. Stale when the pid is gone or reused +# (start_ticks no longer matches). A TUI that started before the plugin has no +# claim: only a live claim, hc .records, or a process argument names a session. +# Treating every uninstrumented TUI as holding it would block restore on a +# machine that already has one open. +_desk_claim_sessions() { + local dir="${XDG_RUNTIME_DIR:-}/bercail/open-sessions" file pid ticks live + [[ -n "${XDG_RUNTIME_DIR:-}" && -d "$dir" ]] || { printf '[]'; return 0; } + for file in "$dir"/*.json; do + [[ -f "$file" ]] || continue + pid="$(jq -r '.pid // empty' "$file" 2>/dev/null)" || continue + ticks="$(jq -r '.start_ticks // empty' "$file" 2>/dev/null)" || continue + [[ -n "$pid" && -n "$ticks" ]] || continue + live="$(_desk_start_ticks "$pid" 2>/dev/null)" || continue + [[ "$live" == "$ticks" ]] || continue + jq -r '.sessions[]? | select(type == "string" and . != "")' "$file" 2>/dev/null || true + done | jq -R . | jq -s 'unique' +} + +# Every live terminal hc knows, plus every record: facts for desk_restore_plan. +# A terminal that is not a desk is not resumed or cleaned up; its .records still +# count, including an idle agent when hc has kept the keys. Claims are read on +# every pass. Fails when hc cannot answer, so an outage never reads as "gone". +_desk_gather() { + local dir list address terminal rc record mtime cwd_exists in_ps ps_args file session claims + local -A addresses=() + dir="$(_desk_sessions_dir)" + list="$(_desk_hc ls --json)" || return 1 + while IFS= read -r address; do + [[ -z "$address" ]] || addresses["$address"]=1 + done < <(jq -r '.terminals[]? | select(.state == "live") | .address' <<<"$list") + for file in "$dir"/*.json; do + [[ -f "$file" ]] || continue + address="$(basename "$file" .json)" + addresses["$address"]=1 + done + ps_args="$(ps -eo args= 2>/dev/null || true)" + claims="$(_desk_claim_sessions)" + for address in "${!addresses[@]}"; do + rc=0 + terminal="$(_desk_hc get "$address" --json 2>/dev/null)" || rc=$? + case "$rc" in + 0) terminal="$(jq -c '{state, reason, reported, pid, foreground, labels, records}' <<<"$terminal")" ;; + 3) terminal=null ;; + *) return 1 ;; + esac + record=null mtime=0 cwd_exists=false in_ps=false + file="$dir/$address.json" + if [[ -f "$file" ]] && jq -e . "$file" >/dev/null 2>&1; then + record="$(<"$file")" + mtime="$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file")" + [[ ! -d "$(jq -r '.cwd // ""' "$file")" ]] || cwd_exists=true + session="$(jq -r '.session // ""' "$file")" + [[ -z "$session" ]] || ! grep -qF -- "$session" <<<"$ps_args" || in_ps=true + fi + jq -cn --arg address "$address" --argjson record "$record" --argjson mtime "$mtime" \ + --argjson terminal "$terminal" --argjson cwd_exists "$cwd_exists" --argjson in_ps "$in_ps" \ + --argjson open_sessions "$claims" \ + '{address: $address, record: $record, mtime: $mtime, terminal: $terminal, + cwd_exists: $cwd_exists, in_ps: $in_ps, open_sessions: $open_sessions}' + done | jq -s 'sort_by(.address)' +} + +# Same plan, on a fresh hc get. The gather can take seconds; if the shell is +# busy or no longer this desk, do not type. A start in the gap after this check +# can still land a line; that race stays. Prints {action, why}. Fails when hc +# does not answer. +_desk_confirm_resume() { + local address="$1" shutdown="$2" facts="$3" terminal rc=0 fact + fact="$(jq -c --arg a "$address" '.[] | select(.address == $a)' <<<"$facts")" + [[ -n "$fact" ]] || { warn "hc did not answer: desks left alone"; return 1; } + terminal="$(_desk_hc get "$address" --json 2>/dev/null)" || rc=$? + case "$rc" in + 0) ;; + 3) terminal=null ;; + *) warn "hc did not answer: desks left alone"; return 1 ;; + esac + jq -c --argjson t "$terminal" '.terminal = $t' <<<"$fact" | jq -s . \ + | desk_restore_plan "$shutdown" | jq -c '.[0] | {action, why}' +} + +# One pass. Prints a line per desk. Sets DESK_RESUMED and DESK_WAITING. A terminal +# typed into is not typed into again: its shell may still be loading its rc files +# on the next pass, and a second line would land in the agent's input. +declare -gA DESK_TYPED=() +_desk_restore_pass() { + local dry="$1" facts plan shutdown row address action why record what confirm + DESK_RESUMED=0 DESK_WAITING=0 + facts="$(_desk_gather)" || { warn "hc did not answer: desks left alone"; return 1; } + shutdown="$(_desk_shutdown_time)" + plan="$(desk_restore_plan "$shutdown" <<<"$facts")" + while IFS= read -r row; do + address="$(jq -r .address <<<"$row")" + [[ -z "${DESK_TYPED[$address]:-}" ]] || continue + action="$(jq -r .action <<<"$row")" + why="$(jq -r .why <<<"$row")" + record="$(jq -c .record <<<"$row")" + case "$action" in + resume) + what="$(jq -r '[.issue, .session, .cwd] | map(select(. != "")) | join(" ")' <<<"$record")" + if [[ "$dry" -eq 1 ]]; then + printf '%-6s would resume %s\n' "$address" "$what" + DESK_RESUMED=$((DESK_RESUMED + 1)) + else + confirm="$(_desk_confirm_resume "$address" "$shutdown" "$facts")" || return 1 + action="$(jq -r .action <<<"$confirm")" + if [[ "$action" != resume ]]; then + why="$(jq -r .why <<<"$confirm")" + if [[ "$action" == wait ]]; then + DESK_WAITING=$((DESK_WAITING + 1)) + printf '%-6s waiting: %s\n' "$address" "$why" + else + printf '%-6s skip: %s\n' "$address" "$why" + fi + elif _desk_hc send "$address" "$(desk_resume_command "$record")" >/dev/null; then + DESK_TYPED["$address"]=1 + printf '%-6s resumed %s\n' "$address" "$what" + DESK_RESUMED=$((DESK_RESUMED + 1)) + else + printf '%-6s could not type into it\n' "$address" + return 1 + fi + fi + ;; + wait) + DESK_WAITING=$((DESK_WAITING + 1)) + printf '%-6s waiting: %s\n' "$address" "$why" + ;; + gone) + [[ "$dry" -eq 1 ]] || rm -f "$(_desk_sessions_dir)/$address.json" + ;; + *) printf '%-6s skip: %s\n' "$address" "$why" ;; + esac + done < <(jq -c '.[]' <<<"$plan") +} + +# fd 9, closed in the hc child by _desk_hc so a core hc starts does not keep it. +# Non-blocking: the same process already holding it (boot calling restore) must +# not flock again and deadlock. +_desk_lock_acquire() { + [[ -z "${DESK_LOCK_HELD:-}" ]] || return 0 + command -v flock >/dev/null 2>&1 || return 0 + exec 9>"${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/bercail-desk-restore.lock" + flock -n 9 || { exec 9>&-; warn "another desk restore is running"; return 1; } + DESK_LOCK_HELD=1 +} + +_desk_lock_release() { + [[ -n "${DESK_LOCK_HELD:-}" ]] || return 0 + exec 9>&- + unset DESK_LOCK_HELD +} + +# Pass until hc has recovered every terminal, or the wait runs out. Still waiting +# at the deadline, or a send that did not land, is a failure. One at a time: the +# boot run and a manual run must not both type into a shell. +_desk_restore() { + local owned=0 rc=0 deadline total=0 + if [[ -z "${DESK_LOCK_HELD:-}" ]]; then + _desk_lock_acquire || return 1 + owned=1 + fi + deadline=$((SECONDS + DESK_RESTORE_RECOVERY_WAIT)) + while :; do + _desk_restore_pass 0 || rc=1 + if [[ "$rc" -ne 0 ]]; then + break + fi + total=$((total + DESK_RESUMED)) + if ((DESK_WAITING == 0)); then + break + fi + if ((SECONDS >= deadline)); then + warn "hc is still recovering desks" + rc=1 + break + fi + sleep "${DESK_RESTORE_POLL:-5}" + done + if [[ "$rc" -eq 0 ]]; then + DESK_RESUMED="$total" + fi + if [[ "$owned" -eq 1 ]]; then + _desk_lock_release + fi + return "$rc" +} + +# Once per boot, from the login hook: $XDG_RUNTIME_DIR is empty at each boot, so +# the marker means "done since this boot". The lock is taken before the marker +# check, and the marker is written only after a completed pass. A failure leaves +# it unset so a later attempt can retry. Waits for the core rather than start it +# (any hc command would): that happens when the person opens Herdr. +_desk_restore_boot() { + local rc=0 + [[ -n "${XDG_RUNTIME_DIR:-}" ]] || die "bercail desk restore --boot needs XDG_RUNTIME_DIR" + _desk_lock_acquire || return 1 + _desk_restore_boot_locked || rc=$? + _desk_lock_release + return "$rc" +} + +_desk_restore_boot_locked() { + local marker deadline desks + marker="$XDG_RUNTIME_DIR/bercail-desk-restore.done" + [[ ! -e "$marker" ]] || return 0 + until [[ -S "$XDG_RUNTIME_DIR/herdr-core/${HC_SESSION:-default}.sock" ]]; do + sleep "${DESK_RESTORE_POLL:-5}" + done + deadline=$((SECONDS + DESK_RESTORE_RECOVERY_WAIT)) + while :; do + if _desk_restore; then + touch "$marker" + if ((DESK_RESUMED > 0)) && command -v notify-send >/dev/null 2>&1; then + desks="desks" + ((DESK_RESUMED > 1)) || desks="desk" + notify-send "Bercail" "$DESK_RESUMED $desks came back" || true + fi + return 0 + fi + ((SECONDS < deadline)) || return 1 + sleep "${DESK_RESTORE_POLL:-5}" + done +} + +cmd_desk() { + local sub="${1:-}" mode="" + shift || true + [[ "$sub" == restore ]] || die "usage: bercail desk restore [--dry-run]" + while (($#)); do + case "$1" in + --dry-run | --boot) mode="$1" ;; + *) die "unknown option: $1" ;; + esac + shift + done + case "$mode" in + --boot) _desk_restore_boot ;; + --dry-run) + _desk_restore_pass 1 || return 1 + log "$DESK_RESUMED would come back" + ;; + *) + _desk_restore || return 1 + log "$DESK_RESUMED came back" + ;; + esac +} + +# --- install --- + +DESK_RESTORE_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/bercail-desk-restore.service" + +# A systemd user manager for this HOME: the one a unit under it would reach. +_desk_user_systemd() { + [[ "$(detect_os)" == linux ]] || return 1 + [[ "$(systemctl --user show-environment 2>/dev/null | sed -n 's/^HOME=//p')" == "$HOME" ]] +} + +_desk_omarchy_hook() { + is_omarchy && command -v omarchy-hook-install >/dev/null 2>&1 +} + +# mise/packslip own the bercail command and do not copy it to ~/.local/bin. +# The unit must run the release's bin/bercail. PATH in the unit keeps mise shims. +_desk_point_restore_unit() { + local cli tmp + [[ "${BERCAIL_PACKAGED:-0}" == 1 ]] || return 0 + cli="$(install_src_dir)/bin/bercail" + [[ -x "$cli" ]] || { warn "packaged bercail not found at $cli"; return 1; } + [[ "$DRY_RUN" -eq 1 || ! -f "$DESK_RESTORE_UNIT" ]] && return 0 + tmp="$(mktemp)" + awk -v cli="$cli" ' + /^ExecStart=/ { print "ExecStart=\"" cli "\" desk restore --boot"; next } + { print } + ' "$DESK_RESTORE_UNIT" >"$tmp" && mv "$tmp" "$DESK_RESTORE_UNIT" +} + +# One way in per machine: Omarchy's post-boot hook, else a systemd user unit. +deploy_desk_restore_boot() { + local staged="${AGENTIC_DEV_SHARE_DIR}/omarchy/bercail-desk-restore.hook" + if _desk_omarchy_hook; then + deploy_install_file "config/omarchy/bercail-desk-restore.hook" "$staged" + if [[ -f "$OMARCHY_POST_BOOT_HOOK" ]] && cmp -s "$staged" "$OMARCHY_POST_BOOT_HOOK"; then + info "unchanged: $OMARCHY_POST_BOOT_HOOK" + else + run omarchy-hook-install post-boot "$staged" + fi + elif _desk_user_systemd; then + deploy_install_file "config/systemd/bercail-desk-restore.service" "$DESK_RESTORE_UNIT" + _desk_point_restore_unit || return 1 + run systemctl --user daemon-reload + run systemctl --user enable bercail-desk-restore.service + fi +} + +# The SessionStart hook that tells a Claude desk's record its session. Adds one +# entry to the person's settings and touches nothing else. +deploy_desk_claude_hook() { + local settings="$HOME/.claude/settings.json" hook="$LOCAL_BIN/bercail-desk-claude-hook" tmp + [[ -d "$HOME/.claude" ]] || return 0 + [[ -f "$settings" ]] || [[ "$DRY_RUN" -eq 1 ]] || printf '{}\n' >"$settings" + if [[ "$DRY_RUN" -eq 1 ]]; then + info "[dry-run] would add the desk SessionStart hook to $settings" + return 0 + fi + jq -e . "$settings" >/dev/null 2>&1 || { + warn "$settings is not valid JSON; Claude desks will not come back after a reboot" + return 0 + } + if jq -e --arg c "$hook" '[.hooks.SessionStart[]?.hooks[]?.command] | index($c)' "$settings" >/dev/null; then + info "unchanged: desk SessionStart hook in $settings" + return 0 + fi + tmp="$(mktemp)" + # args: [] is the exec form: a HOME with spaces is not passed through a shell. + jq --arg c "$hook" '.hooks.SessionStart = ((.hooks.SessionStart // []) + [{hooks: [{type: "command", command: $c, args: []}]}])' \ + "$settings" >"$tmp" && cat "$tmp" >"$settings" + rm -f "$tmp" + info "added the desk SessionStart hook to $settings" +} + +deploy_desk_restore() { + deploy_desk_restore_boot || warn "could not set up the desk restore at boot" + deploy_desk_claude_hook || warn "could not add the desk SessionStart hook" +} + +remove_desk_restore() { + local settings="$HOME/.claude/settings.json" tmp + if [[ -e "$OMARCHY_POST_BOOT_HOOK" ]]; then + info "remove: $OMARCHY_POST_BOOT_HOOK" + run rm -f "$OMARCHY_POST_BOOT_HOOK" + fi + if [[ -e "$DESK_RESTORE_UNIT" ]]; then + info "remove: $DESK_RESTORE_UNIT" + run systemctl --user disable bercail-desk-restore.service || true + run rm -f "$DESK_RESTORE_UNIT" + fi + if [[ -f "$settings" ]] && jq -e --arg c "$LOCAL_BIN/bercail-desk-claude-hook" \ + '[.hooks.SessionStart[]?.hooks[]?.command] | index($c)' "$settings" >/dev/null 2>&1; then + info "remove: desk SessionStart hook from $settings" + [[ "$DRY_RUN" -eq 1 ]] && return 0 + tmp="$(mktemp)" + jq --arg c "$LOCAL_BIN/bercail-desk-claude-hook" ' + .hooks.SessionStart |= map(.hooks |= map(select(.command != $c)) | select(.hooks | length > 0)) + | if .hooks.SessionStart == [] then del(.hooks.SessionStart) else . end + | if .hooks == {} then del(.hooks) else . end' "$settings" >"$tmp" && cat "$tmp" >"$settings" + rm -f "$tmp" + fi +} + +doctor_desk_restore() { + local way="" + if _desk_omarchy_hook; then + way="$OMARCHY_POST_BOOT_HOOK" + elif _desk_user_systemd; then + way="$DESK_RESTORE_UNIT" + fi + [[ -n "$way" ]] || return 0 + if [[ -f "$way" ]]; then + log " ok desks come back after a reboot ($way)" + else + log " missing $way (run bercail update)" + return 1 + fi +} diff --git a/lib/help.sh b/lib/help.sh index 6ae88de..fedd231 100644 --- a/lib/help.sh +++ b/lib/help.sh @@ -25,6 +25,7 @@ Post-install CLI (bercail): doctor Check dependencies and integration harness JSON: claude, codex, cursor-agent, opencode on PATH (missing is not fatal) bstack opencode with your own subscriptions: status | setup | on | off + desk Bring back the desk agents a reboot took: desk restore [--dry-run] update Re-sync configs, Herdr layout and mod, helper, and skills reconfigure Refresh skills from config.toml (not a full redeploy) dry-run Show planned actions without changes @@ -95,10 +96,12 @@ Config: ~/.agents/skills/review/ tuicr review skill (wait / optional GH publish) ~/.agents/skills/bstack/, council/ bstack playbooks, cross-provider review ~/.agents/skills/{how,why,unslop,autopilot}/ explain, history, cleanup, PR to green + ~/.agents/skills/desk-restore/ put a desk with no record back by hand + ~/.local/state/bercail/desk-sessions/ which session runs in which desk ~/.config/opencode/opencode.jsonc bstack's opencode config (agents/, commands/ beside it) ~/.config/fcitx5/conf/keyboard.conf Linux fcitx5 hint trigger override -Agent skills (\`handoff\`, \`review\`, \`bstack\`, \`council\`, \`how\`, \`why\`, \`unslop\`, \`autopilot\`): +Agent skills (\`handoff\`, \`review\`, \`bstack\`, \`council\`, \`how\`, \`why\`, \`unslop\`, \`autopilot\`, \`desk-restore\`): Installed to ~/.agents/skills/ (https://agentskills.io). cursor-agent reads that dir. Extra symlink only if you set another [agent] command. Source: skills/handoff/, skills/review/. @@ -125,7 +128,7 @@ show_summary() { log "Agent command: $(read_agent_command 2>/dev/null || echo cursor-agent) (handoff stages: start=claude, codex=Codex CLI, cursor=cursor-agent, bstack=opencode, autopilot=opencode bstack-autopilot; equal, no default)" log "Review command: $(read_layout_review 2>/dev/null || echo tuicr)" log "Config: ${AGENTIC_DEV_USER_CONFIG}" - log "Skills: ${AGENTS_SKILLS_DIR}/{handoff,review,bstack,council,autopilot,how,why,unslop}" + log "Skills: ${AGENTS_SKILLS_DIR}/{handoff,review,bstack,council,autopilot,how,why,unslop,desk-restore}" log "" log "Try: dev" log "Help: bercail help" diff --git a/lib/uninstall.sh b/lib/uninstall.sh index aad2ec0..52e05e5 100644 --- a/lib/uninstall.sh +++ b/lib/uninstall.sh @@ -14,7 +14,11 @@ uninstall_agentic_dev() { info "remove: $LOCAL_BIN/bercail" run rm -f "$LOCAL_BIN/bercail" fi - for bin in agentic-dev bercail-desk-agent; do + if declare -F remove_desk_restore >/dev/null; then + remove_desk_restore + fi + run rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/bercail-desk/tui.ts" + for bin in agentic-dev bercail-desk-agent bercail-desk-claude-hook; do if [[ -e "$LOCAL_BIN/$bin" ]]; then info "remove: $LOCAL_BIN/$bin" run rm -f "$LOCAL_BIN/$bin" @@ -70,7 +74,7 @@ uninstall_agentic_dev() { remove_bstack fi - if confirm "Remove managed skills (~/.agents/skills/handoff, review, bstack, council, autopilot, how, why, unslop)?"; then + if confirm "Remove managed skills (~/.agents/skills/handoff, review, bstack, council, autopilot, how, why, unslop, desk-restore)?"; then remove_managed_skills fi diff --git a/skills/desk-restore/MANIFEST b/skills/desk-restore/MANIFEST new file mode 100644 index 0000000..55701c7 --- /dev/null +++ b/skills/desk-restore/MANIFEST @@ -0,0 +1,2 @@ +SKILL.md +MANIFEST diff --git a/skills/desk-restore/SKILL.md b/skills/desk-restore/SKILL.md new file mode 100644 index 0000000..95ed232 --- /dev/null +++ b/skills/desk-restore/SKILL.md @@ -0,0 +1,102 @@ +--- +name: desk-restore +description: >- + Puts a desk agent back on its session by hand when `bercail desk restore` + could not: a desk with no record, a session started before records existed, or + a Codex or cursor-agent desk. Finds the sessions written right before the + shutdown and matches them by folder and content. Use when a desk came back + after a reboot as a bare shell, or when asked which session belonged to a desk. +--- + +# desk-restore + +`bercail desk restore` handles desks with a usable record. This skill is for +the rest. The conversations are not lost; what is lost is which one belonged in +which terminal. + +## 1. List the desks to fix + +```bash +bercail desk restore --dry-run +``` + +A line with `would resume` needs no help: run `bercail desk restore`. Work only +on lines saying `no record`, `no session yet` or `cannot resume`. A line saying +`closed before the shutdown` was closed on purpose: leave it unless asked. + +For each one, get its folder and space: + +```bash +hc get
--json | jq '{cwd, labels}' +``` + +## 2. Find when the machine went down + +```bash +journalctl -b -1 -n 1 -o short-unix --no-pager -q | cut -d. -f1 +``` + +If a core restart took the desk rather than a reboot, use the terminal's last +activity instead (`status_ms`, or when you know it happened). + +## 3. Find the candidate sessions + +Sessions in that folder, newest first. Those updated shortly before the shutdown +were most likely running, but an idle agent writes nothing, so look further back +when nothing fits. + +```bash +# opencode: run in the pane's folder to query its project +(cd "$FOLDER" && opencode session list --format json --max-count 1000) \ + | jq --arg d "$FOLDER" --argjson end "$SHUTDOWN" \ + '[.[] | select(.directory == $d and .updated >= (($end - 600) * 1000) + and .updated <= ($end * 1000))] | sort_by(.updated) | reverse' +# 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' +# Codex +rg -l --fixed-strings -- "$(jq -cn --arg cwd "$FOLDER" '{cwd:$cwd}' | sed 's/^{//;s/}$//')" \ + ~/.codex/sessions --glob 'rollout-*.jsonl' +``` + +Set `SHUTDOWN` to the seconds from step 2. For transcripts, check their mtimes +against the same ten-minute window. Treat a broader search as investigation, +not permission to resume an old session. + +## 4. Match by content + +Even one candidate needs a content check. With several, the folder does not +decide it. Evidence, strongest first: + +- `~/.local/state/agentic-dev/handoffs.jsonl`: the line whose `desk` is this + address names the `issue`; the right session talks about that issue. +- The session title and its first messages against the branch and the beads issue. +- The last messages against the state of the worktree. + +If two candidates still fit equally, ask the person. A desk on the wrong session +looks fine and does the wrong work. + +## 5. Put it back + +Check the terminal is still live, has the expected `bercail.role=agent` and +`bercail.space` labels, and sits at a bare shell: `foreground.name` is a shell, +`foreground.pid == pid`, `reason` is null, and `reported` is false. Check all +live terminals' `records` and process arguments for the session ID. If it is +already open, leave this pane alone. Recheck immediately before sending. +It keeps its address, so it +stays the same desk in the same space. `exec`, so quitting the agent ends the +desk as usual. Do not resend the issue prompt: the session has it. + +```bash +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=(codex resume "$SESSION") +# argv=(cursor-agent --resume "$SESSION") +printf -v command 'cd %q && exec %q' "$FOLDER" "$AGENT" +printf -v args ' %q' "${argv[@]}" +hc send "$ADDRESS" "$command$args" +``` + +Say which desks you put back and on what evidence, and which you left alone. diff --git a/skills/handoff/resources/herdr-control.md b/skills/handoff/resources/herdr-control.md index 0c1e4ef..bfc2b13 100644 --- a/skills/handoff/resources/herdr-control.md +++ b/skills/handoff/resources/herdr-control.md @@ -46,6 +46,9 @@ hc keys enter herdr-app do space.select --id ``` +The one exception is `bercail desk restore` (and its `desk-restore` skill). It types a +resume command into a desk's bare recovery shell, after checking nothing runs there. + ## Helpers in the same checkout Not a substitute for a handoff: diff --git a/tests/desk_agent_test.sh b/tests/desk_agent_test.sh index bd135d4..a28c9d2 100755 --- a/tests/desk_agent_test.sh +++ b/tests/desk_agent_test.sh @@ -22,7 +22,7 @@ AGENT done export PATH="$TMP_DIR/bin:$PATH" XDG_CONFIG_HOME="$TMP_DIR/xdg" # BASH_ENV can put the real agents back in front of the fakes. -unset OPENCODE_CLI_CONFIG_CONTENT BASH_ENV +unset OPENCODE_CLI_CONFIG_CONTENT BASH_ENV HC_TERMINAL_ID CONFIG="$XDG_CONFIG_HOME/bercail/config.toml" desk() { "$ROOT/bin/bercail-desk-agent" "$@"; } expect() { [[ "$1" == "$2" ]] || fail "$3: got '$1', want '$2'"; } @@ -50,3 +50,237 @@ expect "$(desk opencode)" "opencode cli=none" "plain opencode with bstack off ke rm "$CONFIG" expect "$(desk)" "cursor-agent cli=none" "no config" printf 'PASS: bstack off runs [agent] command (cursor-agent without a config)\n' + +# --- the desk's record, for bercail desk restore --- + +export XDG_STATE_HOME="$TMP_DIR/state" +RECORDS="$XDG_STATE_HOME/bercail/desk-sessions" +printf '[bstack]\nenabled = true\n' >"$CONFIG" +EVENTS="$TMP_DIR/events" +: >"$EVENTS" +# Fake hc: a labelled desk unless told otherwise, and events from a file. +cat >"$TMP_DIR/bin/hc" <"$TMP_DIR/terminal.json" +record() { jq -c "$1" "$RECORDS/t9.json"; } +# Waits for the watcher to do its part; it runs beside the agent. +wait_for() { + local i + for i in $(seq 1 50); do + if eval "$1"; then return 0; fi + sleep 0.1 + done + fail "timed out: $1" +} + +export HC_TERMINAL_ID=t9 +got="$(desk --prompt 'Your job is beads issue bercail-0uo. Run it.' opencode --verbose)" +[[ "$got" == "opencode [--verbose] [--prompt] [Your job is beads issue bercail-0uo. Run it.] cli="* ]] \ + || fail "the argv a desk runs does not change: $got" +expect "$(record '[.address, .session, .harness, .issue, .space, .args]')" \ + '["t9","","opencode","bercail-0uo","sp1",["--verbose"]]' "a record, with the prompt out of its flags" +expect "$(record .cwd | jq -r .)" "$PWD" "the record has the folder" +printf 'PASS: a desk writes its record and its command stays the same\n' + +desk opencode --session ses_given --verbose >/dev/null +expect "$(record '[.session, .issue, .args]')" '["ses_given","bercail-0uo",["--verbose"]]' \ + "a resume names its session and keeps the terminal's issue" +desk claude --resume abc >/dev/null +expect "$(record '[.session, .harness]')" '["abc","claude"]' "claude's resume flag" +printf 'PASS: a resume keeps session, flags and issue\n' + +# Resume replays args beside an explicit session, so a prompt or "continue the +# latest" must not be in them. The command that ran is unchanged. +got="$(desk opencode --verbose --prompt 'Do not replay')" +[[ "$got" == "opencode [--verbose] [--prompt] [Do not replay] cli="* ]] \ + || fail "native --prompt still runs: $got" +expect "$(record .args)" '["--verbose"]' "native opencode --prompt is not replayed" +got="$(desk opencode --prompt='Do not replay' --verbose)" +[[ "$got" == "opencode [--prompt=Do not replay] [--verbose] cli="* ]] \ + || fail "native --prompt= still runs: $got" +expect "$(record .args)" '["--verbose"]' "native opencode --prompt= is not replayed" +got="$(desk opencode --continue --verbose)" +[[ "$got" == "opencode [--continue] [--verbose] cli="* ]] || fail "--continue still runs: $got" +expect "$(record .args)" '["--verbose"]' "opencode --continue is not replayed" +got="$(desk opencode -c --session ses_keep --verbose)" +[[ "$got" == "opencode [-c] [--session] [ses_keep] [--verbose] cli="* ]] || fail "-c still runs: $got" +expect "$(record '[.session, .args]')" '["ses_keep",["--verbose"]]' \ + "-c is dropped and the session flag is not recorded twice" +desk claude -c --verbose >/dev/null +expect "$(record .args)" '["--verbose"]' "claude -c is not replayed beside --resume" +printf 'PASS: a resume does not replay a prompt or continue the latest\n' + +desk opencode --session=ses_eq --verbose >/dev/null +expect "$(record '[.session, .args]')" '["ses_eq",["--verbose"]]' \ + "opencode --session= is the session, not a second flag" +desk opencode -s ses_short --verbose >/dev/null +expect "$(record '[.session, .args]')" '["ses_short",["--verbose"]]' "opencode -s names the session once" +desk claude --resume=abc-eq --verbose >/dev/null +expect "$(record '[.session, .harness, .args]')" '["abc-eq","claude",["--verbose"]]' \ + "claude --resume= is the session, not a second flag" +desk claude --session-id=abc-fixed --fork-session --verbose >/dev/null +expect "$(record '[.session, .args]')" '["abc-fixed",["--verbose"]]' \ + "Claude resumes the recorded session without assigning/forking it again" +printf 'PASS: an equals session flag is recorded once\n' + +got="$(desk --prompt 'Your job is beads issue bercail-0uo. Run it.' claude --verbose)" +[[ "$got" == "claude [--verbose] [Your job is beads issue bercail-0uo. Run it.] cli=none" ]] \ + || fail "claude's wrapper prompt still runs: $got" +expect "$(record '[.issue, .args]')" '["bercail-0uo",["--verbose"]]' \ + "claude's wrapper prompt is not a recorded positional" +got="$(desk --prompt 'job text' claude --verbose 'job text')" +[[ "$got" == "claude [--verbose] [job text] [job text] cli=none" ]] \ + || fail "claude argv unchanged when the prompt is also positional: $got" +expect "$(record .args)" '["--verbose"]' "that positional prompt is not replayed" +got="$(desk --prompt 'job text' claude --verbose other)" +[[ "$got" == "claude [--verbose] [other] [job text] cli=none" ]] \ + || fail "a different claude positional still runs: $got" +expect "$(record .args)" '["--verbose"]' "native Claude prompts are not replayed either" +desk claude --verbose 'keep me' >/dev/null +expect "$(record .args)" '["--verbose"]' "a native Claude prompt is omitted without a wrapper prompt" +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' + +settings=$'{\n "permissions": {}\n}' +desk claude --settings "$settings" >/dev/null +expect "$(record .args)" "$(jq -cn --arg settings "$settings" '["--settings", $settings]')" \ + "multiline flag values remain one argument" + +# A flag's value is not a subcommand: --prompt run stays a desk. +got="$(desk opencode --prompt run --verbose)" +[[ "$got" == "opencode [--prompt] [run] [--verbose] cli="* ]] || fail "prompt run still runs: $got" +[[ -f "$RECORDS/t9.json" ]] || fail "opencode --prompt run is interactive" +expect "$(record .args)" '["--verbose"]' "the prompt value run is not replayed and not a subcommand" +desk opencode --log-level run --verbose >/dev/null +[[ -f "$RECORDS/t9.json" ]] || fail "opencode --log-level run is interactive" +expect "$(record .args)" '["--log-level","run","--verbose"]' "a flag value named run stays with its flag" +desk codex -c exec --verbose >/dev/null +[[ -f "$RECORDS/t9.json" ]] || fail "codex -c exec is a config value, not headless" +expect "$(record .args)" '["-c","exec","--verbose"]' "codex -c takes the next word" +printf 'PASS: a flag value named run is not a subcommand\n' + +# Args can name a key. A new dir is private; one already there keeps its mode. +# The file is 0600 even under a loose umask, and the harness is not started with another. +rm -rf "$RECORDS" +(umask 022; desk opencode --verbose >/dev/null) +expect "$(stat -c %a "$RECORDS")" 700 "a new desk-sessions dir is private" +expect "$(stat -c %a "$RECORDS/t9.json")" 600 "the record is private" +chmod 755 "$RECORDS" +(umask 022; desk opencode --verbose >/dev/null) +expect "$(stat -c %a "$RECORDS")" 755 "an existing desk-sessions dir keeps its mode" +expect "$(stat -c %a "$RECORDS/t9.json")" 600 "rewriting the record stays private" +cat >"$TMP_DIR/bin/opencode" <<'AGENT' +#!/usr/bin/env bash +umask +AGENT +expect "$(umask 022; desk opencode)" 0022 "the harness keeps the umask it was started with" +cat >"$TMP_DIR/bin/opencode" <<'AGENT' +#!/usr/bin/env bash +printf '%s' "$(basename "$0")" +for a in "$@"; do printf ' [%s]' "$a"; done +printf ' cli=%s\n' "${OPENCODE_CLI_CONFIG_CONTENT:-none}" +AGENT +chmod +x "$TMP_DIR/bin/opencode" +printf 'PASS: records are private, and an existing dir is left as it was\n' + +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 + # shellcheck disable=SC2086 + desk $headless >/dev/null + [[ ! -e "$RECORDS/t9.json" ]] || fail "headless run got a record: $headless" +done +printf 'PASS: headless runs get no record\n' + +printf '{"labels":{}}\n' >"$TMP_DIR/terminal.json" +desk opencode >/dev/null +[[ ! -e "$RECORDS/t9.json" ]] || fail "a terminal that is not a desk got a record" +printf '{"labels":{"bercail.role":"agent","bercail.space":"sp1"}}\n' >"$TMP_DIR/terminal.json" +(unset HC_TERMINAL_ID; desk opencode >/dev/null) +[[ ! -e "$RECORDS/t9.json" ]] || fail "outside hc there is no address to key on" +printf '#!/usr/bin/env bash\nexit 1\n' >"$TMP_DIR/bin/hc.down" +mv "$TMP_DIR/bin/hc" "$TMP_DIR/hc.up"; mv "$TMP_DIR/bin/hc.down" "$TMP_DIR/bin/hc"; chmod +x "$TMP_DIR/bin/hc" +[[ "$(desk opencode)" == "opencode cli="* ]] || fail "hc down must not stop the desk" +[[ ! -e "$RECORDS/t9.json" ]] || fail "hc down: no record" +mv "$TMP_DIR/hc.up" "$TMP_DIR/bin/hc" +printf 'PASS: no record outside a desk, and a failure never stops the desk\n' + +# hc status may include other sessions. Only the TUI plugin owns identity. +cat >"$TMP_DIR/bin/opencode" <<'AGENT' +#!/usr/bin/env bash +exec sleep "${AGENT_LIFE:-2}" +AGENT +status() { jq -cn --argjson r "$1" '{jsonrpc: "2.0", method: "event", params: {type: "status", terminal: "t9", data: {records: $r}}}'; } +{ status '{"ses_second":{"app":"opencode"}}'; } >"$EVENTS" +AGENT_LIFE=1 desk opencode --session ses_given >/dev/null +expect "$(record .session)" '"ses_given"' "hc reports do not replace the selected session" +printf '[bstack]\nenabled = false\n' >"$CONFIG" +AGENT_LIFE=1 desk opencode --session ses_given >/dev/null +expect "$(record .session)" '"ses_given"' "same identity rule without bstack" +printf '[bstack]\nenabled = true\n' >"$CONFIG" +printf 'PASS: hc status cannot overwrite the selected TUI session\n' + +# The record's pid is the agent's: the wrapper execs, so it stays the same. +: >"$EVENTS" +AGENT_LIFE=30 "$ROOT/bin/bercail-desk-agent" opencode >/dev/null & +pid=$! +wait_for '[[ -f "$RECORDS/t9.json" && "$(record .pid 2>/dev/null)" == "$pid" ]]' +kill "$pid"; wait "$pid" 2>/dev/null || true +printf 'PASS: the record names the running agent\n' + +# The watcher ends with the agent, taking its hc events with it: a live stream +# wakes it, as hc's own exit event does. The fake notes its pid to be checked. +cat >"$TMP_DIR/bin/hc" <"$TMP_DIR/events.pid" + while :; do printf '{}\n'; sleep 0.2; done + ;; +esac +HC +AGENT_LIFE=1 desk opencode >/dev/null +wait_for '[[ -s "$TMP_DIR/events.pid" ]] && ! kill -0 "$(cat "$TMP_DIR/events.pid")" 2>/dev/null' +printf 'PASS: the watcher goes when the agent does\n' + +# The heartbeat: an idle agent writes nothing, the watcher keeps the record fresh. +AGENT_LIFE=30 "$ROOT/bin/bercail-desk-agent" opencode >/dev/null & +pid=$! +wait_for '[[ "$(record .pid 2>/dev/null)" == "$pid" ]]' +touch -d '2020-01-01' "$RECORDS/t9.json" +wait_for '[[ "$(stat -c %Y "$RECORDS/t9.json")" -gt 1600000000 ]]' +kill "$pid"; wait "$pid" 2>/dev/null || true +printf 'PASS: the watcher keeps the record fresh while the agent runs\n' + +# --- the Claude hook --- +HOOK="$ROOT/bin/bercail-desk-claude-hook" +printf '{"harness":"claude","pid":%s,"session":""}\n' "$$" >"$RECORDS/t9.json" +hook_input='{"session_id":"claude-1","source":"startup"}' +printf '%s' "$hook_input" | "$HOOK" +expect "$(record .session)" '"claude-1"' "hook run by the desk's Claude" +printf '{"harness":"claude","pid":%s,"session":""}\n' "$$" >"$RECORDS/t9.json" +printf '%s' "$hook_input" | sh -c "'$HOOK'" +expect "$(record .session)" '"claude-1"' "Claude runs hooks through a shell" +printf '{"harness":"claude","pid":%s,"session":"keep"}\n' "$$" >"$RECORDS/t9.json" +cp "$(command -v bash)" "$TMP_DIR/nested-agent" +printf '%s' "$hook_input" | "$TMP_DIR/nested-agent" -c "'$HOOK'; :" +expect "$(record .session)" '"keep"' "a nested agent directly below the desk is not a hook shell" +printf '%s' "$hook_input" | sh -c "sh -c \"sh -c '$HOOK'; :\"; :" +expect "$(record .session)" '"keep"' "a claude -p the desk started is not the desk" +printf '{"harness":"opencode","pid":%s,"session":"keep"}\n' "$$" >"$RECORDS/t9.json" +printf '%s' "$hook_input" | "$HOOK" +expect "$(record .session)" '"keep"' "a Claude inside an opencode desk is not the desk" +rm -f "$RECORDS/t9.json" +printf '%s' "$hook_input" | "$HOOK" +[[ ! -e "$RECORDS/t9.json" ]] || fail "no record, not a desk: the hook writes nothing" +printf 'PASS: the Claude hook names the session of the desk it belongs to, only\n' diff --git a/tests/desk_restore_test.sh b/tests/desk_restore_test.sh new file mode 100644 index 0000000..a8f204e --- /dev/null +++ b/tests/desk_restore_test.sh @@ -0,0 +1,525 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Desks come back after a reboot: which records get resumed, what gets typed, and +# how it is wired. Nothing here reaches a real hc core or the real home. +set -euo pipefail + +unset BASH_ENV HC_TERMINAL_ID HC_SESSION HC_SCOPE HC_TERMINAL_SOCKET +export __MISE_BASH_ENV_LOADED=1 + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +expect() { [[ "$1" == "$2" ]] || fail "$3: got '$1', want '$2'"; } + +export HOME="$TMP_DIR/home" XDG_STATE_HOME="$TMP_DIR/state" XDG_RUNTIME_DIR="$TMP_DIR/run" XDG_CONFIG_HOME="$TMP_DIR/home/.config" +mkdir -p "$HOME" "$TMP_DIR/bin" "$XDG_RUNTIME_DIR/herdr-core" +export PATH="$TMP_DIR/bin:$PATH" HC_BIN="$TMP_DIR/bin/hc" +RECORDS="$XDG_STATE_HOME/bercail/desk-sessions" +export DESK_RESTORE_POLL=0 + +# shellcheck source=lib/common.sh +source "$ROOT/lib/common.sh" +# shellcheck source=lib/detect.sh +source "$ROOT/lib/detect.sh" +# shellcheck source=lib/config.sh +source "$ROOT/lib/config.sh" +# shellcheck source=lib/desk.sh +source "$ROOT/lib/desk.sh" +export INSTALL_SRC="$ROOT" +YES=1 +DRY_RUN=0 + +# --- the selection, from facts alone --- + +SHUTDOWN=1000000 +# hc get for a terminal a reboot left at a bare shell, as seen on a real core. +bare='{"state":"live","reason":null,"reported":false,"pid":7,"argv":["/usr/bin/bash"], + "foreground":{"name":"bash","pid":7},"labels":{"bercail.role":"agent","bercail.space":"sp1"}}' +record() { # session [harness] [space] + jq -cn --arg s "$1" --arg h "${2:-opencode}" --arg sp "${3:-sp1}" \ + '{session: $s, harness: $h, space: $sp, cwd: "/w", issue: "x-1", args: []}' +} +# fact address record mtime terminal [cwd_exists] [in_ps] [open_sessions] +fact() { + jq -cn --arg a "$1" --argjson r "$2" --argjson m "$3" --argjson t "$4" \ + --argjson c "${5:-true}" --argjson p "${6:-false}" \ + --argjson o "${7:-[]}" \ + '{address: $a, record: $r, mtime: $m, terminal: $t, cwd_exists: $c, in_ps: $p, open_sessions: $o}' +} +# verdict "": "address action why" per desk. +verdict() { + printf '%s\n' "$@" | jq -s . | desk_restore_plan "$SHUTDOWN" | jq -r '.[] | "\(.address) \(.action) \(.why)"' +} +one() { verdict "$(fact t1 "$@")"; } + +fresh=$((SHUTDOWN - 60)) +expect "$(one "$(record ses_a)" "$fresh" "$bare")" "t1 resume " "written just before the shutdown" +expect "$(one "$(record ses_a)" $((SHUTDOWN - 600)) "$bare")" "t1 resume " "exactly at the window's edge" +expect "$(one "$(record ses_a)" $((SHUTDOWN - 601)) "$bare")" "t1 skip closed before the shutdown" "stale by a second" +expect "$(one "$(record ses_a)" $((SHUTDOWN - 86400)) "$bare")" "t1 skip closed before the shutdown" "stale by a day" +expect "$(one "$(record ses_a)" $((SHUTDOWN + 5000)) "$bare")" "t1 resume " "this boot, after a core restart" +printf 'PASS: only a record written in the ten minutes before the shutdown comes back\n' + +running='{"state":"live","reason":null,"reported":true,"pid":9,"argv":["bercail-desk-agent"], + "foreground":{"name":"opencode.exe","pid":9},"labels":{"bercail.role":"agent","bercail.space":"sp1"}}' +busy="$(jq -c '.foreground = {name: "vim", pid: 11}' <<<"$bare")" +inner="$(jq -c '.reported = true' <<<"$bare")" +parked="$(jq -c '.reason = "stopped_by_restart" | .foreground = null | .pid = null' <<<"$bare")" +starting="$(jq -c '.foreground = null' <<<"$bare")" +finished="$(jq -c '.state = "finished" | .reason = "exited"' <<<"$bare")" +plain="$(jq -c '.labels = {}' <<<"$bare")" +expect "$(one "$(record ses_a)" "$fresh" "$running")" "t1 skip already running" "an agent is up" +expect "$(one "$(record ses_a)" "$fresh" "$busy")" "t1 skip already running" "the shell is running something" +expect "$(one "$(record ses_a)" "$fresh" "$inner")" "t1 skip already running" "something reports status" +expect "$(one "$(record ses_a)" "$fresh" "$parked")" "t1 wait hc is recovering it" "parked by a core stop" +expect "$(one "$(record ses_a)" "$fresh" "$starting")" "t1 wait hc is recovering it" "shell not up yet" +expect "$(one "$(record ses_a)" "$fresh" null)" "t1 gone terminal ended" "terminal removed" +expect "$(one "$(record ses_a)" "$fresh" "$finished")" "t1 gone terminal ended" "a quit desk stays quit" +expect "$(one "$(record ses_a)" "$fresh" "$plain")" "t1 skip not a desk" "unlabelled terminal" +printf 'PASS: nothing is typed unless the desk sits at a bare shell\n' + +expect "$(one null 0 "$bare")" "t1 skip no record: see the desk-restore skill" "no record" +expect "$(one "$(record '')" "$fresh" "$bare")" "t1 skip no session yet" "never reached a turn" +expect "$(one "$(record ses_a opencode sp2)" "$fresh" "$bare")" "t1 skip record is for another space" "wrong space" +expect "$(one "$(record ses_a codex)" "$fresh" "$bare")" "t1 skip cannot resume codex" "harness without a resume" +expect "$(one "$(record ses_a)" "$fresh" "$bare" false)" "t1 skip folder is gone: /w" "worktree removed" +expect "$(one "$(record ses_a)" "$fresh" "$bare" true true)" "t1 skip session is open elsewhere" "a process has it" +got="$(verdict "$(fact t1 "$(record ses_a)" "$fresh" "$bare")" "$(fact t2 "$(record ses_a)" "$fresh" "$running")")" +expect "$got" $'t1 skip session is open elsewhere\nt2 skip already running' "another desk runs it" +got="$(verdict "$(fact t1 "$(record ses_a)" "$fresh" "$bare")" "$(fact t2 "$(record ses_a)" "$fresh" "$bare")")" +expect "$got" $'t1 resume \nt2 skip session is open elsewhere' "two records, one session: one desk" +held="$(jq -c '.labels = {} | .records = {"ses_a": {"app": "opencode", "state": "idle"}}' <<<"$running")" +got="$(verdict "$(fact t1 "$(record ses_a)" "$fresh" "$bare")" "$(fact t9 null 0 "$held")")" +expect "$got" "t1 skip session is open elsewhere" "ordinary terminal records hold the session" +expect "$(one "$(record ses_a)" "$fresh" "$bare" true false '["ses_a"]')" \ + "t1 skip session is open elsewhere" "a live claim holds the session" +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' + +# --- what gets typed --- + +rec="$(jq -cn '{session: "ses_a", harness: "opencode", cwd: "/w/a b", args: ["/w/stack dir"], issue: "x-1"}')" +cmd="$(desk_resume_command "$rec")" +[[ "$cmd" == "cd /w/a\\ b && TERM=xterm-256color COLORTERM=truecolor exec "* ]] || fail "resume runs in the folder, replacing the shell: $cmd" +[[ "$cmd" == *"bercail-desk-agent opencode --session ses_a /w/stack\\ dir" ]] || fail "resume keeps the flags, session first: $cmd" +[[ "$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" +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" +[[ "$cmd" != *$'\n'* ]] || fail "a newline in a flag was not quoted: $cmd" +printf 'PASS: the resume line quotes its parts and leaves the prompt out\n' + +# --- a whole pass, against a fake hc --- + +SENT="$TMP_DIR/sent" +TERMS="$TMP_DIR/terms" +mkdir -p "$TERMS" +cat >"$TMP_DIR/bin/hc" <"$TMP_DIR/hc-fails" + exit 1 + fi + fi + shift + label="" + while ((\$#)); do + if [[ "\$1" == --label ]]; then + label="\${2:-}" + shift 2 + elif [[ "\$1" == --label=* ]]; then + label="\${1#--label=}" + shift + else + shift + fi + done + if [[ -n "\$label" ]]; then + key="\${label%%=*}" + val="\${label#*=}" + jq -cs --arg k "\$key" --arg v "\$val" \ + '{terminals: [.[] | select(.labels[\$k] == \$v) | {address, state}]}' "$TERMS"/*.json + else + jq -cs '{terminals: [.[] | {address, state}]}' "$TERMS"/*.json + fi + ;; + get) + [[ ! -f "$TMP_DIR/hc-down" ]] || exit 1 + [[ -f "$TERMS/\$2.json" ]] || exit 3 + nfile="$TMP_DIR/gets-\$2" + n=0 + [[ -f "\$nfile" ]] && n="\$(cat "\$nfile")" + printf '%s' "\$((n + 1))" >"\$nfile" + if [[ -f "$TMP_DIR/busy-on-refetch" && "\$n" -ge 1 ]]; then + jq -c '.foreground = {name: "vim", pid: 11}' "$TERMS/\$2.json" + else + cat "$TERMS/\$2.json" + fi + ;; + send) + [[ ! -f "$TMP_DIR/hc-send-fail" ]] || exit 1 + printf '%s %s\n' "\$2" "\$3" >>"$SENT" + ;; +esac +EOF +chmod +x "$TMP_DIR/bin/hc" +cat >"$TMP_DIR/bin/journalctl" <"$TERMS/$1.json" +} +put_record() { # address session age-before-shutdown + 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" + touch -d "@$(($(cat "$TMP_DIR/shutdown") - $3))" "$RECORDS/$1.json" +} +reset_pass() { + rm -rf "$RECORDS" "$SENT" "$XDG_RUNTIME_DIR/bercail/open-sessions" + rm -f "$TERMS"/*.json "$TMP_DIR"/gets-* "$TMP_DIR/hc-fails" "$TMP_DIR/busy-on-refetch" "$TMP_DIR/hc-send-fail" + date +%s >"$TMP_DIR/shutdown" + DESK_TYPED=() +} + +reset_pass +terminal t1 "$bare"; put_record t1 ses_fresh 60 +terminal t2 "$bare"; put_record t2 ses_stale 7200 +terminal t3 "$running"; put_record t3 ses_up 30 +terminal t4 "$bare" +put_record t5 ses_removed 60 + +out="$(cmd_desk restore --dry-run)" +[[ ! -e "$SENT" ]] || fail "dry-run typed: $(cat "$SENT")" +[[ -f "$RECORDS/t5.json" ]] || fail "dry-run removed a record" +[[ "$out" == *"t1 would resume x-1 ses_fresh $TMP_DIR"* ]] || fail "dry-run lists the fresh desk: $out" +[[ "$out" == *"t2 skip: closed before the shutdown"* ]] || fail "dry-run says why stale: $out" +[[ "$out" == *"t3 skip: already running"* ]] || fail "dry-run says running: $out" +[[ "$out" == *"t4 skip: no record"* ]] || fail "dry-run names a desk with no record: $out" +[[ "$out" == *"1 would come back"* ]] || fail "dry-run counts: $out" +printf 'PASS: --dry-run lists the right desks and changes nothing\n' + +out="$(cmd_desk restore)" +expect "$(wc -l <"$SENT" | tr -d ' ')" 1 "only the fresh desk is typed into" +[[ "$(cat "$SENT")" == "t1 cd "*"opencode --session ses_fresh" ]] || fail "typed: $(cat "$SENT")" +[[ ! -e "$RECORDS/t5.json" ]] || fail "a record whose terminal ended is cleaned up" +[[ -f "$RECORDS/t2.json" ]] || fail "a stale record of a live terminal stays" +[[ "$out" == *"1 came back"* ]] || fail "restore counts: $out" +printf 'PASS: restore types into the fresh desk only and drops dead records\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" +rm -f "$TMP_DIR/hc-down" +printf 'PASS: hc down leaves the records alone\n' + +# An ordinary terminal, not a desk, whose hc .records names the session. argv +# does not. Restore must not type, and must not act on that terminal. +reset_pass +terminal t1 "$bare"; put_record t1 ses_held 60 +terminal t9 "$(jq -c '.labels = {} | .records = {"ses_held": {"app": "opencode", "state": "idle"}}' <<<"$running")" +out="$(cmd_desk restore)" +[[ ! -e "$SENT" ]] || fail "resumed a session an ordinary terminal already holds: $(cat "$SENT")" +[[ "$out" == *"t1 skip: session is open elsewhere"* ]] || fail "records duplicate: $out" +[[ "$out" != *"t9"* ]] || fail "acted on a non-desk terminal: $out" +printf 'PASS: a session in an ordinary terminal'\''s records is not resumed\n' + +# The plugin's claim: a live pid whose start time matches suppresses restore. +# A dead pid, or a live pid with the wrong start time, does not. +claim_file() { # pid ticks session + mkdir -p "$XDG_RUNTIME_DIR/bercail/open-sessions" + jq -n --argjson pid "$1" --arg ticks "$2" --arg s "$3" \ + '{pid: $pid, start_ticks: $ticks, sessions: [$s]}' \ + >"$XDG_RUNTIME_DIR/bercail/open-sessions/$1.json" +} +start_ticks() { + local stat + stat="$(<"/proc/$1/stat")" + awk '{ print $20 }' <<<"${stat##*)}" +} +reset_pass +terminal t1 "$bare"; put_record t1 ses_claim 60 +sleep 60 & +claim_pid=$! +claim_file "$claim_pid" "$(start_ticks "$claim_pid")" ses_claim +out="$(cmd_desk restore)" +[[ ! -e "$SENT" ]] || fail "live claim was resumed: $(cat "$SENT")" +[[ "$out" == *"session is open elsewhere"* ]] || fail "live claim: $out" +kill "$claim_pid" +wait "$claim_pid" 2>/dev/null || true +rm -f "$SENT" +DESK_TYPED=() +out="$(cmd_desk restore)" +[[ -e "$SENT" ]] || fail "a dead pid's claim blocked restore: $out" +printf 'PASS: a live claim blocks restore; a dead pid does not\n' + +reset_pass +terminal t1 "$bare"; put_record t1 ses_stale_claim 60 +sleep 60 & +claim_pid=$! +claim_file "$claim_pid" 1 ses_stale_claim +out="$(cmd_desk restore)" +kill "$claim_pid" +wait "$claim_pid" 2>/dev/null || true +[[ -e "$SENT" ]] || fail "a mismatched start time blocked restore: $out" +printf 'PASS: a claim whose start time does not match is ignored\n' + +# Planning saw a bare shell. By the time we would type, it is vim. +reset_pass +terminal t1 "$bare"; put_record t1 ses_flip 60 +touch "$TMP_DIR/busy-on-refetch" +out="$(cmd_desk restore)" +rm -f "$TMP_DIR/busy-on-refetch" +[[ ! -e "$SENT" ]] || fail "typed into a terminal that became busy: $(cat "$SENT")" +[[ "$out" == *"t1 skip: already running"* ]] || fail "refetch skip: $out" +printf 'PASS: a terminal that is busy by send time is not typed into\n' + +reset_pass +terminal t1 "$bare"; put_record t1 ses_send 60 +touch "$TMP_DIR/hc-send-fail" +if out="$(cmd_desk restore 2>&1)"; then fail "send failure should fail: $out"; fi +[[ "$out" == *"could not type"* ]] || fail "send failure message: $out" +[[ ! -e "$SENT" ]] || fail "a failed send was recorded: $(cat "$SENT")" +rm -f "$TMP_DIR/hc-send-fail" +printf 'PASS: hc send failure fails the restore\n' + +# A parked terminal gets its shell a few seconds later: the pass asks again, and +# does not type twice into a shell that is slow to load. +reset_pass +terminal t1 "$parked"; put_record t1 ses_slow 60 +terminal t2 "$bare"; put_record t2 ses_quick 60 +cat >"$TMP_DIR/bin/sleep" <"$TMP_DIR/t1-recovered.json" +out="$(cmd_desk restore)" +rm -f "$TMP_DIR/bin/sleep"; hash -r +expect "$(sort "$SENT" | cut -d' ' -f1 | tr '\n' ' ')" "t1 t2 " "each desk typed into once" +[[ "$out" == *"2 came back"* ]] || fail "both counted: $out" +printf 'PASS: restore waits out hc recovery and types once per desk\n' + +# --- once per boot --- + +reset_pass +terminal t1 "$bare"; put_record t1 ses_boot 60 +cat >"$TMP_DIR/bin/notify-send" <>"$TMP_DIR/notified" +EOF +chmod +x "$TMP_DIR/bin/notify-send" +MARKER="$XDG_RUNTIME_DIR/bercail-desk-restore.done" +SOCK="$XDG_RUNTIME_DIR/herdr-core/default.sock" +# The wait stays in the loop until the core's socket shows up. +cat >"$TMP_DIR/bin/sleep" </dev/null +rm -f "$TMP_DIR/bin/sleep"; hash -r +[[ -S "$SOCK" ]] || fail "boot run should have waited for the core" +[[ -e "$MARKER" ]] || fail "boot run leaves its marker" +expect "$(cut -d' ' -f1 "$SENT")" t1 "boot run restores" +expect "$(cat "$TMP_DIR/notified")" "Bercail 1 desk came back" "boot run says how many" +rm -f "$SENT" +(cmd_desk restore --boot) >/dev/null +[[ ! -e "$SENT" ]] || fail "second run in one boot typed: $(cat "$SENT")" +printf 'PASS: --boot waits for the core, runs once, notifies with the count\n' + +rm -f "$MARKER" "$TMP_DIR/notified" +reset_pass +terminal t1 "$running"; put_record t1 ses_up 60 +(cmd_desk restore --boot) >/dev/null +[[ ! -e "$TMP_DIR/notified" ]] || fail "nothing came back, nothing to say" +printf 'PASS: no notification when nothing came back\n' + +# The parent shell must not still hold the restore lock, or both boots inherit it. +exec 9>&- +unset DESK_LOCK_HELD || true +[[ -S "$SOCK" ]] || /usr/bin/python3 -c 'import socket,sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' "$SOCK" +rm -f "$MARKER" "$SENT" "$TMP_DIR/notified" +reset_pass +terminal t1 "$bare"; put_record t1 ses_once 60 +cmd_desk restore --boot >"$TMP_DIR/boot-a" 2>&1 & +boot_a=$! +cmd_desk restore --boot >"$TMP_DIR/boot-b" 2>&1 & +boot_b=$! +wait "$boot_a" || true +wait "$boot_b" || true +[[ -f "$SENT" ]] || fail "concurrent boot typed nothing: $(cat "$TMP_DIR/boot-a" "$TMP_DIR/boot-b")" +expect "$(wc -l <"$SENT" | tr -d ' ')" 1 "concurrent boots type once" +printf 'PASS: two boots at once type into each desk once\n' + +rm -f "$MARKER" "$SENT" +reset_pass +terminal t1 "$bare"; put_record t1 ses_retry 60 +printf '2' >"$TMP_DIR/hc-fails" +DESK_RESTORE_RECOVERY_WAIT=30 +out="$(cmd_desk restore --boot 2>&1)" || fail "transient hc failure should retry: $out" +DESK_RESTORE_RECOVERY_WAIT=120 +[[ -e "$MARKER" ]] || fail "a retry that finishes marks done" +expect "$(cut -d' ' -f1 "$SENT")" t1 "retried boot restores" +rm -f "$MARKER" "$SENT" "$TMP_DIR/hc-fails" +printf '99' >"$TMP_DIR/hc-fails" +DESK_RESTORE_RECOVERY_WAIT=0 +if cmd_desk restore --boot >/dev/null 2>&1; then fail "hc still down should fail"; fi +DESK_RESTORE_RECOVERY_WAIT=120 +[[ ! -e "$MARKER" ]] || fail "a failed boot must not mark done" +rm -f "$TMP_DIR/hc-fails" +printf 'PASS: a transient hc failure is retried; one that lasts leaves no marker\n' + +rm -f "$MARKER" "$SENT" +reset_pass +terminal t1 "$parked"; put_record t1 ses_wait 60 +DESK_RESTORE_RECOVERY_WAIT=0 +if cmd_desk restore --boot >/dev/null 2>&1; then fail "recovery timeout should fail"; fi +DESK_RESTORE_RECOVERY_WAIT=120 +[[ ! -e "$MARKER" ]] || fail "a timeout must not mark success" +[[ ! -e "$SENT" ]] || fail "a timeout typed: $(cat "$SENT")" +printf 'PASS: a recovery timeout does not mark the boot done\n' + +rm -f "$MARKER" "$SENT" +reset_pass +terminal t1 "$bare"; put_record t1 ses_send_boot 60 +touch "$TMP_DIR/hc-send-fail" +DESK_RESTORE_RECOVERY_WAIT=0 +if cmd_desk restore --boot >/dev/null 2>&1; then fail "boot send failure should fail"; fi +DESK_RESTORE_RECOVERY_WAIT=120 +rm -f "$TMP_DIR/hc-send-fail" +[[ ! -e "$MARKER" ]] || fail "a send failure must not mark done" +printf 'PASS: hc send failure does not mark the boot done\n' + +# No previous boot in the journal: only records this boot wrote can pass. +rm -f "$TMP_DIR/bin/journalctl" +t="$(_desk_shutdown_time)" +[[ "$t" =~ ^[0-9]+$ && "$t" -gt 1000000000 ]] || fail "no journal still gives a time: $t" +printf '#!/usr/bin/env bash\nexit 1\n' >"$TMP_DIR/bin/journalctl" +chmod +x "$TMP_DIR/bin/journalctl" +t="$(_desk_shutdown_time)" +[[ "$t" =~ ^[0-9]+$ && "$t" -gt 1000000000 ]] || fail "a journal with no previous boot still gives a time: $t" +printf 'PASS: no journal falls back to a time that leaves old desks alone\n' + +# --- wiring --- + +# This machine may really be Omarchy, so the test says which one it is. +is_omarchy() { [[ -d "$HOME/.local/share/omarchy" ]]; } +fake_omarchy() { + cat >"$TMP_DIR/bin/omarchy-hook-install" <<'EOF' +#!/bin/bash +set -e +mkdir -p "$HOME/.config/omarchy/hooks/$1.d" +cp -- "$2" "$HOME/.config/omarchy/hooks/$1.d/$(basename -- "$2")" +chmod 755 "$HOME/.config/omarchy/hooks/$1.d/$(basename -- "$2")" +EOF + chmod +x "$TMP_DIR/bin/omarchy-hook-install" +} +cat >"$TMP_DIR/bin/systemctl" <>"$TMP_DIR/systemctl-calls" +[[ "\$*" != "--user show-environment" ]] || printf 'HOME=%s\n' "\$HOME" +EOF +chmod +x "$TMP_DIR/bin/systemctl" + +deploy_desk_restore_boot >/dev/null +[[ -f "$DESK_RESTORE_UNIT" ]] || fail "off Omarchy, a systemd user unit is installed" +grep -q 'enable bercail-desk-restore.service' "$TMP_DIR/systemctl-calls" || fail "the unit is enabled" +grep -q 'bercail desk restore --boot' "$DESK_RESTORE_UNIT" || fail "the unit runs the boot restore" +[[ ! -e "$OMARCHY_POST_BOOT_HOOK" ]] || fail "no Omarchy hook off Omarchy" +out="$(doctor_desk_restore)"; [[ "$out" == *ok* ]] || fail "doctor: $out" +rm -f "$DESK_RESTORE_UNIT" +if out="$(doctor_desk_restore)"; then fail "doctor should flag the missing unit: $out"; fi +printf 'PASS: a systemd user unit off Omarchy, and doctor checks it\n' + +rm -f "$TMP_DIR/systemctl-calls" +ln -s "$ROOT" "$TMP_DIR/package tree" +( + BERCAIL_PACKAGED=1 + INSTALL_SRC="$TMP_DIR/package tree" + deploy_desk_restore_boot >/dev/null +) +grep -qF "ExecStart=\"$TMP_DIR/package tree/bin/bercail\" desk restore --boot" "$DESK_RESTORE_UNIT" \ + || fail "packaged unit must run the release CLI: $(cat "$DESK_RESTORE_UNIT")" +if grep -q '%h/.local/bin/bercail' "$DESK_RESTORE_UNIT"; then + fail "packaged unit still points at the skipped binary: $(cat "$DESK_RESTORE_UNIT")" +fi +grep -q '%h/.local/share/mise/shims' "$DESK_RESTORE_UNIT" \ + || fail "unit PATH drops mise shims: $(cat "$DESK_RESTORE_UNIT")" +rm -f "$DESK_RESTORE_UNIT" +printf 'PASS: packaged install runs the release CLI and keeps harness tools on PATH\n' + +mv "$TMP_DIR/bin/systemctl" "$TMP_DIR/systemctl.off" +deploy_desk_restore_boot >/dev/null +[[ ! -e "$DESK_RESTORE_UNIT" ]] || fail "no user manager, no unit" +out="$(doctor_desk_restore)"; [[ -z "$out" ]] || fail "doctor stays quiet without a way in: $out" +mv "$TMP_DIR/systemctl.off" "$TMP_DIR/bin/systemctl" +rm -f "$TMP_DIR/systemctl-calls" +printf 'PASS: no boot wiring without a user manager\n' + +mkdir -p "$HOME/.local/share/omarchy" +fake_omarchy +deploy_desk_restore_boot >/dev/null +cmp -s "$ROOT/config/omarchy/bercail-desk-restore.hook" "$OMARCHY_POST_BOOT_HOOK" || fail "Omarchy gets the post-boot hook" +[[ -x "$OMARCHY_POST_BOOT_HOOK" ]] || fail "hook not executable" +[[ ! -e "$DESK_RESTORE_UNIT" ]] || fail "one way in per machine" +out="$(deploy_desk_restore_boot)" +[[ "$out" == *"unchanged: $OMARCHY_POST_BOOT_HOOK"* ]] || fail "second update leaves the hook: $out" +printf 'PASS: Omarchy gets the post-boot hook, and keeps one\n' + +# The hook must return at once: omarchy-hook runs its hooks in a row. +cat >"$TMP_DIR/bin/bercail" <"$TMP_DIR/hook-ran" +EOF +chmod +x "$TMP_DIR/bin/bercail" +timeout 5 bash "$OMARCHY_POST_BOOT_HOOK" /dev/null 2>&1 || fail "hook failed or hung" +for _ in 1 2 3 4 5 6 7 8 9 10; do + [[ ! -f "$TMP_DIR/hook-ran" ]] || break + sleep 0.2 +done +expect "$(cat "$TMP_DIR/hook-ran")" "desk restore --boot" "the hook starts the boot run" +rm -f "$TMP_DIR/bin/bercail" +timeout 5 bash "$OMARCHY_POST_BOOT_HOOK" /dev/null 2>&1 || fail "hook without bercail must not fail" +printf 'PASS: the hook returns at once and runs the boot restore\n' + +# Claude's settings: one entry added, the rest untouched, uninstall takes it back. +SETTINGS="$HOME/.claude/settings.json" +deploy_desk_claude_hook >/dev/null +[[ ! -e "$SETTINGS" ]] || fail "no ~/.claude, no settings created" +mkdir -p "$HOME/.claude" +printf '{"model":"x","hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"mine"}]}],"Stop":[{"hooks":[]}]}}\n' >"$SETTINGS" +deploy_desk_claude_hook >/dev/null +deploy_desk_claude_hook >/dev/null +HOOK_CMD="$LOCAL_BIN/bercail-desk-claude-hook" +expect "$(jq -c '[.hooks.SessionStart[].hooks[].command]' "$SETTINGS")" "[\"mine\",\"$HOOK_CMD\"]" "added once, beside the person's own" +expect "$(jq -c --arg c "$HOOK_CMD" '[.hooks.SessionStart[].hooks[] | select(.command == $c) | .args]' "$SETTINGS")" '[[]]' "new hook is the exec form" +expect "$(jq -r .model "$SETTINGS")" x "other settings stay" +touch "$OMARCHY_POST_BOOT_HOOK" +remove_desk_restore >/dev/null +[[ ! -e "$OMARCHY_POST_BOOT_HOOK" ]] || fail "uninstall removes the hook" +expect "$(jq -c .hooks.SessionStart "$SETTINGS")" '[{"hooks":[{"type":"command","command":"mine"}]}]' "uninstall takes only ours" +jq -e '.hooks.Stop' "$SETTINGS" >/dev/null || fail "uninstall leaves other hooks" +printf '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"%s"}]}]}}\n' "$HOOK_CMD" >"$SETTINGS" +remove_desk_restore >/dev/null +expect "$(cat "$SETTINGS")" '{}' "uninstall leaves no empty hooks behind" +printf 'not json' >"$SETTINGS" +out="$(deploy_desk_claude_hook 2>&1)" +[[ "$out" == *"not valid JSON"* ]] || fail "broken settings are reported: $out" +expect "$(cat "$SETTINGS")" 'not json' "broken settings are left alone" +printf 'PASS: the Claude hook is added once, kept apart, and removed cleanly\n' diff --git a/tests/desk_session_plugin_live.py b/tests/desk_session_plugin_live.py new file mode 100644 index 0000000..e434dc6 --- /dev/null +++ b/tests/desk_session_plugin_live.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +# Disposable OpenCode TUI: the plugin records the selected session without a prompt. +import base64 +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 +import urllib.request +from pathlib import Path +from urllib.parse import quote + +ROOT = Path(sys.argv[1]) +OC = sys.argv[2] +PLUGIN = ROOT / "config/opencode/plugins/bercail-desk/tui.ts" +TMP = Path("/tmp/opencode") +TMP.mkdir(parents=True, exist_ok=True) +WORK = None +PROCS = [] +FAILS = [] + + +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() + for key in list(env): + if key.startswith("OPENCODE") or key in ("HC_TERMINAL_ID", "HC_SESSION", "BASH_ENV"): + env.pop(key, None) + env.update(base) + env.update(extra) + env["TERM"] = "xterm-256color" + env["COLORTERM"] = "truecolor" + return env + + +def start_ticks(pid): + text = Path(f"/proc/{pid}/stat").read_text() + return text[text.rfind(")") + 2 :].split()[19] + + +def api(base, password, method, path, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(base + path, data=data, method=method) + token = base64.b64encode(f"opencode:{password}".encode()).decode() + req.add_header("Authorization", f"Basic {token}") + if body is not None: + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode()) + + +def wait_for(label, pred, timeout): + deadline = time.time() + timeout + while time.time() < deadline: + 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): + self.record = record + self.term = WORK / "terminal.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}.sh" + wrap.write_text( + "#!/bin/bash\n" + "set -euo pipefail\n" + "python3 -c 'import json,os,sys\n" + "p,pid,addr=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\":\"opencode\",\"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)}\n" + f"exec {shlex.quote(OC)} \"$@\"\n" + ) + wrap.chmod(0o755) + cmd = [str(wrap), *args] + else: + cmd = [OC, *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 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 main(): + global WORK + run = Path(TMP) / f"desk-session-plugin.{os.getpid()}" + if run.exists(): + raise SystemExit(f"temp exists: {run}") + WORK = run + home = run / "home" + plugin_dir = home / ".config/opencode/plugins/bercail-desk" + plugin_dir.mkdir(parents=True) + (plugin_dir / "tui.ts").write_bytes(PLUGIN.read_bytes()) + state = run / "state" + runtime = run / "run" + project = run / "project" + for path in (state, runtime, project, run / "data", run / "cache"): + path.mkdir(parents=True) + (project / "README").write_text("desk plugin 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" + log = open(run / "serve.log", "wb") + server = subprocess.Popen( + [OC, "serve", "--hostname", "127.0.0.1", "--port", "0"], + cwd=project, + env=child_env(base), + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + PROCS.append(server) + password = port = None + deadline = time.time() + 20 + while time.time() < deadline and (password is None or port is None): + text = Path(run / "serve.log").read_text(errors="replace") if Path(run / "serve.log").exists() else "" + for line in text.splitlines(): + if line.startswith("server listening on http://127.0.0.1:"): + port = line.rsplit(":", 1)[-1].strip() + if line.startswith("server password "): + password = line.split(" ", 2)[-1].strip() + if server.poll() is not None: + break + time.sleep(0.1) + if not password or not port: + fail(f"server did not start: {Path(run / 'serve.log').read_text(errors='replace')[-500:]}") + return + origin = f"http://127.0.0.1:{port}" + created = api(origin, password, "POST", "/api/session", {}) + session = created["data"]["id"] + if not session.startswith("ses_"): + fail(f"session was not created: {created}") + return + url = f"http://opencode:{quote(password, safe='')}@{origin.split('://', 1)[1]}" + print(f"session {session} created with no prompt") + + # Tabs on. A second tab is not opened here: the TUI's leader key is not + # reliable on a raw PTY. The unit test unions an idle tab with the selection. + record = records / "t9.json" + decoy = records / "t-other.json" + records.mkdir(parents=True, exist_ok=True) + decoy.write_text('{"address":"t-other","session":"ses_keep","harness":"opencode","pid":1}\n') + tui = Tui( + child_env(base, HC_TERMINAL_ID="t9"), + ["--server", url, "--session", session, str(project)], + record, + "t9", + ) + claim = runtime / "bercail/open-sessions" / f"{tui.proc.pid}.json" + + def recorded(): + data = read_json(record) + return data.get("session") == session and data.get("cwd") == str(project) + + if wait_for("selected session and its directory", recorded, 20): + data = read_json(record) + if data.get("issue") != "bercail-1" or data.get("args") != ["--verbose"] or data.get("space") != "sp1": + fail(f"record metadata changed: {data}") + if data.get("pid") != tui.proc.pid or data.get("address") != "t9" or data.get("harness") != "opencode": + fail(f"record identity 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 session not in claimed.get("sessions", []): + fail(f"claim misses the open session: {claimed}") + cmd = Path(f"/proc/{tui.proc.pid}/cmdline").read_bytes().replace(b"\0", b" ").decode() + if "--prompt" in cmd: + fail(f"a prompt was passed: {cmd}") + fresh = api(origin, password, "GET", f"/api/session/{session}") + tokens = fresh["data"]["tokens"] + if fresh["data"].get("cost") or tokens.get("input") or tokens.get("output"): + fail(f"a model was prompted: {fresh['data']}") + if decoy.read_text() != '{"address":"t-other","session":"ses_keep","harness":"opencode","pid":1}\n': + fail("another terminal's record was touched") + print("PASS: idle TUI recorded the resumed session, its directory, and the open-session claim") + # Quitting must not look like home: a reboot still needs this session. + tui.close() + try: + tui.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + if read_json(record).get("session") != session: + fail(f"exit cleared the session: {read_json(record)}") + else: + print("PASS: exiting the TUI leaves the session to be resumed") + else: + tui.close() + + # Tabs off, the desk's bstack config: the route still names the session. + off = records / "t-off.json" + tui = Tui( + child_env(base, HC_TERMINAL_ID="t-off", OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"mode":"off"}}'), + ["--server", url, "--session", session, str(project)], + off, + "t-off", + ) + off_claim = runtime / "bercail/open-sessions" / f"{tui.proc.pid}.json" + + def off_recorded(): + data = read_json(off) + claimed = read_json(off_claim) + return data.get("session") == session and session in claimed.get("sessions", []) + + if wait_for("tabs off still records the selected session", off_recorded, 20): + print("PASS: tabs off, the selected session is still the one recorded") + tui.close() + + # A nested or stale pid must not overwrite the desk, but this TUI still claims. + stale = records / "t-stale.json" + stale.write_text( + json.dumps( + { + "address": "t-stale", + "session": "", + "cwd": "/stale", + "harness": "opencode", + "issue": "keep", + "space": "sp1", + "args": [], + "pid": 1, + } + ) + + "\n" + ) + original = stale.read_text() + tui = Tui( + child_env(base, HC_TERMINAL_ID="t-stale"), + ["--server", url, "--session", session, str(project)], + ) + stale_claim = runtime / "bercail/open-sessions" / f"{tui.proc.pid}.json" + + def claimed_only(): + return session in read_json(stale_claim).get("sessions", []) + + if wait_for("stale desk still publishes a claim", claimed_only, 20): + if stale.read_text() != original: + fail(f"a stale pid was overwritten: {stale.read_text()}") + else: + print("PASS: a stale pid is not overwritten; the TUI still claims its sessions") + # No terminal: a claim, and no record invented. + tui.close() + bare = Tui(child_env(base), ["--server", url, "--session", session, str(project)]) + bare_claim = runtime / "bercail/open-sessions" / f"{bare.proc.pid}.json" + invented = records / f"{bare.proc.pid}.json" + + def bare_claimed(): + return session in read_json(bare_claim).get("sessions", []) + + if wait_for("a TUI outside a desk still claims", bare_claimed, 20): + if invented.exists() or (records / "none.json").exists(): + fail("a TUI outside a desk created a record") + else: + print("PASS: outside a desk the claim is written and no record is created") + bare.close() + + # The claim is left behind when the process ends: a reload must not open a gap. + if not bare_claim.exists(): + fail("the claim was deleted on exit") + else: + print("PASS: the claim is not deleted when the TUI exits") + + +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_plugin_stubs.mjs b/tests/desk_session_plugin_stubs.mjs new file mode 100644 index 0000000..aba3c71 --- /dev/null +++ b/tests/desk_session_plugin_stubs.mjs @@ -0,0 +1,4 @@ +export const Plugin = { define: (plugin) => plugin } +export function createEffect(run) { + run() +} diff --git a/tests/desk_session_plugin_test.mjs b/tests/desk_session_plugin_test.mjs new file mode 100644 index 0000000..d5dcd80 --- /dev/null +++ b/tests/desk_session_plugin_test.mjs @@ -0,0 +1,135 @@ +// The desk plugin's record and claim rules, with no OpenCode installed. +import { registerHooks } from "node:module" +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const root = new URL("..", import.meta.url) +const pluginURL = new URL("./config/opencode/plugins/bercail-desk/tui.ts", root) + +registerHooks({ + resolve(specifier, context, next) { + if (specifier === "@opencode/plugin/tui" || specifier === "solid-js") { + return { url: new URL("./desk_session_plugin_stubs.mjs", import.meta.url).href, shortCircuit: true } + } + return next(specifier, context) + }, +}) + +const plugin = await import(pluginURL.href) +const { applyClaim, applyDesk, claimPath, deskFields, deskNext, deskRecordPath, sessionIDs, startTicks, writeAtomic } = + plugin + +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") + +const idle = { sessionID: "ses_idle", active: false, busy: false } +const selected = { sessionID: "ses_now", active: true, busy: false } +expect( + JSON.stringify(sessionIDs({ type: "session", sessionID: "ses_now" }, [idle, selected, { sessionID: "ses_now" }])) === + JSON.stringify(["ses_idle", "ses_now"]), + "a claim is the selected session plus idle tabs, once each", +) +expect( + JSON.stringify(sessionIDs({ type: "home" }, [idle])) === JSON.stringify(["ses_idle"]), + "home still claims a tab that is open", +) +expect(JSON.stringify(sessionIDs({ type: "home" }, [])) === "[]", "home with no tabs claims nothing") +expect(JSON.stringify(sessionIDs(null, null)) === "[]", "a route that is not ready claims nothing") + +const base = { + address: "t9", + session: "", + cwd: "/old", + harness: "opencode", + 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 selected session is stored at once") +expect(deskNext({ ...base }, { ...gate, session: "ses_a" }).cwd === "/old", "cwd waits until the session cache has it") +expect( + deskNext({ ...base, session: "ses_a" }, { ...gate, session: "ses_a", cwd: "/w" }).cwd === "/w", + "cwd is stored once the session cache 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", + "home clears the session and leaves the rest") +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: "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(deskFields({ type: "session", sessionID: "ses_a" }, undefined).cwd === undefined, "no cwd until it is known") +expect(deskFields({ type: "home" }).session === "", "home is an empty session") +expect(deskFields({ type: "plugin" }) === null, "another route is not a selection") +expect(deskFields(null) === null, "no route yet does not clear") + +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") + +mkdirSync("/tmp/opencode", { recursive: true }) +const dir = mkdtempSync(join("/tmp/opencode", "desk-plugin-unit-")) +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") + 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") + expect(applyDesk(join(dir, "missing.json"), { ...gate, session: "ses_a" }) === false, "a missing record is not created") + + const claim = join(dir, "42.json") + const doc = { pid: 42, start_ticks: "424242", sessions: ["ses_idle", "ses_now"] } + 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") + const other = join(dir, "7.json") + writeFileSync(other, '{"pid":7,"start_ticks":"1","sessions":["ses_old"]}\n') + applyClaim(claim, { ...doc, sessions: [] }) + expect(readFileSync(other, "utf8").includes("ses_old"), "another process's claim is not deleted") + expect(JSON.parse(readFileSync(claim, "utf8")).sessions.length === 0, "a closed selection replaces the claim") + + 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") +} finally { + rmSync(dir, { recursive: true, force: true }) +} + +if (failed) { + console.error(`${failed} failed`) + process.exit(1) +} +console.log("PASS: session, cwd, home, idle tabs, and stale record gates") diff --git a/tests/desk_session_plugin_test.sh b/tests/desk_session_plugin_test.sh new file mode 100755 index 0000000..7ce1d17 --- /dev/null +++ b/tests/desk_session_plugin_test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# The desk's OpenCode plugin: which session is selected, without waiting for a turn. +# The unit half runs with node alone. The live half needs the installed opencode. +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_plugin_test.mjs" + +# mise's shim reinstalls when HOME changes. The aube shim execs the binary. +oc="" +if command -v mise >/dev/null 2>&1; then + oc="$(mise which opencode 2>/dev/null || true)" +fi +if [[ -z "$oc" || ! -x "$oc" || "$oc" == */mise/shims/* ]]; then + oc="$(command -v opencode 2>/dev/null || true)" + [[ -n "$oc" && "$oc" != */mise/shims/* && -x "$oc" ]] || oc="" +fi +if [[ -z "$oc" ]]; then + printf 'SKIP: opencode not found; live proof not run\n' + exit 0 +fi +command -v python3 >/dev/null 2>&1 || fail "python3 is required for the live proof" + +python3 "$ROOT/tests/desk_session_plugin_live.py" "$ROOT" "$oc" diff --git a/tests/desk_upgrade_test.sh b/tests/desk_upgrade_test.sh new file mode 100644 index 0000000..16a46c1 --- /dev/null +++ b/tests/desk_upgrade_test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# The previous updater reloads config.sh, then calls its new finalizer. Its +# already-running body still has the old library, binary and skill lists. +set -euo pipefail + +unset BASH_ENV +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT +export HOME="$TMP_DIR/home" XDG_CONFIG_HOME="$TMP_DIR/home/.config" +export INSTALL_SRC="$ROOT" +mkdir -p "$HOME/.claude" +printf '{}\n' >"$HOME/.claude/settings.json" + +source "$ROOT/lib/common.sh" +source "$ROOT/lib/detect.sh" +source "$ROOT/lib/config.sh" +source "$ROOT/lib/skills.sh" +DRY_RUN=0 +YES=1 + +# Values/functions retained by the old process. No desktop or real user manager. +AGENTIC_DEV_SKILL_IDS=(handoff review bstack council autopilot how why unslop) +unset OMARCHY_POST_BOOT_HOOK +is_omarchy() { return 1; } +systemctl() { return 1; } +legacy_update() { + local name + for name in bercail agentic-dev bercail-desk-agent; do + deploy_install_file "bin/$name" "$LOCAL_BIN/$name" + done + # The old remote library list doesn't include desk.sh. + deploy_install_file lib/config.sh "$AGENTIC_DEV_SHARE_DIR/lib/config.sh" + source "$AGENTIC_DEV_SHARE_DIR/lib/config.sh" + deploy_finalize_permissions +} + +legacy_update >"$TMP_DIR/update.log" +[[ -x "$LOCAL_BIN/bercail-desk-claude-hook" ]] || { cat "$TMP_DIR/update.log"; exit 1; } +[[ -f "$AGENTIC_DEV_SHARE_DIR/lib/desk.sh" ]] || exit 1 +[[ -f "$XDG_CONFIG_HOME/opencode/plugins/bercail-desk/tui.ts" ]] || exit 1 +[[ -f "$AGENTS_SKILLS_DIR/desk-restore/SKILL.md" ]] || exit 1 +jq -e --arg c "$LOCAL_BIN/bercail-desk-claude-hook" \ + '[.hooks.SessionStart[].hooks[].command] | index($c)' "$HOME/.claude/settings.json" >/dev/null +printf 'PASS: the previous updater installs desk restore and its hook/skill on the first update\n' diff --git a/tests/doctor_stale_test.sh b/tests/doctor_stale_test.sh index 9afc431..429934b 100644 --- a/tests/doctor_stale_test.sh +++ b/tests/doctor_stale_test.sh @@ -43,7 +43,7 @@ EOF cp "$ROOT/config/worktrunk/herdr-layout.sh" "$WORKTRUNK_CONFIG_DIR/herdr-layout.sh" cp "$ROOT/skills/handoff/SKILL.md" "$AGENTIC_DEV_SKILL_DIR/SKILL.md" cp "$ROOT/skills/review/SKILL.md" "$AGENTS_SKILLS_DIR/review/SKILL.md" -for id in bstack council autopilot how why unslop; do +for id in bstack council autopilot how why unslop desk-restore; do mkdir -p "$AGENTS_SKILLS_DIR/$id" cp "$ROOT/skills/$id/SKILL.md" "$AGENTS_SKILLS_DIR/$id/SKILL.md" done