diff --git a/internal/box/acpdriver.go b/internal/box/acpdriver.go index 81d1ee9a..e862423a 100644 --- a/internal/box/acpdriver.go +++ b/internal/box/acpdriver.go @@ -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 { @@ -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": @@ -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. @@ -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. diff --git a/internal/box/claudedriver.go b/internal/box/claudedriver.go index 788a17aa..25fc528d 100644 --- a/internal/box/claudedriver.go +++ b/internal/box/claudedriver.go @@ -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 { @@ -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": @@ -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 @@ -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 } @@ -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) diff --git a/internal/box/codexdriver.go b/internal/box/codexdriver.go index c76a3521..eefc40bc 100644 --- a/internal/box/codexdriver.go +++ b/internal/box/codexdriver.go @@ -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 { diff --git a/internal/box/switch.go b/internal/box/switch.go index dbec759e..c540ddd6 100644 --- a/internal/box/switch.go +++ b/internal/box/switch.go @@ -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. @@ -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) @@ -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 @@ -245,8 +246,10 @@ 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) @@ -254,10 +257,10 @@ func (b *Box) respawnAgent(ctx context.Context, sess Session, command string, en 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...))...)