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
21 changes: 12 additions & 9 deletions internal/box/acpdriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ func (c *acpConv) follow() {
c.mu.Lock()
from, done := c.lastSeq, c.exited
c.mu.Unlock()
if done {
if done && !hostAlive(c.sock) {
// Its last word was its end, and no host has started since. (A
// host started again listens a moment before it says so.)
return
}
err := agenthost.Attach(context.Background(), c.sock, from, func(e agenthost.Entry) error {
Expand Down Expand Up @@ -341,7 +343,9 @@ func (c *acpConv) apply(e agenthost.Entry, live bool) {
case m.Method != "" && len(m.ID) > 0:
c.sent[string(m.ID)] = m.Method
case m.Method == "" && len(m.ID) > 0:
c.drop(string(m.ID))
if c.drop(string(m.ID)) && live {
say = append(say, func() { c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) })
}
}
return
case "out":
Expand Down Expand Up @@ -519,13 +523,14 @@ func (c *acpConv) readSession(raw json.RawMessage) {
}
}

func (c *acpConv) drop(id string) {
func (c *acpConv) drop(id string) bool {
for i, r := range c.pending {
if string(r.ID) == id {
c.pending = append(c.pending[:i], c.pending[i+1:]...)
return
return true
}
}
return false
}

// ask is what a permission asks, as a hook's ask is.
Expand Down Expand Up @@ -736,11 +741,9 @@ func (c *acpConv) choose(r acpRequest, optionID string) error {
if optionID == "" {
result = map[string]any{"outcome": map[string]any{"outcome": "cancelled"}}
}
err := c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": result})
if err == nil {
c.event(adapters.Started, map[string]any{"signal": "tool"}, nil)
}
return err
// Working again is said when the answer is read back from the log
// (apply), before anything the agent says after it.
return c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": result})
}

// allow says yes to a permission, for this time or always.
Expand Down
36 changes: 26 additions & 10 deletions internal/box/claudedriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ func (c *claudeConv) follow() {
c.mu.Lock()
from, done := c.lastSeq, c.exited
c.mu.Unlock()
if done {
if done && !hostAlive(c.sock) {
// Its last word was its end, and no host has started since. (A
// host started again listens a moment before it says so.)
return
}
err := agenthost.Attach(context.Background(), c.sock, from, func(e agenthost.Entry) error {
Expand Down Expand Up @@ -414,7 +416,10 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) {
case "in":
var l streamLine
if json.Unmarshal(e.Data, &l) == nil && l.Type == "control_response" && l.Response != nil {
c.drop(l.Response.RequestID)
if c.drop(l.Response.RequestID) && live {
// Answered: the tool runs, or the agent goes on without it.
say = append(say, func() { c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) })
}
}
return
case "out":
Expand Down Expand Up @@ -527,15 +532,16 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) {
}
}

// drop forgets a permission that was answered or withdrawn. Called with
// c.mu held.
func (c *claudeConv) drop(id string) {
// drop forgets a permission that was answered or withdrawn, and says
// whether it was waiting. Called with c.mu held.
func (c *claudeConv) drop(id string) bool {
for i, r := range c.pending {
if r.ID == id {
c.pending = append(c.pending[:i], c.pending[i+1:]...)
return
return true
}
}
return false
}

// reconcile tells the ledger what the log says now, after a restart read
Expand Down Expand Up @@ -738,10 +744,8 @@ func (c *claudeConv) respond(r claudeRequest, resp map[string]any) error {
err := c.write(context.Background(), map[string]any{"type": "control_response", "response": map[string]any{
"subtype": "success", "request_id": r.ID, "response": resp,
}})
if err == nil {
// The tool runs, or the agent goes on without it: working again.
c.event(adapters.Started, map[string]any{"signal": "tool"}, nil)
}
// Working again is said when the answer is read back from the log
// (apply), before anything the agent says after it.
return err
}

Expand Down Expand Up @@ -1057,6 +1061,18 @@ func (b *Box) forgetConversation(name string) {
b.acp().forget(name)
}

// hostAlive says whether a host answers on sock.
func hostAlive(sock string) bool {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
c, err := agenthost.Dial(ctx, sock)
if err != nil {
return false
}
c.Close()
return true
}

// awaitHost waits up to d for a session's host to listen.
func awaitHost(sock string, d time.Duration) bool {
deadline := time.Now().Add(d)
Expand Down
4 changes: 3 additions & 1 deletion internal/box/codexdriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ func (c *codexConv) follow() {
c.mu.Lock()
from, done := c.lastSeq, c.exited
c.mu.Unlock()
if done {
if done && !hostAlive(c.sock) {
// Its last word was its end, and no host has started since. (A
// host started again listens a moment before it says so.)
return
}
err := agenthost.Attach(context.Background(), c.sock, from, func(e agenthost.Entry) error {
Expand Down
17 changes: 10 additions & 7 deletions internal/box/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ import (
// back (Session.Switch "chat").

// labelHandoff is where Claude Code's transcript stood when the session
// went to its terminal: the file, then its size. What it says past that
// was said in the terminal.
// went to its terminal: its size, a space, then the file. What it says past
// that was said in the terminal. (Printable only: tmux on Linux shows a
// control character in an option escaped.)
const labelHandoff = "@berth_handoff"

// SwitchRequest moves a session to its terminal or back to its chat.
Expand Down Expand Up @@ -126,7 +127,7 @@ func (b *Box) toTerminal(r *http.Request, sess Session) error {
handoff := ""
if _, path, _ := b.transcriptFile(ctx, sess, nil); path != "" {
if st, err := os.Stat(path); err == nil {
handoff = path + "\x1f" + strconv.FormatInt(st.Size(), 10)
handoff = strconv.FormatInt(st.Size(), 10) + " " + path
}
}
stopCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
Expand Down Expand Up @@ -198,7 +199,7 @@ func (b *Box) importTerminalTurns(ctx context.Context, sess Session) error {
if err != nil {
return nil
}
path, off, ok := strings.Cut(strings.TrimSpace(string(v)), "\x1f")
off, path, ok := strings.Cut(strings.TrimSpace(string(v)), " ")
from, perr := strconv.ParseInt(off, 10, 64)
if !ok || perr != nil || path == "" {
return nil
Expand Down Expand Up @@ -245,19 +246,21 @@ func lastSessionID(path string) string {

// respawnAgent runs command in the session's pane in place of what ran
// there, with env added to the session's own and labels set, as create
// starts one (sessions.go).
// starts one (sessions.go): with its agent's CLI on PATH, which a login
// shell may otherwise lose (Debian's resets it).
func (b *Box) respawnAgent(ctx context.Context, sess Session, command string, env []string, labels map[string]string) error {
agent := firstNonEmpty(agentFor(sess), "claude")
s := b.Sessions
extra, wrap := b.sessionEnv(ctx, sess.Dir)
file, err := s.writeCommand(sess.Name, command)
if err != nil {
return err
}
shell := sessionShell()
argv := []string{shell, "-lc", withPATH(shell, launchPATH(command, "claude"), sourceCommand(shell, file))}
argv := []string{shell, "-lc", withPATH(shell, launchPATH(command, agent), sourceCommand(shell, file))}
target := "=" + sess.Name + ":"
args := []string{"respawn-pane", "-k", "-t", target, "-c", sess.Dir}
for _, kv := range append(append(withAgentBrowserIdle(extra), "BERTH_SESSION="+sess.Name, "BERTH_AGENT=claude"), env...) {
for _, kv := range append(append(withAgentBrowserIdle(extra), "BERTH_SESSION="+sess.Name, "BERTH_AGENT="+agent), env...) {
args = append(args, "-e", kv)
}
args = append(append(args, "--"), groups.Wrap(append(append([]string(nil), wrap...), argv...))...)
Expand Down
Loading