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
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,13 @@ cfg := agentfleet.DefaultConfig()
// cfg.Fleet.LogDir = "/tmp" (default; empty = no log file)
// cfg.TUI.Columns = 3
// cfg.TUI.RefreshRate = 500ms
// cfg.TUI.ShowLogPath = true (render cfg.TUI.LogPath in the log divider)
// cfg.Agent.PTYRows = 24
// cfg.Agent.PTYCols = 220
// cfg.LogFile.Enabled = true
// cfg.LogFile.Path = "" (empty = <cwd>/agentfleet.log)
// cfg.LogFile.MaxBytes = 10MB (0 = never rotate)
// cfg.LogFile.Backups = 5 (0 = truncate, keep no generations)

cfg.Agent = agentfleet.AgentConfigFromTerminal() // read from actual terminal
```
Expand Down Expand Up @@ -276,3 +281,20 @@ ANTHROPIC_API_KEY=sk-... go run ./examples/generate-manager/ --generate "Run 5 c
### Log a session

Set `FleetConfig.LogDir` to a directory path (e.g., `cfg.Fleet.LogDir = "/var/log/agentfleet"`). The Runner writes to `{LogDir}/agentfleet-{task-id}.log`. Set to `""` to disable.

### Persist the process log stream

Session logs (above) record agent PTY traffic. The *process* log stream — whatever the host writes through `slog` — is separate, and `LogFileConfig` covers it:

```go
logFile, err := agentfleet.OpenLogFile(cfg.LogFile) // nil, nil when Enabled is false
defer logFile.Close()

logBuf := agentfleet.NewLogBuffer(500)
cfg.TUI.Log = logBuf
cfg.TUI.LogPath = logFile.Path() // divider renders " Logs (<path>) "

logger := slog.New(slog.NewTextHandler(io.MultiWriter(logBuf, logFile), nil))
```

`*LogFile` is nil-safe on every method, so a disabled log file needs no branching at the call site. Rotation is Unix-style: the live file keeps its name and older generations shift down through `<path>.1` … `<path>.N`.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,45 @@ func main() {
}
```

## Log File

`OpenLogFile` returns a rotating `io.Writer` for the process log stream. Tee it
into whatever handler already feeds the TUI panel and the same lines land on
disk; set `TUI.LogPath` and the panel divider shows where they went.

```go
cfg := agentfleet.DefaultConfig()
cfg.LogFile.Path = "retask.log" // default: <cwd>/agentfleet.log

logFile, err := agentfleet.OpenLogFile(cfg.LogFile)
if err != nil {
return err
}
defer logFile.Close()

logBuf := agentfleet.NewLogBuffer(500)
cfg.TUI.Log = logBuf
cfg.TUI.LogPath = logFile.Path()

logger := slog.New(slog.NewTextHandler(io.MultiWriter(logBuf, logFile), nil))
```

```
── Logs (/work/session-abc/retask.log) ───────────────────────────
```

Rotation follows the Unix convention: the live file keeps its name and older
generations shift down through `retask.log.1`, `retask.log.2`, … up to
`LogFile.Backups` before being discarded.

| Setting | Default | Meaning |
|---------|---------|---------|
| `LogFile.Enabled` | `true` | Write the log stream to a file. `false` makes `OpenLogFile` return a nil no-op writer. |
| `LogFile.Path` | `<cwd>/agentfleet.log` | Live log file. Relative paths resolve against the working directory. |
| `LogFile.MaxBytes` | `10MB` | Rotate once the live file exceeds this. `0` disables rotation. |
| `LogFile.Backups` | `5` | Rotated generations kept. `0` truncates instead of keeping any. |
| `TUI.ShowLogPath` | `true` | Render `TUI.LogPath` in the log panel divider. |

## Examples

| Example | Purpose |
Expand Down
15 changes: 12 additions & 3 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import (

// Config holds all configuration for a fleet run.
type Config struct {
Fleet FleetConfig
TUI TUIConfig
Agent AgentConfig
Fleet FleetConfig
TUI TUIConfig
Agent AgentConfig
LogFile LogFileConfig
}

// FleetConfig controls task scheduling and I/O paths.
Expand All @@ -30,6 +31,8 @@ type TUIConfig struct {
AutoOpen bool // auto-open a tab for each task when it starts — default: true
MaxDoneTasks int // done/failed tasks kept in list; 0 = no limit — default: 10
Log *LogBuffer // nil = no log panel
LogPath string // log file shown in the log panel divider; empty = none
ShowLogPath bool // render LogPath in the divider — default: true
OnClose func(taskID string) // called when user presses x on a selected task; nil = no-op
FilterLines func([]string) []string // pre-process runner output before preview; nil = default chrome filter
}
Expand All @@ -54,8 +57,14 @@ func DefaultConfig() Config {
RefreshRate: 500 * time.Millisecond,
AutoOpen: true,
MaxDoneTasks: 10,
ShowLogPath: true,
},
Agent: AgentConfig{PTYRows: 24, PTYCols: 220},
LogFile: LogFileConfig{
Enabled: true,
MaxBytes: DefaultLogMaxBytes,
Backups: DefaultLogBackups,
},
}
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/hoaitan/agentfleet

