Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
7906854
feat(terminals): add the terminal-driver axis — registry, record sche…
fujibee Aug 30, 2026
956dd5f
fix(terminals): address co1 review — complete ABI + structural clobbe…
fujibee Aug 30, 2026
a5a5bd4
fix(terminals): align to the v1 scope doc + co1 round-2 (source-failu…
fujibee Aug 31, 2026
9f9bc7b
fix(terminals): align contract to code + prove boot reachability (co1…
fujibee Aug 31, 2026
f2e2a1d
docs(terminals): the override-surface comment now states tl's ruling,…
fujibee Aug 31, 2026
fe4a679
wip(pr2): route despawn through the terminal driver
fujibee Aug 30, 2026
78c1161
wip(pr2): detect reports presence+self-id+reason; resolver split into…
fujibee Aug 31, 2026
e78d66a
fix(terminals): lift errexit around the driver source so bash 3.2 doe…
fujibee Aug 31, 2026
68b912b
fix(terminals): presence != binary, errexit-safe reason read, and a t…
fujibee Sep 1, 2026
031b3c9
feat(terminals): peek.sh and poke.sh — the entry points over the driv…
fujibee Sep 1, 2026
c89ad12
ci(terminals): a checker for status reads errexit already decided, an…
fujibee Sep 1, 2026
546b32c
fix(templates): peek/poke take <team> <name>, not <team> $AGENT <name>
fujibee Sep 1, 2026
08b9c8f
terminals: injective naming key + resolver errexit-leak fix (PR1 roun…
fujibee Sep 1, 2026
441796a
ci(terminals): lower errexit-status-reads baseline 2 -> 1
fujibee Sep 1, 2026
6d9a6a3
feat(terminals): name this pane from join, actas and SessionStart
fujibee Sep 1, 2026
46e96e9
feat(terminals): plain poke points at the type's native channel; peek…
fujibee Sep 1, 2026
c29256c
docs(claude-code): what to do when poke declines with "may offer a na…
fujibee Sep 1, 2026
bb4c020
terminals: herdr live JSON shape, schema-gated resolve, id-producer o…
fujibee Sep 1, 2026
e126feb
terminals: prove the herdr entry SHAPE, not just the array container
fujibee Sep 1, 2026
0fe7385
fix(terminals): naming a pane is not claiming a seat — join writes no…
fujibee Sep 1, 2026
1a39ed8
terminals: absence needs the WHOLE agent set comparable, not one entry
fujibee Sep 1, 2026
c09d6e6
terminals: derive the herdr count AND the lookup from one well-formed…
fujibee Sep 1, 2026
7142e23
fix(ci): the errexit checker was cutting statements at a `;` inside a…
fujibee Sep 1, 2026
182f340
terminals: narrow the herdr pane_id to its measured form (framing-safe)
fujibee Sep 1, 2026
c5f4abf
terminals: decide herdr membership per-entry; a bare pane is not drift
fujibee Sep 1, 2026
3496f92
terminals: decidability names BOTH kinds positively (session entry OR…
fujibee Sep 1, 2026
fb92158
terminals: normalize pane_ok to 0/1 so a missing pane_id is not lost …
fujibee Sep 1, 2026
a2732f6
spawn: add --terminal-driver / AGMSG_TERMINAL_DRIVER placement override
fujibee Sep 1, 2026
bcb49ff
spawn: make the override test's output check enforceable (grep, not n…
fujibee Sep 1, 2026
8d77a45
terminals: herdr spawn fails closed on a non-text pane_id (driver-side)
fujibee Sep 1, 2026
8e750dd
spawn: document --terminal-driver and validate it early (co1)
fujibee Sep 1, 2026
7a95cf8
terminals: one pane-id grammar authority for both resolve and spawn (…
fujibee Sep 2, 2026
e36c8ad
spawn: align the override accept-set to dispatch, route tmux placemen…
fujibee Sep 2, 2026
f28d3b1
terminals: tmux spawn validates the id KIND before returning it (co1)
fujibee Sep 2, 2026
0879675
spawn: route herdr placement via the driver (launcher->driver reroute…
fujibee Sep 2, 2026
711e466
terminals: tmux --split targets the caller's pane, not the active win…
fujibee Sep 2, 2026
d47176e
spawn: drop the now-dead herdr_json_str after the herdr reroute (co1 …
fujibee Sep 2, 2026
a19b2ce
terminals: a tmux split FAILS CLOSED without $TMUX_PANE, never guesse…
fujibee Sep 2, 2026
0270150
despawn: a free lock with a placement record is not "gone" (#625 defe…
fujibee Sep 2, 2026
53baebf
feat(watch): graceful despawn folds the member's own pane through its…
fujibee Sep 2, 2026
4ad32d5
fix(remote): capture resolve-team's status on the assignment, not bar…
fujibee Sep 2, 2026
34f12ea
Revert "fix(remote): capture resolve-team's status on the assignment,…
fujibee Sep 2, 2026
a0cb472
terminals: an unknown/corrupt placement ref fails closed, never defau…
fujibee Sep 2, 2026
1d99002
despawn: --force must CONFIRM the teardown before deleting the record…
fujibee Sep 2, 2026
4726f0d
check(errexit): a backslash-newline is one statement, and a trailing …
fujibee Sep 2, 2026
cdd46df
fix(terminals): actas hands the terminal the identifier the TERMINAL …
fujibee Sep 2, 2026
77034b8
spawn: wire plain through its driver, and don't call a record write a…
fujibee Sep 2, 2026
f5353a6
feat(terminals): poke takes --body-file / --body -, so a body never c…
fujibee Sep 2, 2026
767c134
templates: teach poke's --body-file, and say why send still needs quo…
fujibee Sep 2, 2026
44d7969
terminals: validate the ref's ID, not just its scheme; don't leak pla…
fujibee Sep 2, 2026
4b75dfd
terminals: plain spawn isolates each backend's stdout so only '-' is …
fujibee Sep 2, 2026
ff4d7a1
terminals: herdr peek keeps error bodies off the content channel, and…
fujibee Sep 2, 2026
859c426
terminals: a session-less herdr pane is agent_session:null, not a mis…
fujibee Sep 2, 2026
9330091
fix(herdr): match the MEASURED session-less pane shape in not-among
fujibee Sep 3, 2026
1962062
fix(peek): uniform exit taxonomy, verbatim READ contract, and ref-gua…
fujibee Sep 3, 2026
2aada23
fix(herdr): pin the bare-pane proof to agent_status=done, and unify t…
fujibee Sep 3, 2026
52427c9
fix(poke): same 10/12/13 taxonomy as peek, and let an unsupported rea…
fujibee Sep 4, 2026
16cf6f8
fix(spawn): confirm startup before claiming it, and write the placeme…
fujibee Sep 4, 2026
74eb083
fix(herdr): recognize a session-less pane by STRUCTURE, not a live-ch…
fujibee Sep 4, 2026
d73d3df
feat(herdr): gate the boot on pre-input pane readiness (requirement 1…
fujibee Sep 4, 2026
4c0a027
test(terminals): pin that join names a pane without taking the seat's…
fujibee Sep 4, 2026
283266f
fix(herdr): harden the readiness gate — errexit-safe, fixed bound, ty…
fujibee Sep 4, 2026
77064ff
check(errexit): record the shape axis this file never enumerated (#1034)
fujibee Sep 4, 2026
0f4bfc5
test(terminals): compare the placement record's BYTES, not a stripped…
fujibee Sep 4, 2026
a3ef920
test(terminals): the sibling assertion had the same blind spot, narro…
fujibee Sep 4, 2026
4204c39
test(herdr): complete the pid json_type control across both arms and …
fujibee Sep 4, 2026
d2bd809
fix(spawn): explicit --no-wait reports launched-unconfirmed too (star…
fujibee Sep 4, 2026
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
1 change: 1 addition & 0 deletions .github/errexit-status-reads-baseline
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1
481 changes: 481 additions & 0 deletions .github/scripts/check-errexit-status-reads.sh

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,24 @@ jobs:
- name: No new assertion that cannot fail
run: .github/scripts/check-enforced-assertions.sh

errexit-status-reads:
name: errexit status reads
runs-on: ubuntu-latest
timeout-minutes: 5
# Unconditional for the same reason as `enforced-assertions` above: a job
# that skips on some diffs is a required context that can sit pending. It
# is a static read of `scripts/**/*.sh`.
steps:
- uses: actions/checkout@v4

# Ubuntu's bash is 5.x, and one of the three shapes this looks for is
# fatal ONLY on bash 3.2 (macOS /bin/bash). That is why the check is
# static: running it under one interpreter would miss the shape that
# kills the other. The behaviour was measured on both (see the header);
# what runs here is the count.
- name: No status read that errexit already decided
run: .github/scripts/check-errexit-status-reads.sh

private-names:
name: internal names
runs-on: ubuntu-latest
Expand Down
39 changes: 39 additions & 0 deletions scripts/actas-claim.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ source "$SCRIPT_DIR/lib/actas-lock.sh"
source "$SCRIPT_DIR/lib/resolve-project.sh"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/role-session.sh" # role->session record (#339)
# Terminal registry, for naming this pane after the claim (v1 scope, item 4). The
# errexit lift is not decoration: on bash 3.2 a failure inside a sourced file
# fires THIS script's `set -e`, so `. x || true` would take the script down
# instead of the guard arm. Naming must never be able to fail a claim.
_agmsg_tr_rc=0; _agmsg_tr_e=0
case $- in *e*) _agmsg_tr_e=1 ;; esac
set +e
# shellcheck disable=SC1091
[ -r "$SCRIPT_DIR/lib/terminal-registry.sh" ] && . "$SCRIPT_DIR/lib/terminal-registry.sh"
_agmsg_tr_rc=$?
[ "$_agmsg_tr_e" = 1 ] && set -e
[ "$_agmsg_tr_rc" -eq 0 ] || echo "agmsg: terminal registry unavailable; this pane will not be named" >&2

# Resolve the session's real project root (see #92) before any lookup, so an
# actas issued from a subdir/worktree claims against the registered project
Expand Down Expand Up @@ -97,6 +109,33 @@ while IFS= read -r team; do
agmsg_role_session_record "$team" "$NAME" "$BARE_SID" "$PROJECT_PHYS" "$TYPE" || true
done <<< "$TEAMS"

# Name this pane for the role just claimed, so peek/poke can reach a session a
# human started by hand — not only one `spawn` placed. `|| true` twice over: the
# claim is what the caller is waiting on, and naming must not be able to fail it
# or delay its status line. A terminal that cannot name says so on stderr once.
#
# BARE_SID, not $SESSION_ID. In THIS script $SESSION_ID has been overwritten with
# the normalized composite "<sid>.<pid>" (above), a token that exists only inside
# agmsg; in session-start.sh the identically named variable holds the BARE sid the
# CLI handed the hook, and it passes that. What a terminal knows is the bare one —
# herdr stores exactly it in agent_session.value — so handing over the composite
# asks a question no terminal can answer. It comes back as "cannot identify this
# pane", which reads as a resolution problem and is an identifier mismatch, and
# the `|| true` below means the claim still reports success while the pane goes
# unnamed and unaddressable. watch.sh does the same lookup and was corrected the
# same way (watch.sh:271); this was the remaining site.
#
# Once per claimed team, mirroring the role-session loop above: each (team, role)
# gets its own record, because that pair is what peek/poke resolve by. The
# VISIBLE pane name is whichever team comes last — panes have one name and a role
# in two teams is one pane. Stable, since the order is $TEAMS'.
if declare -F agmsg_terminal_name_self_safe >/dev/null 2>&1; then
while IFS= read -r team; do
[ -z "$team" ] && continue
agmsg_terminal_name_self_safe "$BARE_SID" "$team" "$NAME" "$PROJECT_PHYS" "$TYPE" record || true
done <<< "$TEAMS"
fi

# Start the engine for each claimed team, if one is not already up (#774).
#
# The second of the two trigger points. `actas` is where a session takes on a
Expand Down
57 changes: 56 additions & 1 deletion scripts/delivery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ set -euo pipefail
#
# Usage:
# delivery.sh set <mode> <type> <project_path>
# delivery.sh status [<type> <project_path>]
# delivery.sh status [<type> <project_path> [<session_id>]]
# delivery.sh stop
# delivery.sh restart [<project_path> <type>]
#
Expand Down Expand Up @@ -72,6 +72,8 @@ RUN_DIR="$SKILL_DIR/run"
# command; see lib/shquote.sh for why naive `'$var'` is not enough.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/lib/shquote.sh"
# shellcheck disable=SC1091
. "$SCRIPT_DIR/lib/terminal-registry.sh"
_agmsg_shq() { agmsg_shq "$1"; }

# True (0) iff <cli>'s reported version is >= <min>, compared as MAJOR.MINOR.PATCH.
Expand Down Expand Up @@ -718,9 +720,60 @@ do_set() {
esac
}

# Report which terminal this session resolves to, as three distinguishable
# answers rather than one hopeful line:
#
# terminal: herdr (pane w1:p1) resolved, and nameable/peekable
# terminal: herdr (cannot identify ...) under it, but this pane is unknown
# terminal: unknown the resolver answered for nothing
#
# The middle one is the one worth printing separately: it is the state where
# `name` and `peek` will fail while everything else looks fine, and a status
# that folded it into either neighbour would be the reason nobody could tell.
#
# The session id is optional because delivery.sh is type-generic and each CLI
# names its own session differently. Without one, PLACEMENT still answers
# ("which terminal am I under") — that needs no self-id — and the pane is
# reported as not asked for, not as absent.
print_terminal_status() {
local sid="${1:-}" line name id errf reason

if [ -z "$sid" ]; then
if name="$(agmsg_terminal_resolve_placement "" 2>/dev/null)"; then
echo "terminal: $name (pane not resolved — no session id given)"
else
echo "terminal: unknown"
fi
return 0
fi

errf="$(mktemp "${TMPDIR:-/tmp}/agmsg-status.XXXXXX")" || errf=/dev/null
# resolve_name is fail-closed: present-but-unidentifiable is a non-zero with
# the driver's reason on stderr. Keep that reason — it is the whole content
# of the middle state.
line="$(agmsg_terminal_resolve_name "$sid" 2>"$errf")" || line=""
if [ -n "$line" ]; then
name="${line%% *}"
id="${line#* }"
echo "terminal: $name (pane $id)"
else
reason=""
if [ "$errf" != /dev/null ] && [ -f "$errf" ]; then
reason="$(cat "$errf" 2>/dev/null || true)"
fi
if name="$(agmsg_terminal_resolve_placement "$sid" 2>/dev/null)"; then
echo "terminal: $name (${reason:-cannot identify this pane})"
else
echo "terminal: unknown${reason:+ ($reason)}"
fi
fi
[ "$errf" = /dev/null ] || rm -f "$errf"
}

do_status() {
local TYPE="${1:-}"
local PROJECT="${2:-}"
local SESSION_ID="${3:-}"

# Mode is derived from the project's settings.local.json — there's no
# global mode value. When called without <type> <project>, we can't infer
Expand All @@ -734,6 +787,8 @@ do_status() {
fi

agmsg_delivery_runtime_status "$TYPE" "$PROJECT"

print_terminal_status "$SESSION_ID"
}

kill_all_watchers() {
Expand Down
87 changes: 57 additions & 30 deletions scripts/despawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,31 @@ set -euo pipefail
#
# Default (graceful): send a `ctrl:despawn` control message to <name>. The
# member's watcher (watch.sh) sees it, drops its own role (releasing the actas
# lock) and closes its own tmux pane — ending its CLI. We block until the lock
# is released, up to --timeout (default 30s); on timeout the member didn't
# respond (dead watcher, or a codex member with no Monitor) — re-run with
# --force.
# lock) and folds its OWN pane through the terminal driver named by its placement
# record — so tmux AND herdr members fold themselves (a plain/OS-terminal member
# has no addressable pane, so it drops its role and its window is closed by hand).
# We block until the lock is released, up to --timeout; on timeout the member
# didn't respond (dead watcher, or a monitor=no member with no watcher) — re-run
# with --force. A `free` lock with a placement record is NOT proof the member is
# gone (a monitor=no type never holds one): that reports `needs-force` and KEEPS
# the record, rather than a false `ok` (#625).
#
# --force: skip the message and tear the member down from here using the
# placement recorded at spawn time — kill its tmux pane/window and drop its
# registration. For when the member's watcher can't respond.
# --force: skip the message and tear the member down from here through the
# terminal driver named by the placement record. The teardown must be CONFIRMED
# (the ref resolves to a terminal, the driver loads, and terminal_despawn exits 0)
# BEFORE the record / registration / lock are dropped — an unconfirmed teardown
# keeps all three and reports `status=error`, so the record (the only retry
# authority) is never deleted out from under a pane that is still alive (#625, the
# --force side). For when the member's watcher can't respond.
#
# See #109. Graceful teardown's full pane-close is tmux-only (the member needs a
# tmux pane to close); an OS-terminal member drops its role but its window must
# be closed by hand.
# See #109.

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # actas-lock.sh requires SKILL_DIR
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/actas-lock.sh"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/terminal-registry.sh" # kill via the terminal driver

die() { echo "despawn: $*" >&2; exit 1; }

Expand All @@ -50,33 +58,39 @@ case "$TIMEOUT" in ''|*[!0-9]*) die "--timeout must be a whole number of seconds

SPAWN_REC="$(agmsg_spawn_path "$TEAM" "$NAME")"

# Kill the recorded tmux target. ids are self-describing: %N pane, @N window.
# Tear down the recorded placement through the terminal-driver registry, and PROVE
# it (co1, full-head review). The record ref is <terminal>:<id> or a legacy bare
# %N/@N; an unknown/corrupt ref does NOT resolve (agmsg_terminal_ref_terminal fails
# closed). The teardown counts as confirmed only if the ref resolved, the driver
# loaded, AND terminal_despawn exited 0 — a driver reporting runtime_error/13 (a real
# possibility for tmux and herdr) means the pane may STILL be alive, and the caller
# must keep the record rather than delete the one retry authority. Returns 0 on a
# confirmed teardown, non-zero otherwise (no side effects here beyond the kill call).
kill_recorded_placement() {
[ -f "$SPAWN_REC" ] || return 1
local id _proj _type
local id _proj _type _term _bare
IFS=$'\t' read -r id _proj _type < "$SPAWN_REC"
[ -n "$id" ] || return 1
case "$id" in
herdr:*)
command -v herdr >/dev/null 2>&1 && herdr pane close "${id#herdr:}" 2>/dev/null || true
;;
*)
if command -v tmux >/dev/null 2>&1; then
case "$id" in
%*) tmux kill-pane -t "$id" 2>/dev/null || true ;;
@*) tmux kill-window -t "$id" 2>/dev/null || true ;;
esac
fi
;;
esac
printf '%s\t%s\t%s' "$id" "$_proj" "$_type" # echo back for the caller
_term="$(agmsg_terminal_ref_terminal "$id")" || return 1 # unknown/corrupt ref
_bare="$(agmsg_terminal_ref_id "$id")"
agmsg_terminal_load "$_term" 2>/dev/null || return 1 # driver would not load
terminal_despawn "$_bare" >/dev/null 2>&1 || return 1 # terminal did not confirm
return 0
}

if [ "$FORCE" = "1" ]; then
[ -f "$SPAWN_REC" ] || die "no placement record for '$TEAM/$NAME' — nothing to force (was it launched via 'spawn'? graceful despawn does not need this)"
IFS=$'\t' read -r _id _proj _type < "$SPAWN_REC"
kill_recorded_placement >/dev/null
# Drop the member's registration, and release its (now-stale) lock.
if ! kill_recorded_placement; then
# Teardown NOT confirmed. Keep the record (the only retry authority), the
# registration and the lock, and say so — never claim a forced teardown that did
# not happen (the #625 shape, on the --force side).
echo "despawn: could not confirm '$NAME' was torn down via its placement record ($_id) — the terminal driver did not report the pane closed (unknown/corrupt ref, the driver would not load, or the terminal returned an error). The record is KEPT so you can retry; check the pane manually." >&2
echo "status=error name=$NAME team=$TEAM note=force-teardown-unconfirmed"
exit 1
fi
# Confirmed torn down: NOW drop the registration, release the (stale) lock, and
# delete the record.
if [ -n "${_proj:-}" ] && [ -n "${_type:-}" ]; then
"$SCRIPT_DIR/reset.sh" "$_proj" "$_type" "$NAME" >/dev/null 2>&1 || true
fi
Expand All @@ -91,8 +105,21 @@ fi
state="$(actas_lock_state "$TEAM" "$NAME" "" 2>/dev/null || echo free)"
case "$state" in
free)
echo "despawn: '$NAME' holds no live actas lock — nothing to confirm a teardown against (a codex member has no watcher; a tmux member may already be gone). If a window remains, use --force." >&2
rm -f "$SPAWN_REC" 2>/dev/null || true
# #625: a free actas lock does NOT prove the member is gone. A monitor=no type
# (cursor, codex) never runs a watcher and so NEVER holds a lock; a member whose
# watcher merely died reads identically. So split on the placement record — the
# positive evidence that something was spawned and may still be running.
if [ -f "$SPAWN_REC" ]; then
# A pane/process was placed and is likely still there. Do NOT delete the record
# (--force reads exactly this — deleting it here is what made the advised
# recovery impossible), and do NOT report a teardown we did not perform.
echo "despawn: '$NAME' holds no live actas lock, but a placement record remains — graceful despawn cannot confirm a teardown (a monitor=no member such as cursor/codex never holds a lock; a watcher may have died). Retry with --force to tear it down via the record, which is kept intact." >&2
echo "status=needs-force name=$NAME team=$TEAM note=no-live-lock-recorded"
exit 1
fi
# No placement record: nothing was spawned here to tear down (a hand-joined
# member, or one already gone). The free lock is all there is to act on.
echo "despawn: '$NAME' holds no live actas lock and has no placement record — nothing to tear down here (if a window remains, it was not launched via spawn; close it directly)." >&2
echo "status=ok name=$NAME team=$TEAM note=no-live-lock"
exit 0
;;
Expand Down
Loading
Loading