Skip to content
Open
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
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

- **A long Codex turn stays working until it ends.** Codex's `notify` comes
at the end of each of its messages, not only the turn's, so a turn read as
finished after its first message. Once a session's Codex hooks have said a
prompt started, only its `Stop` hook ends the turn; Codex versions without
hooks still finish with `notify`. `berthd integrations install codex` (and
an upgrade) also adds `PreToolUse` and `PostToolUse` hooks: a question in
Codex's question box shows as needs you, and answering it, or Codex carrying
on by itself after a turn ended, shows it working again. If Codex asks,
trust the new hooks with `/hooks`.

## v0.3.21

- **The phone app pairs from a pasted link.** Its Pair and Boxes screens have
Expand Down
12 changes: 8 additions & 4 deletions docs/guides/agent-integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ with the command that installs them.
`~/.cursor/hooks.json`, after any hooks already there (Orca's are kept).
- **Codex**: installs Shipyard's skills in `~/.agents/skills/`, where Codex
reads user skills (a copy an older version left in `~/.codex/skills/` is
removed), adds `SessionStart` / `UserPromptSubmit` / `PermissionRequest` /
`Stop` hooks to `~/.codex/hooks.json` (trust them once in Codex with
`/hooks`), and a `notify` line to `~/.codex/config.toml` for versions
without hooks. Codex runs a single notify program, so when the config
removed), adds `SessionStart` / `UserPromptSubmit` / `PreToolUse` /
`PostToolUse` / `PermissionRequest` / `Stop` hooks to `~/.codex/hooks.json`
(trust them once in Codex with `/hooks`), and a `notify` line to
`~/.codex/config.toml` for versions without hooks. Codex's question box is
a tool, so its `PreToolUse` means "needs you" (never with the questions).
Notify comes at the end of each of Codex's messages, not only the turn's:
once a session's hooks have said a prompt started, its `Stop` hook alone
ends the turn. Codex runs a single notify program, so when the config
already sets one to something else, Shipyard leaves it and prints the line to
change yourself.
- **Gemini CLI**: `SessionStart` / `BeforeAgent` / `Notification` /
Expand Down
13 changes: 8 additions & 5 deletions docs/reference/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ and dashes.
| Event | Data |
| --- | --- |
| `agent.ready` | `path`, `agent`, `session` (when the hook knows it) and the tool's own id (below): a new agent at its prompt. After a finished turn [turn check](/concepts/sessions-and-agents#agent-state) judged unfinished but not waiting on you, with `source: "turncheck"`, `choice` and `seq`: the session stays `finished`, with `unfinished: true` for `choice: "working"` (something still runs) or `parked: true` for `choice: "parked"` (it waits on a reply or a later time); neither notifies |
| `agent.started` | the same, with `signal`: `prompt` (working on a prompt) or `tool` (working again after an approval). For an agent that cannot say it started, Shipyard sends it at the send, with `source: "send"` and `turn` |
| `agent.waiting` | the same, with `reason` (`permission`, `question`): needs you. At a startup question Shipyard sends it itself, with `reason: "startup question"`; after a finished turn [turn check](/concepts/sessions-and-agents#agent-state) judged to need you, with `reason: "turn check"`, `source: "turncheck"` and the model's `choice` (`needs_input` or `blocked`) |
| `agent.started` | the same, with `signal`: `prompt` (working on a prompt) or `tool` (working again after an approval or an answer). For an agent that cannot say it started, Shipyard sends it at the send, with `source: "send"` and `turn` |
| `agent.waiting` | the same, with `reason` (`permission`, `question`): needs you. Codex's async question box adds `question_box: "async"`: its turn can end before the answer, and the session keeps waiting until you prompt again. At a startup question Shipyard sends it itself, with `reason: "startup question"`; after a finished turn [turn check](/concepts/sessions-and-agents#agent-state) judged to need you, with `reason: "turn check"`, `source: "turncheck"` and the model's `choice` (`needs_input` or `blocked`) |
| `agent.finished` | the same: done with its turn; `status: "error"` when it ended on a failure |
| `agent.exited` | the same: the agent itself quit |
| `task.created` | `location`, `name`, `path`, `branch`, `session`, `agent`, `from_session` |
Expand All @@ -64,9 +64,12 @@ Cursor `conversation_id` and `status`). A hook names the Shipyard session it
runs in as `session`, from `$BERTH_SESSION`. Hooks spooled while berthd was
down carry `spooled: true` and the time they ran. Shipyard does not publish a
tool use (`agent.started` with `signal: "tool"`) for an agent that is
already working: it would change nothing. When an agent's hooks cannot tell,
Shipyard reads the terminal and sends these itself with `origin: "screen"` and
`source: "screen"`. See [turns](/concepts/sessions-and-agents#turns).
already working: it would change nothing. Nor does it publish Codex's
`notify` (`agent.finished` with `via: "notify"`) once the session's hooks
have said a prompt started: notify comes at the end of each of Codex's
messages, and its `Stop` hook ends the turn. When an agent's hooks cannot
tell, Shipyard reads the terminal and sends these itself with
`origin: "screen"` and `source: "screen"`. See [turns](/concepts/sessions-and-agents#turns).

### Locations and worktrees

Expand Down
50 changes: 47 additions & 3 deletions internal/box/turns.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ type sessTrack struct {
// they said a prompt started, so its sends can wait for that.
Hooked bool `json:"hooked,omitempty"`
SawStart bool `json:"saw_start,omitempty"`
// AsyncAsk is set while the session waits on Codex's async question
// box, which a turn can end before anyone answers.
AsyncAsk bool `json:"async_ask,omitempty"`
// Prompted is set once the ledger has seen a prompt for the session,
// sent or typed: the first one may name it (see Box.nameAfter).
Prompted bool `json:"prompted,omitempty"`
Expand Down Expand Up @@ -629,6 +632,14 @@ func sameDir(a, b string) bool {

var stateOf = map[string]string{adapters.Ready: "idle", adapters.Started: "running", adapters.Waiting: "waiting", adapters.Finished: "finished", adapters.Exited: "exited"}

// notifyAfterHooks says whether a finished event is Codex's notify for a
// session whose hooks have said a prompt started. Notify comes at the end
// of each of Codex's messages, not only the turn's, so there its Stop hook
// ends the turn. A Codex without hooks still ends turns with notify.
func notifyAfterHooks(s *sessTrack, typ string, data map[string]any) bool {
return typ == adapters.Finished && str(data, "via") == "notify" && s.SawStart
}

func (t *Turns) agentEvent(e events.Event) {
s, ambiguous := t.resolve(e)
if s == nil {
Expand All @@ -644,6 +655,9 @@ func (t *Turns) agentEvent(e events.Event) {
}
return
}
if notifyAfterHooks(s, e.Type, e.Data) {
return
}
source := str(e.Data, "source")
if source == "" {
s.Hooked = true
Expand Down Expand Up @@ -672,6 +686,12 @@ func (t *Turns) agentEvent(e events.Event) {
s.set("idle", e)
}
case adapters.Started:
// A question the agent's own hook reported is answered when the
// agent says so (a tool, a prompt), not when its screen changes.
if source == "screen" && cur != nil && cur.State == "waiting" && lastWait(cur) == "question" {
return
}
s.AsyncAsk = false
signal := str(e.Data, "signal")
switch {
case source == "send":
Expand Down Expand Up @@ -735,8 +755,15 @@ func (t *Turns) agentEvent(e events.Event) {
}
cur.State = "waiting"
}
s.AsyncAsk = str(e.Data, "question_box") == "async"
s.set("waiting", e)
case adapters.Finished:
// A turn that ends on its own async question (Codex's question box
// returns at once, then the turn stops) still waits for the answer.
// A question answered within the turn (a structured driver's) ends
// with it.
asked := cur != nil && cur.State == "waiting" && source == "" && s.AsyncAsk
s.AsyncAsk = asked
if cur != nil {
if source == "screen" || source == "reconcile" {
cur.Fidelity = "screen" // its end was read off the screen
Expand All @@ -748,6 +775,10 @@ func (t *Turns) agentEvent(e events.Event) {
}
}
s.Reconcile = false
if asked {
s.set("waiting", e)
break
}
s.set("finished", e)
t.kickInbox()
case adapters.Exited:
Expand Down Expand Up @@ -831,10 +862,12 @@ func (t *Turns) kickInbox() {
}

// Redundant says whether an agent event would change nothing: a tool use
// while the agent is already working. Those are frequent, so the box does
// not publish them.
// while the agent is already working, which is frequent, or Codex's notify
// once its hooks are heard (notifyAfterHooks). The box does not publish
// them.
func (t *Turns) Redundant(typ string, data map[string]any) bool {
if typ != adapters.Started || str(data, "signal") != "tool" {
tool := typ == adapters.Started && str(data, "signal") == "tool"
if !tool && typ != adapters.Finished {
return false
}
t.mu.Lock()
Expand All @@ -845,6 +878,9 @@ func (t *Turns) Redundant(typ string, data map[string]any) bool {
if s == nil {
return false
}
if !tool {
return notifyAfterHooks(s, typ, data)
}
return s.State == "running"
}

Expand Down Expand Up @@ -1717,3 +1753,11 @@ func (t *Turns) waitSessionState(ctx context.Context, name string, want map[stri
}
}
}

// lastWait is the reason of the turn's latest wait, or "".
func lastWait(tr *Turn) string {
if n := len(tr.Waits); n > 0 {
return tr.Waits[n-1].Reason
}
return ""
}
100 changes: 100 additions & 0 deletions internal/box/turns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,106 @@ func TestAFinishedOnlyAgentWorksFromTheSend(t *testing.T) {
}
}

// Codex's notify comes at the end of each of its messages, not only the
// turn's. Once its hooks said a prompt started, Stop alone ends the turn,
// and the notify is not published; a Codex without hooks still ends its
// turn with notify.
func TestCodexNotifyEndsATurnOnlyWithoutHooks(t *testing.T) {
tr, bus := ledger(t, Session{Name: "cx", Dir: "/w", Agent: "codex"}, Session{Name: "old", Dir: "/v", Agent: "codex"})
hook(bus, "agent.ready", "cx", "/w", "codex")
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt")
if !tr.Redundant("agent.finished", map[string]any{"session": "cx", "via": "notify"}) {
t.Fatal("a hooked Codex's notify would be published")
}
hook(bus, "agent.finished", "cx", "/w", "codex", "via", "notify") // its first message
if st, _ := tr.State("cx"); st.State != "running" || turnState(t, tr, "cx#1") != "running" {
t.Fatalf("after a notify mid-turn: session %+v, turn %s", st, turnState(t, tr, "cx#1"))
}
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool")
hook(bus, "agent.finished", "cx", "/w", "codex") // Stop
if st, _ := tr.State("cx"); st.State != "finished" || turnState(t, tr, "cx#1") != "finished" {
t.Fatalf("after Stop: session %+v, turn %s", st, turnState(t, tr, "cx#1"))
}
if tr.Redundant("agent.finished", map[string]any{"session": "cx"}) {
t.Fatal("Stop would not be published")
}

sent := bus.Publish(events.Event{Type: "session.sent", Data: map[string]any{"name": "old"}})
mine, _ := tr.ForSent("old", sent.Seq)
if tr.Redundant("agent.finished", map[string]any{"session": "old", "via": "notify"}) {
t.Fatal("the notify of a Codex without hooks would not be published")
}
hook(bus, "agent.finished", "old", "/v", "codex", "via", "notify")
if st := turnState(t, tr, mine.ID); st != "finished" {
t.Fatalf("without hooks, notify left the turn %s", st)
}
}

// Codex's question box is a tool: its PreToolUse makes the turn wait, and
// the PostToolUse once it is answered has it running again. Any other tool
// use after a turn ended (Codex carrying on by itself once a background
// command finishes) shows the agent working.
func TestACodexQuestionWaitsUntilItIsAnswered(t *testing.T) {
tr, bus := ledger(t, Session{Name: "cx", Dir: "/w", Agent: "codex"})
hook(bus, "agent.ready", "cx", "/w", "codex")
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt")
hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question") // PreToolUse request_user_input_async
got, _ := tr.Get("cx#1")
if st, _ := tr.State("cx"); st.State != "waiting" || got.State != "waiting" || len(got.Waits) != 1 || got.Waits[0].Reason != "question" {
t.Fatalf("asking: session %+v, turn %+v", st, got)
}
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool") // PostToolUse, answered
got, _ = tr.Get("cx#1")
if st, _ := tr.State("cx"); st.State != "running" || got.State != "running" || got.Waits[0].End.IsZero() {
t.Fatalf("answered: session %+v, turn %+v", st, got)
}
hook(bus, "agent.finished", "cx", "/w", "codex")
if tr.Redundant("agent.started", map[string]any{"session": "cx", "signal": "tool"}) {
t.Fatal("a tool use after the turn ended would not be published")
}
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool") // PreToolUse Bash
if st, _ := tr.State("cx"); st.State != "running" {
t.Fatalf("a tool use after the turn ended left the session %+v", st)
}
if all := tr.List("cx", 10); len(all) != 1 || all[0].State != "finished" {
t.Fatalf("a tool use made or reopened a turn: %+v", all)
}
}

// Codex asks with its async question box and ends its turn: the turn is
// over, but the session still waits for the answer, until you prompt again.
func TestATurnThatEndsOnItsQuestionStillWaits(t *testing.T) {
tr, bus := ledger(t, Session{Name: "cx", Dir: "/w", Agent: "codex"})
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt")
hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question", "question_box", "async")
// The question box redraws the screen; that is not an answer.
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool", "source", "screen")
hook(bus, "agent.finished", "cx", "/w", "codex")
got, _ := tr.Get("cx#1")
if st, _ := tr.State("cx"); st.State != "waiting" || got.State != "finished" {
t.Fatalf("ended on its question: session %+v, turn %+v", st, got)
}
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt")
if st, _ := tr.State("cx"); st.State != "running" {
t.Fatalf("answered: session %+v", st)
}
// Work after the question means it may have been answered: the end is an end.
hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question", "question_box", "async")
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool")
hook(bus, "agent.finished", "cx", "/w", "codex")
if st, _ := tr.State("cx"); st.State != "finished" {
t.Fatalf("worked on after asking: session %+v", st)
}
// A question that blocks the turn (Plan mode's, or a structured
// driver's) is answered within it, so its turn's end is an end.
hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt")
hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question")
hook(bus, "agent.finished", "cx", "/w", "codex")
if st, _ := tr.State("cx"); st.State != "finished" {
t.Fatalf("ended after a blocking question: session %+v", st)
}
}

// E13: an agent finishes while berthd is down. Its hook is spooled, and
// published when berthd is back, so the turn ends; with no spooled hook,
// the open turn is left for the screen to settle.
Expand Down
9 changes: 4 additions & 5 deletions internal/integrations/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,11 @@ func accountHooks(agent, dir string) (hooked, current bool) {
b, _ := os.ReadFile(filepath.Join(dir, "settings.json"))
return bytes.Contains(b, []byte("hook claude Stop")), bytes.Contains(b, []byte("hook claude PostToolUse"))
case "codex":
// hooks.json is this release's; a notify line alone is an older
// berth's, or a Codex without hooks.
// hooks.json with PreToolUse is this release's; one without it, or a
// notify line alone, is an older berth's (or a Codex without hooks).
h, _ := os.ReadFile(filepath.Join(dir, "hooks.json"))
current = bytes.Contains(h, []byte("hook codex Stop"))
if current {
return true, true
if bytes.Contains(h, []byte("hook codex Stop")) {
return true, bytes.Contains(h, []byte("hook codex PreToolUse"))
}
c, _ := os.ReadFile(filepath.Join(dir, "config.toml"))
return bytes.Contains(c, []byte(`"hook", "codex"`)), false
Expand Down
26 changes: 25 additions & 1 deletion internal/integrations/adapters/codex.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package adapters

import "strings"

// Codex reports through its hooks (hooks.json, trusted once with /hooks)
// and, on versions without them, only through notify when a turn ends.
// and, on versions without them, only through notify. Notify comes at the
// end of each of its messages, not only the turn's: once a session's hooks
// are heard, the ledger leaves the end to Stop.
var Codex = register(&Adapter{
Name: "codex",
Caps: Caps{Ready: true, Started: true, Waiting: true, Finished: true, FinalMessage: true, Via: "hooks"},
Expand All @@ -23,7 +27,27 @@ var Codex = register(&Adapter{
// after it, and drops it before the event is published.
d["title"] = Title(in.Str("prompt"))
return Started, d, true
case "PreToolUse":
// Codex's question box is a tool. Only that it asks is kept,
// never the questions.
if tool := in.Str("tool_name"); strings.HasPrefix(tool, "request_user_input") {
d["reason"] = "question"
if strings.HasPrefix(tool, "request_user_input_async") {
// It returns at once and the turn may end before
// anyone answers (the ledger keeps that wait).
d["question_box"] = "async"
}
return Waiting, d, true
}
d["signal"] = "tool"
return Started, d, true
case "PostToolUse":
// The async question box returns at once, before anyone
// answers; the wait holds until the turn ends.
if strings.HasPrefix(in.Str("tool_name"), "request_user_input_async") {
return "", nil, false
}
// Working again: a tool ran, or the question was answered.
d["signal"] = "tool"
return Started, d, true
case "PermissionRequest":
Expand Down
4 changes: 3 additions & 1 deletion internal/integrations/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ func installCodexHooks(f files, rel, bin string) (bool, error) {
return editJSON(f, rel, func(root map[string]any) bool {
hooks := object(root, "hooks")
changed := false
for _, event := range []string{"SessionStart", "UserPromptSubmit", "PermissionRequest", "Stop"} {
// PreToolUse says when Codex asks a question (its question box is a
// tool); it and PostToolUse mark the agent busy again after one.
for _, event := range []string{"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"} {
if addNested(hooks, event, hookCommand(bin, "codex", event)) {
changed = true
}
Expand Down
31 changes: 31 additions & 0 deletions internal/integrations/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,37 @@ func TestQuotedBinaryPaths(t *testing.T) {
}
}

// Codex's question box and tool uses come through PreToolUse and
// PostToolUse. A hooks.json from an older berth, without them, reads as
// outdated so an upgrade adds them: once, after the hooks already there.
func TestCodexToolHooksAreAddedToAnOlderInstallOnce(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")
os.WriteFile(path, []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/opt/berthd hook codex Stop"}]}],"PreToolUse":[{"hooks":[{"type":"command","command":"my-guard"}]}]}}`), 0o644)
if hooked, current := accountHooks("codex", dir); !hooked || current {
t.Fatalf("older hooks: hooked %v current %v", hooked, current)
}
if changed, err := InstallCodexHooks(path, "/opt/berthd"); !changed || err != nil {
t.Fatalf("install: %v changed=%v", err, changed)
}
if changed, _ := InstallCodexHooks(path, "/opt/berthd"); changed {
t.Fatal("a second install changed the file again")
}
hooks := readJSON(t, path)["hooks"].(map[string]any)
for event, n := range map[string]int{"SessionStart": 1, "UserPromptSubmit": 1, "PreToolUse": 2, "PostToolUse": 1, "PermissionRequest": 1, "Stop": 1} {
if list, _ := hooks[event].([]any); len(list) != n {
t.Errorf("%s hooks = %v", event, list)
}
}
pre := hooks["PreToolUse"].([]any)
if !containsCommand(pre[:1], "my-guard") || !containsCommand(pre[1:], "/opt/berthd hook codex PreToolUse") {
t.Fatalf("PreToolUse hooks = %v", pre)
}
if hooked, current := accountHooks("codex", dir); !hooked || !current {
t.Fatalf("after install: hooked %v current %v", hooked, current)
}
}

func TestNewAgentHooksInstallOnceAndKeepOtherSettings(t *testing.T) {
dir := t.TempDir()
gem := filepath.Join(dir, "settings.json")
Expand Down
Loading