diff --git a/.golangci.yml b/.golangci.yml index b89afc4..ba46a52 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,6 +4,7 @@ linters: - exhaustive settings: exhaustive: + default-signifies-exhaustive: true check: - "switch" - "map" diff --git a/README.md b/README.md index dd39485..8d92c50 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ agentapi server -- goose > [!NOTE] > When using Codex, Opencode, Copilot, Gemini, Amp or CursorCLI, always specify the agent type explicitly (eg: `agentapi server --type=codex -- codex`), or message formatting may break. + +> [!NOTE] +> When using Amp, set `"amp.terminal.animation": false` in `~/.config/amp/settings.json` to disable terminal animations, which can interfere with AgentAPI's message parsing. + An OpenAPI schema is available in [openapi.json](openapi.json). By default, the server runs on port 3284. Additionally, the server exposes the same OpenAPI schema at http://localhost:3284/openapi.json and the available endpoints in a documentation UI at http://localhost:3284/docs. diff --git a/lib/httpapi/setup.go b/lib/httpapi/setup.go index 1620304..00d0b45 100644 --- a/lib/httpapi/setup.go +++ b/lib/httpapi/setup.go @@ -32,6 +32,7 @@ func SetupProcess(ctx context.Context, config SetupProcessConfig) (*termexec.Pro Args: config.ProgramArgs, TerminalWidth: config.TerminalWidth, TerminalHeight: config.TerminalHeight, + AgentType: config.AgentType, }) if err != nil { logger.Error(fmt.Sprintf("Error starting process: %v", err)) diff --git a/lib/screentracker/conversation.go b/lib/screentracker/conversation.go index 4617e8e..5247fd8 100644 --- a/lib/screentracker/conversation.go +++ b/lib/screentracker/conversation.go @@ -145,24 +145,11 @@ func FindNewMessage(oldScreen, newScreen string, agentType msgfmt.AgentType) str newLines := strings.Split(newScreen, "\n") oldLinesMap := make(map[string]bool) - // -1 indicates no header - dynamicHeaderEnd := -1 - - // Skip header lines for Opencode agent type to avoid false positives - // The header contains dynamic content (token count, context percentage, cost) - // that changes between screens, causing line comparison mismatches: - // - // ┃ # Getting Started with Claude CLI ┃ - // ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃ - if len(newLines) >= 2 && agentType == msgfmt.AgentTypeOpencode { - dynamicHeaderEnd = 2 - } - for _, line := range oldLines { oldLinesMap[line] = true } firstNonMatchingLine := len(newLines) - for i, line := range newLines[dynamicHeaderEnd+1:] { + for i, line := range newLines { if !oldLinesMap[line] { firstNonMatchingLine = i break diff --git a/lib/termexec/termexec.go b/lib/termexec/termexec.go index fd2c04b..e30b1ff 100644 --- a/lib/termexec/termexec.go +++ b/lib/termexec/termexec.go @@ -13,15 +13,18 @@ import ( "github.com/ActiveState/termtest/xpty" "github.com/coder/agentapi/lib/logctx" + "github.com/coder/agentapi/lib/msgfmt" "github.com/coder/agentapi/lib/util" "golang.org/x/xerrors" ) type Process struct { - xp *xpty.Xpty - execCmd *exec.Cmd - screenUpdateLock sync.RWMutex - lastScreenUpdate time.Time + xp *xpty.Xpty + execCmd *exec.Cmd + screenUpdateLock sync.RWMutex + lastScreenUpdate time.Time + checkAnimatedContent bool + agentType msgfmt.AgentType } type StartProcessConfig struct { @@ -29,6 +32,7 @@ type StartProcessConfig struct { Args []string TerminalWidth uint16 TerminalHeight uint16 + AgentType msgfmt.AgentType } func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error) { @@ -46,7 +50,7 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error return nil, err } - process := &Process{xp: xp, execCmd: execCmd} + process := &Process{xp: xp, execCmd: execCmd, checkAnimatedContent: true, agentType: args.AgentType} go func() { // HACK: Working around xpty concurrency limitations @@ -112,17 +116,24 @@ func (p *Process) Signal(sig os.Signal) error { // result in a malformed agent message being returned to the // user. func (p *Process) ReadScreen() string { + var state string for range 3 { p.screenUpdateLock.RLock() if time.Since(p.lastScreenUpdate) >= 16*time.Millisecond { - state := p.xp.State.String() + state = p.xp.State.String() p.screenUpdateLock.RUnlock() + if p.checkAnimatedContent { + state, p.checkAnimatedContent = removeAnimatedContent(state, p.agentType) + } return state } p.screenUpdateLock.RUnlock() time.Sleep(16 * time.Millisecond) } - return p.xp.State.String() + if p.checkAnimatedContent { + state, p.checkAnimatedContent = removeAnimatedContent(p.xp.State.String(), p.agentType) + } + return state } // Write sends input to the process via the pseudo terminal. diff --git a/lib/termexec/utils.go b/lib/termexec/utils.go new file mode 100644 index 0000000..6a06468 --- /dev/null +++ b/lib/termexec/utils.go @@ -0,0 +1,31 @@ +package termexec + +import ( + "strings" + + "github.com/coder/agentapi/lib/msgfmt" +) + +func calcOpencodeAnimatedContent(lines []string) (int, bool) { + // Skip header lines for Opencode agent type to avoid false positives + // The header contains dynamic content (token count, context percentage, cost) + // that changes between screens, causing line comparison mismatches: + // + // ┃ # Getting Started with Claude CLI ┃ + // ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃ + if len(lines) >= 2 { + return 2, true + } + return -1, true +} + +func removeAnimatedContent(screen string, agentType msgfmt.AgentType) (string, bool) { + switch agentType { + case msgfmt.AgentTypeOpencode: + lines := strings.Split(screen, "\n") + animatedContentEnd, continueRemoving := calcOpencodeAnimatedContent(lines) + return strings.Join(lines[animatedContentEnd+1:], "\n"), continueRemoving + default: + return screen, false + } +}