Skip to content
Draft
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
1 change: 1 addition & 0 deletions .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it |
| Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits; a structured agent's form while its helpers report back, and a reply box that answers a card with nothing to pick | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card`, `chat-structured-ask-live` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box |
| The board's cards for agents in chat: what they ask with its answers, their reply's last lines, the prompt they started with; never the host's frames or "stdio" | [chat-board-cards.md](chat-board-cards.md) | `chat-board-cards`; `app/e2e/dashboard-chat-cards.spec.ts` asserts it |
| A session's own variables: `--env KEY=VALUE` on `task new` and `session new`, `env` on `berth_task_new`; over the box's and the project's, through the agent host; berthd's own names and malformed ones refused | [session-env.md](session-env.md) | `scripts/session-env-live.sh` (a real box, real Claude), then `session-env-live` (`--agent`) |
| Turn check's "working": an hourglass with why in the sidebar, the tab and the rail's card, not a live turn's spinner; a handoff reads as done | [turn-check-indicator.md](turn-check-indicator.md) | `turn-check-indicator`; `app/e2e/sidebar-turn-check.spec.ts` asserts it, `TestTurnCheckLive` asks the real provider |

## Proof and skip reporting
Expand Down
28 changes: 28 additions & 0 deletions .claude/skills/verify-berthd/features/session-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# A session's own variables: `--env KEY=VALUE`

A task or session can be started with variables for it alone: `--env KEY=VALUE` (repeatable) on `berthd task new` and `berthd session new` (and `berth` for a box), `env` in the `POST /v1/tasks` and `POST /v1/sessions` bodies, and `env` (an object) on the `berth_task_new` MCP tool. They reach the agent's process in its terminal and through the agent host (a structured agent), and come over the same names from the box's `env.json` and the project's `env`. They last through a switch between the terminal and the chat: `@berth_env` names them on the tmux session, and `respawnAgent` leaves those names out of what it passes again (the worktree's values and the browser timeout's default), so the session's tmux environment supplies them. berthd's own names (`BERTH_SESSION`, `BERTH_PORT`, `BERTH_PORT_<n>`, the worktree's `BERTH_*`, `BERTH_AGENT`, `BERTH_DRIVER`, and `BERTH_HOME`, `BERTH_USER_DIR`, `BERTH_OP`, which the secret wrapper trusts, …) and malformed ones (no `=`, no name, a name twice, a NUL in a value) are refused before anything is made (`internal/box/sessionenv.go`, `boxcmd` `envFlag`, `mcpserver` `envArg`).

## Sub-features

- `task-env`: `task new --env` sets them for the new worktree's agent.
- `session-env`: `session new --env` sets them for a session in an existing worktree, agent or command.
- `mcp-env`: `berth_task_new` with `env: {…}` does the same from an agent.
- `precedence`: session over project over box; names only one level sets come through.
- `refused`: a name berthd sets, or a malformed `--env`, is an error with a message, and no worktree or session is made.

## How to get to it (user POV)

- From a terminal or an agent on a box: `berthd task new acme/fix --agent claude --prompt … --env TICKET=T-7`.
- The app doesn't set them; it shows the agents started with them like any other.

## Driving it

