Skip to content
Open
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
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ linters:
- exhaustive
settings:
exhaustive:
default-signifies-exhaustive: true
check:
- "switch"
- "map"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lib/httpapi/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
15 changes: 1 addition & 14 deletions lib/screentracker/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 18 additions & 7 deletions lib/termexec/termexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,26 @@ 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 {
Program string
Args []string
TerminalWidth uint16
TerminalHeight uint16
AgentType msgfmt.AgentType
}

func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error) {
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions lib/termexec/utils.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is largely the same, but the continue removing variable seems new, perhaps we should ensure we've covered the new behavior with tests?

}

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
}
}