go 1.26.4
go 1.26.5

require (
github.com/charmbracelet/bubbletea v1.3.10
Expand Down
167 changes: 167 additions & 0 deletions logfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package agentfleet

import (
"fmt"
"os"
"path/filepath"
"sync"
)

// DefaultLogFileName is used when LogFileConfig.Path is empty.
const DefaultLogFileName = "agentfleet.log"

// Default rotation thresholds used by DefaultConfig.
const (
DefaultLogMaxBytes = 10 << 20 // 10MB
DefaultLogBackups = 5
)

// LogFileConfig controls the on-disk copy of the process log stream.
//
// The library never installs a logger of its own: OpenLogFile hands back an
// io.Writer that the caller tees into whatever handler it already uses, so the
// same lines reach the TUI log panel and the file.
type LogFileConfig struct {
Enabled bool // write the log stream to a file — default: true
Path string // log file path; empty = <cwd>/agentfleet.log
MaxBytes int64 // rotate once the live file exceeds this — default: 10MB; <= 0 disables rotation
Backups int // rotated generations kept (.1 … .N) — default: 5; 0 truncates instead
}

// LogFile is an io.Writer that appends to a file and rotates it Unix-style:
// the live file keeps its name, and older generations shift down through
// <path>.1, <path>.2, … up to Backups before being discarded.
//
// A nil *LogFile is a valid no-op writer, so callers can pass the result of
// OpenLogFile straight to io.MultiWriter without a nil check.
type LogFile struct {
mu sync.Mutex
path string
maxBytes int64
backups int
f *os.File
size int64
}

// OpenLogFile opens (creating or appending to) the configured log file.
// It returns a nil *LogFile when cfg.Enabled is false — writes to that value
// are discarded, so disabling the file needs no branching at the call site.
func OpenLogFile(cfg LogFileConfig) (lf *LogFile, err error) {
if !cfg.Enabled {
return nil, nil
}
path := cfg.Path
if path == "" {
path = DefaultLogFileName
}
abs, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve log file path %q: %w", path, err)
}
backups := cfg.Backups
if backups < 0 {
backups = 0
}
l := &LogFile{path: abs, maxBytes: cfg.MaxBytes, backups: backups}
if err := l.open(); err != nil {
return nil, err
}
return l, nil
}

// Path returns the absolute path of the live log file, or "" for a nil LogFile.
func (l *LogFile) Path() string {
if l == nil {
return ""
}
return l.path
}

// Write appends p to the log file, rotating first when the write would push
// the live file past MaxBytes. A single write is never split across two
// generations, so a log line always lands in one file.
func (l *LogFile) Write(p []byte) (n int, err error) {
if l == nil {
return len(p), nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.f == nil {
return 0, os.ErrClosed
}
if l.maxBytes > 0 && l.size > 0 && l.size+int64(len(p)) > l.maxBytes {
if err := l.rotate(); err != nil {
return 0, err
}
}
n, err = l.f.Write(p)
l.size += int64(n)
return n, err
}

// Close closes the live file. Writes after Close return os.ErrClosed.
func (l *LogFile) Close() error {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.f == nil {
return nil
}
err := l.f.Close()
l.f = nil
return err
}

// open opens the live file for append and records its current size.
// Callers other than OpenLogFile must hold l.mu.
func (l *LogFile) open() error {
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("open log file %q: %w", l.path, err)
}
size := int64(0)
if st, err := f.Stat(); err == nil {
size = st.Size()
}
l.f, l.size = f, size
return nil
}

// rotate shifts the log generations down and reopens an empty live file.
// The caller must hold l.mu.
func (l *LogFile) rotate() error {
if err := l.f.Close(); err != nil {
return fmt.Errorf("close log file %q: %w", l.path, err)
}
l.f = nil

if l.backups == 0 {
// No generations kept: start the live file over.
if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove log file %q: %w", l.path, err)
}
return l.open()
}

// Drop the oldest generation, then shift the rest down: .N-1 -> .N, … , .1 -> .2.
if err := os.Remove(l.backupPath(l.backups)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove log file %q: %w", l.backupPath(l.backups), err)
}
for i := l.backups - 1; i >= 1; i-- {
from, to := l.backupPath(i), l.backupPath(i+1)
if err := os.Rename(from, to); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("rotate log file %q -> %q: %w", from, to, err)
}
}
if err := os.Rename(l.path, l.backupPath(1)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("rotate log file %q -> %q: %w", l.path, l.backupPath(1), err)
}
return l.open()
}

// backupPath returns the path of the nth rotated generation (<path>.n).
func (l *LogFile) backupPath(n int) string {
return fmt.Sprintf("%s.%d", l.path, n)
}
Loading
Loading