- Go: `internal/box/sessionenv_test.go` (precedence over a real tmux, refusals with nothing made, a structured agent's pane keeping them across a respawn), `internal/boxcmd` `TestEnvFlags…`/`TestAMalformedEnvFlag…`, `internal/mcpserver` `TestATasksAgentGetsTheVariablesItWasGiven`.
- On a real box, with real Claude Code: `scripts/session-env-live.sh [OUT]` on a dev box of this checkout (`VERIFY_BOX_DIR=/tmp/vb-N scripts/dev-box.sh up`, every `BERTH_*` unset when you are an agent). It sets `ACME_*` in the box's env, the project's and each session's, starts Claude four ways (task new, session new, `berth_task_new` through `berthd mcp`, all structured; session new in its terminal) and has each one's Bash tool write `env | grep ^ACME_` to `OUT/<name>.txt`, which it compares; then the two refusals, checking no session or worktree appeared. Evidence: the four files, each session's transcript, the terminal's screen, `refused-*.txt`.
- The chats in the app: then `node scripts/drive.mjs session-env-live --port $PORT --agent http://127.0.0.1:9378` opens `env-mcp`'s and `env-task`'s chats and asserts the Bash tool's output lines on screen (`scenarios/session-env-live.mjs`).

## Gotchas

- The terminal run needs Claude's folder-trust prompt answered: the script presses Down until `❯ … Yes, I trust` (or `Yes, proceed`) shows, then Enter, and runs Claude with `--dangerously-skip-permissions` so its Bash tool doesn't ask.
- The structured runs need the box's agent default on (`PUT agents/defaults {"structured":true,"mode":"bypassPermissions"}`); the script sets it, so `dev-box.sh`'s terminal default no longer holds after it.
- `env-task`'s worktree has two sessions (its chat and `env-term`'s terminal), so the scenario picks the chat's tab if the terminal opens first.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// session-env-live: the chats of agents started with variables of their own
// (features/session-env.md), on the dev box ../session-env-live.sh left
// them on. Each chat shows Claude Code's Bash tool reading its own
// environment: the session's ACME_REGION and ACME_RUN over the project's
// and the box's, ACME_TICKET the project's over the box's, ACME_ACCOUNT the
// box's.
//
// node drive.mjs session-env-live --port $PORT --agent http://127.0.0.1:9378
//
// The sessions are found on the box by where they run: env-mcp's worktree
// (berth_task_new) and env-task's (berthd task new).

export const live = true;
export const params = { view: "conversation", sidebar: "list" };

const BOX = (process.env.VERIFY_WORKTREE ?? "devq/acme-shop").split("/")[0];
const SAW = {
"env-mcp": ["ACME_ACCOUNT=box", "ACME_REGION=ap-south", "ACME_RUN=mcp-43", "ACME_TICKET=repo"],
"env-task": ["ACME_ACCOUNT=box", "ACME_REGION=eu-north", "ACME_RUN=task run 41", "ACME_TICKET=repo"],
};

export async function run({ page, shot, step, expect, call }) {
const sessions = await call(`/v1/boxes/${BOX}/api/sessions`);
const pane = page.locator("[data-testid=pane]:visible");
const chat = pane.locator("[data-testid=chat]");
for (const [wt, lines] of Object.entries(SAW)) {
const sess = sessions.find((s) => s.location === `acme-shop/${wt}` && s.driver === "claude");
if (!sess) throw new Error(`no chat session in acme-shop/${wt}: ${sessions.map((s) => `${s.name} ${s.location} ${s.driver}`).join(", ")}`);
step(`${wt}: open ${sess.name}'s chat`);
await page.locator(`[data-testid=worktree-row][data-worktree="${BOX}/${wt}"]`).click({ position: { x: 32, y: 12 } });
const tab = pane.getByRole("tab").filter({ hasText: /claude/i });
if (await chat.count() === 0 && await tab.count() > 0) await tab.first().click();
await expect(chat).toBeVisible({ timeout: 30_000 });
for (const line of lines) await expect(chat.getByText(line, { exact: false }).first()).toBeVisible({ timeout: 30_000 });
step(`${wt}: the chat shows ${lines.join(", ")}`);
await shot(`${wt}-chat`);
}
}
102 changes: 102 additions & 0 deletions .claude/skills/verify-berthd/scripts/session-env-live.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# session-env-live.sh [OUT]: a session's own variables (`--env KEY=VALUE`,
# features/session-env.md) on a dev box of this checkout (dev-box.sh up),
# read back from inside Claude Code's Bash tool.
#
# It sets ACME_* at three levels: the box's env.json, the project's box-local
# env and the session's own. Then it starts Claude Code four ways, each
# with its own variables:
# env-task berthd task new --env, structured (the agent host)
# env-session berthd session new --env, structured
# env-mcp the berth_task_new MCP tool with env, structured
# env-term berthd session new --env -- claude …, in its terminal
# and each one's Bash tool writes `env | grep ^ACME_` to OUT/<name>.txt,
# which must read: ACME_ACCOUNT=box (the box's), ACME_TICKET=repo (the
# project's over the box's), ACME_REGION=<the session's>, ACME_RUN=<the
# session's>. Then the refusals: a name berthd sets and a malformed --env,
# neither of which may start anything.
#
# Run it with VERIFY_BOX_DIR as dev-box.sh had it, and every BERTH_*
# variable unset when you are an agent yourself. Its sessions stay for the
# app scenario (session-env-live.mjs) and go with `dev-box.sh down`.
set -euo pipefail
root=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
dir=${VERIFY_BOX_DIR:-/tmp/verify-berthd-box}
out=${1:-/tmp/verify-berthd/session-env-$(date +%Y%m%d-%H%M%S)}
mkdir -p "$out"
[[ -f $dir/berthd.pid ]] || { echo "no dev box in $dir: dev-box.sh up first" >&2; exit 1; }
box() { env BERTH_HOME="$dir/box-home" BERTH_USER_DIR="$dir/box-user" TMUX_TMPDIR="$dir/tmux" "$root/bin/berthd" "$@"; }
tm() { env TMUX_TMPDIR="$dir/tmux" tmux -L berth "$@"; }
eval "$("$(dirname "$0")/dev-box.sh" env)"
api() { curl -sf -X "$1" -H "Authorization: Bearer $VERIFY_BERTH_TOKEN" -H 'content-type: application/json' "http://127.0.0.1:9378/v1/boxes/devq/api/$2" ${3:+-d "$3"}; }
fail() { echo "FAIL: $*" >&2; exit 1; }

