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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions bin/bercail-desk-agent
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ flag_has_value() {
# 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 harness session="" issue="" issue_title="" space record tmp flags="[]" arg name hcjson=""
local dir="${XDG_STATE_HOME:-$HOME/.local/state}/bercail/desk-sessions"
local -a rest=()
local saw_pos=0
Expand Down Expand Up @@ -181,8 +181,8 @@ remember_desk() {
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
hcjson="$(hc get "$HC_TERMINAL_ID" --json 2>/dev/null)" || return 0
space="$(jq -r 'select(.labels["bercail.role"] == "agent") | .labels["bercail.space"] // empty' <<<"$hcjson")" || 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
Expand All @@ -195,14 +195,20 @@ remember_desk() {
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=""
# Label title when it names this issue; else the record's, on resume.
if [[ -n "$issue" ]] && jq -e --arg issue "$issue" '.labels["bercail.issue"] == $issue' <<<"$hcjson" >/dev/null 2>&1; then
issue_title="$(jq -r '.labels["bercail.issue_title"] // ""' <<<"$hcjson" 2>/dev/null)" || issue_title=""
elif [[ -n "$issue" && -r "$record" ]]; then
issue_title="$(jq -r --arg issue "$issue" 'if (.issue // "") == $issue then .issue_title // "" else "" end' "$record" 2>/dev/null)" || issue_title=""
fi
[[ ${#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" \
--arg harness "$harness" --arg issue "$issue" --arg issue_title "$issue_title" --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
issue: $issue, issue_title: $issue_title, space: $space, args: $args, pid: $pid}' >"$tmp" || ! mv "$tmp" "$record"; then
rm -f "$tmp"
return 0
fi
Expand Down
3 changes: 3 additions & 0 deletions bstack/pi/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ at it. These rules apply to every bstack agent.

- The job is one thing, written down. When a beads issue names it (`bd show <id>`),
that issue and its comments are the spec. Re-read them before you stop.
- When you name a beads issue or a desk to the person or a sibling (reports,
`hc send`, `bd comment`), write it as `<id>: <title>`, not the bare id.
- Restate the goal in one sentence before non-trivial work. If the ask is too thin
to act on, say what is missing in one message, then do the part you can.
- Load the `bstack` skill for anything beyond a one-line change. It holds the
Expand Down Expand Up @@ -52,6 +54,7 @@ Start one from your own terminal:
space="$(hc get "$HC_TERMINAL_ID" --json | jq -r '.labels["bercail.space"] // empty')"
hc run --bg --name <issue>-build --cwd <worktree> \
--label bercail.role=bstack-builder --label "bercail.space=$space" --label bercail.issue=<id> \
--label "bercail.issue_title=<title>" \
bstack-builder "<brief>. Write long output to a file. Report with hc send $HC_TERMINAL_ID when done or stuck."
```

Expand Down
13 changes: 11 additions & 2 deletions config/worktrunk/herdr-layout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,20 @@ _wt_herdr_start_desk() {
read -r -a cmd <<<"$WT_HERDR_AGENT_CMD"
argv+=("${cmd[@]}")
fi
# Handoff names the issue on the desk. A default start clears those vars.
local -a issue_labels=()
if [[ -n "${WT_HERDR_ISSUE:-}" ]]; then
issue_labels+=(--label "bercail.issue=${WT_HERDR_ISSUE}")
if [[ -n "${WT_HERDR_ISSUE_TITLE:-}" ]]; then
issue_labels+=(--label "bercail.issue_title=${WT_HERDR_ISSUE_TITLE}")
fi
fi
# hc passes on the caller's environment; a headless caller (Shep) has
# TERM=dumb, which leaves the desk without colors or key handling.
TERM=xterm-256color COLORTERM=truecolor "$HC" run --bg --keep --outlive-parent --no-notify \
--parent "$shell" --cwd "$(_wt_realpath "$workdir")" \
--label bercail.role=agent --label "bercail.space=$space" \
"${issue_labels[@]}" \
"${argv[@]}" >/dev/null
}

Expand All @@ -159,12 +168,12 @@ wt_herdr_layout_create() {
}

# Clear session of the configured agent (d / dev / apply). Ignores any
# inherited handoff prompt.
# inherited handoff prompt or issue labels.
wt_herdr_start_default_agent() {
local label="$1" workdir="$2" space
_wt_herdr_app_running || { echo "Herdr app is not running" >&2; return 1; }
space="$(_wt_herdr_resolve_space "$label" "$workdir")" || return 1
WT_HERDR_AGENT_CMD= _wt_herdr_start_desk "$space" "$workdir"
WT_HERDR_AGENT_CMD= WT_HERDR_ISSUE= WT_HERDR_ISSUE_TITLE= _wt_herdr_start_desk "$space" "$workdir"
}

# Orchestrator / handoff-spawn: start or replace the desk agent with
Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and b

Herdr brings back spaces, tabs, and each desk's terminal, but as a bare shell: it never reruns a program. So `bercail-desk-agent` writes down what ran there, and the desk comes back on the same session in the same space, with no prompt sent again.

- **The record.** `~/.local/state/bercail/desk-sessions/<address>.json` holds `session`, `cwd`, `harness`, `issue`, `space`, `args` (resume-safe flags) and `pid`, keyed by the terminal's address. A local opencode TUI plugin records the selected session, even while idle and with bstack off; on a Pi desk the `bercail-desk` extension records Pi's session the same way. A watcher touches the file every minute while the desk lives. Claude's session comes from a `SessionStart` hook. Headless runs get no record.
- **The record.** `~/.local/state/bercail/desk-sessions/<address>.json` holds `session`, `cwd`, `harness`, `issue`, `issue_title` (empty when unknown), `space`, `args` (resume-safe flags) and `pid`, keyed by the terminal's address. A local opencode TUI plugin records the selected session, even while idle and with bstack off; on a Pi desk the `bercail-desk` extension records Pi's session the same way. A watcher touches the file every minute while the desk lives. Claude's session comes from a `SessionStart` hook. Headless runs get no record.
- **What comes back.** A record fresh within ten minutes of shutdown (the last journal line of the previous boot), on a desk at a bare shell, in a folder that still exists, whose session is not already open. Records written since shutdown also qualify for a manual restore after a core stop/start. A desk you quit has finished, so it stays quit. Older records stay shells.
- **Duplicate checks.** Restore checks process arguments, all live Herdr terminals' status records, and the opencode plugin's and Pi extension's runtime claims in `$XDG_RUNTIME_DIR/bercail/open-sessions/`. Claims include idle sessions and tabs, and count only while their original process is alive. TUIs started before the plugin was installed may have no claim: check those manually before restoring.
- **When.** Once per boot, through Omarchy's post-boot hook or else a systemd user unit: it waits until you open Herdr, then a notification says how many came back. Omarchy logs to `~/.local/state/bercail/desk-restore.log`; systemd logs to `journalctl --user -u bercail-desk-restore`. After a core restart, or to run it yourself, use `bercail desk restore`; `--dry-run` lists each desk and why.
Expand Down
6 changes: 3 additions & 3 deletions docs/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot)

`--base BRANCH` cuts the desk branch from BRANCH instead of the default branch, so a dependent issue's desk stacks on its parent's branch. The [git backend](#git-backend) registers that parent with `gh stack`, `gt track`, or `origin stack track`. It works only with `--branch`, and stage `autopilot` can't take `--branch`: give it `--worktree` on the PR's checkout.

**Prompt.** One line pointing at the issue, the same for every harness: ``Your job is beads issue <id>. Run `bd show <id>` (read its comments too) and do the work it describes.``
**Prompt.** One line pointing at the issue, the same for every harness: ``Your job is beads issue <id>: <title>. Run `bd show <id>` (read its comments too) and do the work it describes.`` The title is the issue's, truncated to 60 characters. When bd has none, the sentence keeps the id only: ``Your job is beads issue <id>. Run `bd show <id>` (read its comments too) and do the work it describes.``

**Context.** Before it starts the agent, bercail adds a `bd comment` to the issue with the worktree and, headless, the result file and session id. A worktree's `.beads/redirect` points `bd` at the main checkout's database, so every comment lands in one store.

**Result.** A headless run leaves its CLI log in `<worktree>/.bercail/start.log` and `{status: ready|blocked|failed, summary, session_id}`, plus an optional `verification` array of `{kind: test|verify-skill|statechart, command, exit_code, evidence}` from `/ready` (a malformed array is dropped; the rest of the result stands). A plan usage limit also sets `reason` to `usage-limit`, so Shep can propose another plan. Bercail does not switch or rerun. Read that file, not terminal output. `--resume SESSION` continues the same session (`claude --resume`, `codex exec resume`, `cursor-agent --resume`, `opencode run --session`).
**Result.** A headless run leaves its CLI log in `<worktree>/.bercail/start.log` and `{status: ready|blocked|failed, summary, session_id}`, plus `issue` and, when known, `issue_title`, and an optional `verification` array of `{kind: test|verify-skill|statechart, command, exit_code, evidence}` from `/ready` (a malformed array is dropped; the rest of the result stands). A plan usage limit also sets `reason` to `usage-limit`, so Shep can propose another plan. Bercail does not switch or rerun. Read that file, not terminal output. `--resume SESSION` continues the same session (`claude --resume`, `codex exec resume`, `cursor-agent --resume`, `opencode run --session`).

**Blocked, not switched.** Headless, if the stage's binary, `bd`, or the issue is missing, the result is `blocked` and nothing starts. With `--branch`, `handoff-spawn` exits 1 before it makes a worktree. Bercail never falls through to another harness. `--binary claude|codex|cursor-agent|opencode` runs a headless stage on another CLI only when you ask (with `opencode`, `start` stays on Claude and `codex` on GPT, through your opencode plans; stage `autopilot` takes only `opencode`).

**Dry run.** `--dry-run` prints the exact argv for any stage, even when that binary is not installed.

**Which harnesses?** `bercail harness` reports which of claude, codex, cursor-agent, and opencode are on PATH, with a local model hint. It does not choose.

Each `--branch` spawn is logged in `${XDG_STATE_HOME:-~/.local/state}/agentic-dev/handoffs.jsonl`; headless runs are not logged there. Follow a `--branch` desk through [agent status webhooks](shep.md#agent-status-webhooks) or with `hc wait <desk>`; `desk` and `space` (and `base`, when set) are in the spawn JSON.
Each `--branch` spawn is logged in `${XDG_STATE_HOME:-~/.local/state}/agentic-dev/handoffs.jsonl`; headless runs are not logged there. Follow a `--branch` desk through [agent status webhooks](shep.md#agent-status-webhooks) or with `hc wait <desk>`; `desk`, `space`, and, when known, `issue_title` (and `base`, when set) are in the spawn JSON.

## Git backend

Expand Down
7 changes: 4 additions & 3 deletions docs/shep.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ Each POST body is `bercail.agent_status/v2`:
"at":"2026-10-29T22:42:00Z","event_id":"t9:finished:1793312520:4242",
"agent":{"address":"t9","app":"claude-code","status":"done","previous_status":"working","title":"..."},
"space":{"id":"sp98de","name":"Render-preview_Leftlane_app","repo":"leftlane_app","checkout_path":"/path/desk","linked":true},
"desk":{"branch":"feat/render","issue":"lef2745-2vs","stage":"start","harness":"claude",
"result":{"status":"ready","summary":"...","session_id":"..."}}}
"desk":{"branch":"feat/render","issue":"lef2745-2vs","issue_title":"Fix the login timeout","stage":"start","harness":"claude",
"result":{"status":"ready","summary":"...","session_id":"..."}},
"text":"lef2745-2vs: Fix the login timeout. Desk t9 (Render-preview_Leftlane_app) finished."}
```

`agent.address` is the hc terminal (`hc get`/`hc read` it). `desk.issue`, `stage`, and `harness` come from the matching `handoffs.jsonl` record (`harness` falls back to `agent.app`). `desk.result` is `status`, `summary` (its first 500 characters), and `session_id` from the checkout's `.bercail/handoff-result.json` when one exists (usually `null`: headless runs have no desk terminal, so they send nothing).
`agent.address` is the hc terminal (`hc get`/`hc read` it). `desk.issue`, `desk.issue_title`, `stage`, and `harness` come from the matching `handoffs.jsonl` record (`harness` falls back to `agent.app`; `issue_title` is null when that record has none). `desk.result` is `status`, `summary` (its first 500 characters), and `session_id` from the checkout's `.bercail/handoff-result.json` when one exists (usually `null`: headless runs have no desk terminal, so they send nothing). `text` is one line for a person: `<id>: <title>. Desk <address> (<space name>) finished.` or `is blocked.`; the bare id when there is no title, no parentheses when the space has no name, and `Test from bercail (sink <name>).` for a test post.
16 changes: 10 additions & 6 deletions lib/desk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ _desk_confirm_resume() {
# 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
local dry="$1" facts plan shutdown row address action why record what confirm named tag
DESK_RESUMED=0 DESK_WAITING=0
facts="$(_desk_gather)" || { warn "hc did not answer: desks left alone"; return 1; }
shutdown="$(_desk_shutdown_time)"
Expand All @@ -219,9 +219,13 @@ _desk_restore_pass() {
action="$(jq -r .action <<<"$row")"
why="$(jq -r .why <<<"$row")"
record="$(jq -c .record <<<"$row")"
# The desk's issue as "id: title", or the bare id, so an address reads as a job.
named="$(jq -r '(.issue // "") as $i | (.issue_title // "") as $t
| if $i != "" and $t != "" then "\($i): \($t)" else $i end' <<<"$record")"
tag="${named:+ ($named)}"
case "$action" in
resume)
what="$(jq -r '[.issue, .session, .cwd] | map(select(. != "")) | join(" ")' <<<"$record")"
what="$(jq -r --arg named "$named" '[$named, .session, .cwd] | map(select(. != "")) | join(" ")' <<<"$record")"
if [[ "$dry" -eq 1 ]]; then
printf '%-6s would resume %s\n' "$address" "$what"
DESK_RESUMED=$((DESK_RESUMED + 1))
Expand All @@ -232,9 +236,9 @@ _desk_restore_pass() {
why="$(jq -r .why <<<"$confirm")"
if [[ "$action" == wait ]]; then
DESK_WAITING=$((DESK_WAITING + 1))
printf '%-6s waiting: %s\n' "$address" "$why"
printf '%-6s waiting: %s%s\n' "$address" "$why" "$tag"
else
printf '%-6s skip: %s\n' "$address" "$why"
printf '%-6s skip: %s%s\n' "$address" "$why" "$tag"
fi
elif _desk_hc send "$address" "$(desk_resume_command "$record")" >/dev/null; then
DESK_TYPED["$address"]=1
Expand All @@ -248,12 +252,12 @@ _desk_restore_pass() {
;;
wait)
DESK_WAITING=$((DESK_WAITING + 1))
printf '%-6s waiting: %s\n' "$address" "$why"
printf '%-6s waiting: %s%s\n' "$address" "$why" "$tag"
;;
gone)
[[ "$dry" -eq 1 ]] || rm -f "$(_desk_sessions_dir)/$address.json"
;;
*) printf '%-6s skip: %s\n' "$address" "$why" ;;
*) printf '%-6s skip: %s%s\n' "$address" "$why" "$tag" ;;
esac
done < <(jq -c '.[]' <<<"$plan")
}
Expand Down
16 changes: 12 additions & 4 deletions lib/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ doctor_herdr_app_update() {
# started, later than the last transition the follower recorded, is one it
# didn't see. A follower without a record runs a bercail older than this check.
doctor_notify_missed() {
local f started last address ms
local f started last address ms term issue title
f="$(notify_follower_file)"
started="$(jq -r '.started_at // empty' "$f" 2>/dev/null)"
if [[ ! "$started" =~ ^[0-9]+$ ]]; then
Expand All @@ -98,12 +98,20 @@ doctor_notify_missed() {
[[ "$last" =~ ^[0-9]+$ ]] || last=0
while IFS= read -r address; do
[[ -n "$address" ]] || continue
ms="$("$(notify_hc)" get "$address" --json 2>/dev/null </dev/null \
| jq -r 'select(.status.state == "done" or .status.state == "blocked") | .status_ms // empty' 2>/dev/null)"
term="$("$(notify_hc)" get "$address" --json 2>/dev/null </dev/null)" || continue
ms="$(jq -r 'select(.status.state == "done" or .status.state == "blocked") | .status_ms // empty' <<<"$term" 2>/dev/null)" || true
[[ "$ms" =~ ^[0-9]+$ ]] || continue
# Slack: the follower records a transition a moment after hc stamps it.
if (( ms / 1000 > started + 5 && ms / 1000 > last + 60 )); then
printf 'missed desk %s finishing or blocking' "$address"
issue="$(jq -r '.labels["bercail.issue"] // empty' <<<"$term" 2>/dev/null)" || issue=""
title="$(jq -r '.labels["bercail.issue_title"] // empty' <<<"$term" 2>/dev/null)" || title=""
if [[ -n "$issue" && -n "$title" ]]; then
printf 'missed desk %s (%s: %s) finishing or blocking' "$address" "$issue" "$title"
elif [[ -n "$issue" ]]; then
printf 'missed desk %s (%s) finishing or blocking' "$address" "$issue"
else
printf 'missed desk %s finishing or blocking' "$address"
fi
return 1
fi
done < <("$(notify_hc)" ls --json --label bercail.role=agent 2>/dev/null </dev/null \
Expand Down
Loading
Loading