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
7 changes: 7 additions & 0 deletions cmd/berthd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"text/tabwriter"
"time"

"github.com/cosscom/shipyard/internal/agenthost"
"github.com/cosscom/shipyard/internal/box"
"github.com/cosscom/shipyard/internal/boxcmd"
"github.com/cosscom/shipyard/internal/debugserver"
Expand Down Expand Up @@ -205,6 +206,12 @@ func run(args []string) error {
return nil
case "headless":
return box.RunHeadless(args[1:], os.Stdout)
case "agent-host":
code, err := agenthost.Main(args[1:], os.Stdout)
if err != nil {
return err
}
os.Exit(code)
case "mcp":
return mcpserver.Serve(context.Background(), b.socket(), os.Stdin, os.Stdout)
case "agents":
Expand Down
7 changes: 4 additions & 3 deletions design/structured-agents/plan.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Structured agents: plan

Status: 2026-10-13. Phase 0 (spike) done: go. Phase 1 (driver interface) in progress. The spike's recordings join the repo as test fixtures in phase 3, once reviewed.
Status: 2026-10-13. Phase 0 (spike) done: go. Phase 1 (driver interface) merged. Phase 2 (agent-host + log) in review. The spike's recordings join the repo as test fixtures in phase 3, once reviewed.

## Why

Expand Down Expand Up @@ -90,8 +90,9 @@ Exit: a go/no-go note per item; any no-go changes the phases below.
- Keep streamed partial text in the log (a killed agent loses it). Stop =
interrupt, then SIGTERM, then kill the process tree (SIGKILL leaves Bash
children running).
- SQLite index: conversation id, location/worktree, agent, driver, agent session
id, title, status, cost, timestamps.
- The SQLite index (conversation id, location/worktree, agent, driver, agent
session id, title, status, cost, timestamps) comes with the Claude driver
(phase 3), the first thing that has those to record.
- Push to the app via the events bus (items in the event, not a "changed" ping);
no stat-polling for driver agents.
- Attaching to the tmux session shows a readable live render, so `berth attach` keeps working.
Expand Down
93 changes: 93 additions & 0 deletions internal/agenthost/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package agenthost

import (
"bufio"
"context"
"encoding/json"
"errors"
"net"
"time"
)

// ErrNoHost is a socket no host answers on: the agent has ended (its log
// is still there to read), or never started.
var ErrNoHost = errors.New("agent-host: no host is running")

func dial(ctx context.Context, sock string) (net.Conn, error) {
var d net.Dialer
c, err := d.DialContext(ctx, "unix", sock)
if err != nil {
return nil, ErrNoHost
}
if dl, ok := ctx.Deadline(); ok {
c.SetDeadline(dl)
}
return c, nil
}

func call(ctx context.Context, sock string, req request) (reply, error) {
c, err := dial(ctx, sock)
if err != nil {
return reply{}, err
}
defer c.Close()
if err := json.NewEncoder(c).Encode(req); err != nil {
return reply{}, err
}
var rep reply
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&rep); err != nil {
return reply{}, err
}
if rep.Error != "" {
return rep, errors.New(rep.Error)
}
return rep, nil
}

// Write gives the agent one JSON line on its stdin, and returns the log
// entry it became.
func Write(ctx context.Context, sock string, data json.RawMessage) (int64, error) {
rep, err := call(ctx, sock, request{Op: "write", Data: data})
return rep.Seq, err
}

// Stop ends the agent and waits for it to be gone.
func Stop(ctx context.Context, sock string) error {
_, err := call(ctx, sock, request{Op: "stop"})
return err
}

// Attach calls fn with each log entry after from, then each new one as the
// agent works, until the stream ends (nil), ctx ends or fn fails. The
// stream ends after the agent's "exited" entry; one that ends before it is
// a host that let a reader fall behind go, or went away: attach again from
// the last Seq seen. With no host running it is ErrNoHost: read the log
// file instead.
func Attach(ctx context.Context, sock string, from int64, fn func(Entry) error) error {
c, err := dial(ctx, sock)
if err != nil {
return err
}
defer c.Close()
stop := context.AfterFunc(ctx, func() { c.SetReadDeadline(time.Now()) })
defer stop()
if err := json.NewEncoder(c).Encode(request{Op: "attach", From: from}); err != nil {
return err
}
dec := json.NewDecoder(bufio.NewReaderSize(c, 64<<10))
for {
var e Entry
if err := dec.Decode(&e); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return nil
}
if e.Seq == 0 {
return errors.New("agent-host: the host refused to attach")
}
if err := fn(e); err != nil {
return err
}
}
}
31 changes: 31 additions & 0 deletions internal/agenthost/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package agenthost

import (
"context"
"errors"
"flag"
"io"
"os"
"os/signal"
"syscall"
)

// Main is `berthd agent-host --dir DIR [--socket S] [--cwd W] -- COMMAND...`,
// run by berthd as a tmux session's program: it hosts the agent until it
// ends, and returns its exit code. Closing the session (SIGHUP) or SIGTERM
// stops the agent first.
func Main(args []string, stdout io.Writer) (int, error) {
fs := flag.NewFlagSet("agent-host", flag.ContinueOnError)
dir := fs.String("dir", "", "the conversation's folder (its log, and socket)")
sock := fs.String("socket", "", "where to listen (default DIR/"+SocketName+")")
cwd := fs.String("cwd", "", "where the agent runs (default here)")
if err := fs.Parse(args); err != nil {
return 2, err
}
if *dir == "" || fs.NArg() == 0 {
return 2, errors.New("usage: berthd agent-host --dir DIR [--socket S] [--cwd W] -- COMMAND...")
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT)
defer stop()
return Run(ctx, Options{Dir: *dir, Socket: *sock, WorkDir: *cwd, Command: fs.Args(), Render: stdout, Env: os.Environ()})
}
Loading
Loading