echo "== the box's and the project's variables"
api PUT env '{"env":{"ACME_ACCOUNT":"box","ACME_TICKET":"box","ACME_REGION":"box"}}' >/dev/null
api PUT locations/acme-shop/config '{"local":{"env":{"ACME_TICKET":"repo","ACME_REGION":"repo"}}}' >/dev/null
# The app's default: agents in chat, allowed what they ask.
api PUT agents/defaults '{"structured":true,"mode":"bypassPermissions"}' >/dev/null

prompt() {
echo "Run exactly this one command with your Bash tool, nothing else: env | grep '^ACME_' | sort > $out/$1.txt && cat $out/$1.txt -- then reply with its output, verbatim."
}

echo "== berthd task new --env (structured)"
box task new acme-shop/env-task --agent claude --no-notify --env ACME_REGION=eu-north --env "ACME_RUN=task run 41" --prompt "$(prompt env-task)" | tee "$out/cli.txt"

echo "== berthd session new --env (structured)"
box session new acme-shop --name env-session --agent claude --env ACME_REGION=us-east --env ACME_RUN=session-42 --prompt "$(prompt env-session)" | tee -a "$out/cli.txt"

echo "== berth_task_new with env (structured)"
args=$(jq -nc --arg p "$(prompt env-mcp)" '{location:"acme-shop",name:"env-mcp",agent:"claude",prompt:$p,notify:false,env:{ACME_REGION:"ap-south",ACME_RUN:"mcp-43"}}')
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"berth_task_new\",\"arguments\":$args}}" |
box mcp | tail -1 | tee "$out/mcp.json"
jq -e '.result.isError != true' "$out/mcp.json" >/dev/null || fail "berth_task_new: $(cat "$out/mcp.json")"
echo

echo "== berthd session new --env -- claude (its terminal)"
box session new acme-shop/env-task --name env-term --env ACME_REGION=eu-west --env ACME_RUN=term-44 -- claude --dangerously-skip-permissions "$(prompt env-term)" | tee -a "$out/cli.txt"
# Claude Code asks to trust a new folder before it reads the prompt.
for _ in $(seq 90); do
[[ -s $out/env-term.txt ]] && break
screen=$(box session screen env-term 2>/dev/null || true)
if grep -q '❯ .*Yes, I trust\|❯ .*Yes, proceed\|❯ .*Yes, I accept' <<<"$screen"; then
tm send-keys -t env-term Enter
elif grep -qi 'trust this folder\|Bypass Permissions mode' <<<"$screen"; then
tm send-keys -t env-term Down
fi
sleep 2
done

echo "== what each agent's Bash tool saw"
want() { printf 'ACME_ACCOUNT=box\nACME_REGION=%s\nACME_RUN=%s\nACME_TICKET=repo\n' "$1" "$2"; }
check() {
local name=$1 region=$2 run=$3
for _ in $(seq 150); do [[ -s $out/$name.txt ]] && break; sleep 2; done
[[ -s $out/$name.txt ]] || fail "$name wrote nothing to $out/$name.txt in 5 minutes"
diff <(want "$region" "$run") "$out/$name.txt" || fail "$name saw the above"
echo "ok $name: $(tr '\n' ' ' <"$out/$name.txt")"
}
check env-task eu-north "task run 41"
check env-session us-east session-42
check env-mcp ap-south mcp-43
check env-term eu-west term-44
box session screen env-term --history 60 >"$out/env-term.screen.txt" || true
for s in $(box sessions --json | jq -r '.[] | select(.name | test("env-(task|session|mcp)")) | .name'); do
api GET "sessions/$s/transcript" >"$out/$s.transcript.json" || true
done
box sessions --json | jq -r '.[] | select(.name | test("env-")) | "\(.name)\t\(.driver // "terminal")\t\(.location)"' | tee "$out/sessions.tsv"

echo "== refusals: nothing starts"
before=$(box sessions --json | jq length)
if box task new acme-shop/env-refused --agent claude --env BERTH_SESSION=someone-else >"$out/refused-reserved.txt" 2>&1; then fail "BERTH_SESSION was accepted"; fi
grep -q "BERTH_SESSION is set by berthd" "$out/refused-reserved.txt" || fail "refusal said: $(cat "$out/refused-reserved.txt")"
if box session new acme-shop --agent claude --env ACME_REGION >"$out/refused-malformed.txt" 2>&1; then fail "--env with no = was accepted"; fi
grep -q -- '--env takes KEY=VALUE, got "ACME_REGION" with no =' "$out/refused-malformed.txt" || fail "refusal said: $(cat "$out/refused-malformed.txt")"
if box locations --json | jq -e '.[] | select(.name == "acme-shop") | .worktrees[]? | select(.name == "env-refused")' >/dev/null; then fail "a refused task made its worktree"; fi
[[ $(box sessions --json | jq length) == "$before" ]] || fail "a refused request started a session"
cat "$out/refused-reserved.txt" "$out/refused-malformed.txt"
echo "ok: refused, nothing started"
echo "evidence: $out"
12 changes: 12 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ upgrades with `berth upgrade BOX`, or by running the
[install command](/getting-started/add-a-box#with-the-install-command)
again.

## Unreleased

- **Give one agent variables of its own.** `berthd task new` and
`berthd session new` (and `berth` for a box) take `--env KEY=VALUE`, as
many as you like, and the `berth_task_new` tool for agents takes `env`.
The variables reach that session alone, in its terminal or its chat, and
win over the same names from the box's `env.json` or the project's
`env`: a ticket for its hooks, a second Claude Code account
(`CLAUDE_CONFIG_DIR`), a different config for each agent you try. Names
Shipyard sets itself, such as `BERTH_SESSION` or `BERTH_PORT`, and
malformed ones are refused with a message, and nothing is started.

## v0.3.31

- **Worktrees with submodules can be removed.** git refuses to remove any
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/project-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ berth location scripts devl/shop --clear

A box can also give every worktree on it some environment, in
`~/.berth/env.json` (`{"env": {"KEY": "value"}}`), under the project's own.
One agent or terminal can have [variables of its own](/reference/files#a-sessions-own-variables)
over both: `berthd task new … --env KEY=VALUE`.

## Next

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/runs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ JSON:
| --- | --- |
| `berth_sessions` | name, agent, state, turn, where |
| `berth_screen` | the last lines (at most 60) |
| `berth_task_new` | the new session and worktree |
| `berth_task_new` | the new session and worktree; `env` gives its agent [variables of its own](/reference/files#a-sessions-own-variables), such as a different config per agent you start |
| `berth_send` | the turn (held until the agent is idle, by default) |
| `berth_wait_turn` | the state after at most 90 s; if not ended, a cursor to call again with (for short waits) |
| `berth_exec` | a detached run; with `wait` (at most 90 s) its exit code and failing tail |
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/berthd.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ asks the app to open the page as a browser tab beside the agent; the
| --- | --- |
| `berthd sessions [--json]` | List sessions |
| `berthd agents [--json]` | Agent CLIs this box can start |
| `berthd task new LOC/NAME [--agent ID] [--prompt TEXT] [--open split\|tab] [--branch B] [--base REF] [--no-notify] [-- COMMAND...]` | A worktree with an agent (or a command) running in it; from an agent's session, that agent [hears back](/guides/orchestration#hearing-back) when its first turn ends, and the new worktree nests under that agent's |
| `berthd session new LOC[/WORKTREE] [--name N] [--agent ID [--prompt TEXT]] [--open split\|tab] [-- COMMAND...]` | Start an agent or a command (default: a shell) there |
| `berthd task new LOC/NAME [--agent ID] [--prompt TEXT] [--env KEY=VALUE]... [--open split\|tab] [--branch B] [--base REF] [--no-notify] [-- COMMAND...]` | A worktree with an agent (or a command) running in it; from an agent's session, that agent [hears back](/guides/orchestration#hearing-back) when its first turn ends, and the new worktree nests under that agent's. `--env` sets a variable [for this agent only](/reference/files#a-sessions-own-variables) |
| `berthd session new LOC[/WORKTREE] [--name N] [--agent ID [--prompt TEXT]] [--env KEY=VALUE]... [--open split\|tab] [-- COMMAND...]` | Start an agent or a command (default: a shell) there, `--env` as for `task new` |
| `berthd session screen NAME [--history N]` | Print what the session shows |
| `berthd session send NAME TEXT [--when now\|idle] [--force] [--idem KEY] [--no-enter] [--wait [--timeout 30m]] [--no-notify]` | Type a prompt into a session (or hold it until the agent is idle), and wait for its turn; without `--wait`, an agent sending it hears back when the turn ends |
| `berthd session wait NAME [--turn ID] [--for finished,waiting] [--timeout 30m]` | Wait for a turn (from `session send`), or its agent's current one, to end |
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ Who may be reviewed is read from GitHub with this laptop's `gh`, as you. See
| --- | --- |
| `berth sessions BOX [--json]` | List sessions |
| `berth agents BOX [--json]` | Agent CLIs this box can start |
| `berth task new BOX/LOC/NAME [--agent ID] [--prompt TEXT] [--open split\|tab] [--branch B] [--base REF] [-- COMMAND...]` | A worktree with an agent (or a command) running in it |
| `berth session new BOX/LOC[/WORKTREE] [--name N] [--agent ID [--prompt TEXT]] [--open split\|tab] [-- COMMAND...]` | Start an agent or a command (default: a shell) there |
| `berth task new BOX/LOC/NAME [--agent ID] [--prompt TEXT] [--env KEY=VALUE]... [--open split\|tab] [--branch B] [--base REF] [-- COMMAND...]` | A worktree with an agent (or a command) running in it; `--env` sets a variable [for this agent only](/reference/files#a-sessions-own-variables) |
| `berth session new BOX/LOC[/WORKTREE] [--name N] [--agent ID [--prompt TEXT]] [--env KEY=VALUE]... [--open split\|tab] [-- COMMAND...]` | Start an agent or a command (default: a shell) there, `--env` as for `task new` |
| `berth session screen BOX/NAME [--history N]` | Print what the session shows |
| `berth session send BOX/NAME TEXT [--when now\|idle] [--force] [--idem KEY] [--no-enter] [--wait [--timeout 30m]] [--queue]` | Type a prompt into a session (or hold it until the agent is idle), and wait for its turn |
| `berth session wait BOX/NAME [--turn ID] [--for finished,waiting] [--timeout 30m]` | Wait for a turn (from `session send`), or its agent's current one, to end |
Expand Down
Loading