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
1 change: 1 addition & 0 deletions backend/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ linters:
- G104 # unchecked errors — errcheck owns this
- G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input
- G703 # path traversal via taint analysis — same as G304: binary-resolution and worktree-derived paths, not user input
- G704 # SSRF via taint analysis — the daemon client's host is hardcoded loopback; only the request path varies, so it cannot be steered to an external host

exclusions:
generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT")
Expand Down
5 changes: 3 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ require (
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/pressly/goose/v3 v3.27.1
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
github.com/swaggest/jsonschema-go v0.3.79
github.com/swaggest/openapi-go v0.2.61
golang.org/x/sys v0.43.0
Expand All @@ -17,17 +19,16 @@ require (

require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/swaggest/refl v1.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
Expand Down
8 changes: 4 additions & 4 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3Ifn
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
Expand Down
9 changes: 8 additions & 1 deletion backend/internal/adapters/agent/activitydispatch/dispatch.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// Package activitydispatch is the single source of truth mapping the agent
// token in `ao hooks <agent> <event>` onto the function that interprets that
// agent's hook callbacks as an AO activity state.
//
// The hidden `ao hooks` CLI command dispatches a live callback through it. Every
// adapter that installs `ao hooks <tok>` callbacks must have a deriver
// registered here — otherwise the adapter writes callbacks that nothing on the
// receiving side understands, so its activity is silently never reported.
package activitydispatch

import (
Expand All @@ -15,14 +20,16 @@ import (
type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool)

// Derivers maps the agent token in `ao hooks <agent> <event>` to its deriver.
// Per-adapter PRs add their tokens here as they land.
var Derivers = map[string]DeriveFunc{
"claude-code": claudecode.DeriveActivityState,
"codex": codex.DeriveActivityState,
"opencode": opencode.DeriveActivityState,
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Derive looks up the deriver for an agent token and applies it. ok=false when
// the token has no registered deriver or the event carries no activity signal.
// the token has no registered deriver or the event carries no activity signal —
// the caller reports nothing in either case.
func Derive(agent, event string, payload []byte) (domain.ActivityState, bool) {
derive, found := Derivers[agent]
if !found {
Expand Down
26 changes: 23 additions & 3 deletions backend/internal/adapters/agent/claudecode/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

// DeriveActivityState maps a Claude Code hook event and its native stdin payload
// onto an AO activity state. The bool is false when the event carries no
// activity signal.
// DeriveActivityState maps a Claude Code hook event (and its native stdin
// payload) onto an AO activity state. The bool is false when the event carries
// no activity signal — e.g. SessionStart (metadata only, v1), a Notification
// type we don't track, or a SessionEnd reason that doesn't actually end the AO
// session — in which case the caller reports nothing.
//
// event is the AO hook sub-command name installed in claudeManagedHooks
// ("user-prompt-submit", "stop", "notification", "session-end", ...), NOT the
// native Claude event name. Keeping this beside hooks.go means the events AO
// installs and what they mean live in one place.
func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) {
switch event {
case "user-prompt-submit":
return domain.ActivityActive, true
case "stop":
// End of a turn: the agent is idle but alive (not exited). A following
// Notification(idle_prompt) upgrades this to the sticky waiting_input.
return domain.ActivityIdle, true
case "notification":
return notificationState(payload)
Expand All @@ -24,6 +33,10 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo
}
}

// notificationState reports waiting_input only for the notification types that
// mean "the agent is blocked on the user": a pending tool-permission prompt or
// an idle prompt awaiting the next instruction. Other types (auth_success,
// elicitation_*) carry no activity meaning, as does a malformed payload.
func notificationState(payload []byte) (domain.ActivityState, bool) {
var p struct {
NotificationType string `json:"notification_type"`
Expand All @@ -37,6 +50,13 @@ func notificationState(payload []byte) (domain.ActivityState, bool) {
}
}

// sessionEndState reports exited for reasons that actually end the session.
// clear/resume keep the same AO session alive (a new native session continues
// in the worktree), so they report nothing. Any other reason — logout,
// prompt_input_exit, bypass_permissions_disabled, other, or an absent/unknown
// reason on a SessionEnd that did fire — is treated as a real exit. SessionEnd
// is not guaranteed on crash/SIGKILL, so the reaper remains the backstop; both
// paths guard on IsTerminated, so whichever lands first wins.
func sessionEndState(payload []byte) (domain.ActivityState, bool) {
var p struct {
Reason string `json:"reason"`
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/adapters/agent/claudecode/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ func TestDeriveActivityState(t *testing.T) {
{"notification idle_prompt -> waiting_input", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityWaitingInput, true},
{"notification permission_prompt -> waiting_input", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityWaitingInput, true},
{"notification auth_success -> no signal", "notification", `{"notification_type":"auth_success"}`, "", false},
{"notification empty type -> no signal", "notification", `{}`, "", false},
{"notification malformed payload -> no signal", "notification", `not json`, "", false},
{"session-end logout -> exited", "session-end", `{"reason":"logout"}`, domain.ActivityExited, true},
{"session-end prompt_input_exit -> exited", "session-end", `{"reason":"prompt_input_exit"}`, domain.ActivityExited, true},
{"session-end other -> exited", "session-end", `{"reason":"other"}`, domain.ActivityExited, true},
{"session-end absent reason -> exited", "session-end", `{}`, domain.ActivityExited, true},
{"session-end clear -> no signal", "session-end", `{"reason":"clear"}`, "", false},
{"session-end resume -> no signal", "session-end", `{"reason":"resume"}`, "", false},
Expand Down
12 changes: 2 additions & 10 deletions backend/internal/adapters/agent/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,6 @@ const (
// adapterID is the registry id and the value users pass to
// `ao spawn --agent`.
adapterID = "claude-code"

// Normalized session-metadata keys the Claude Code hooks persist into the
// AO session store and SessionInfo reads back. Shared vocabulary
// with the Codex adapter so the dashboard treats every agent uniformly.
// The native session id key lives in ports as MetadataKeyAgentSessionID
// because the Session Manager also reads it.
claudeTitleMetadataKey = "title"
claudeSummaryMetadataKey = "summary"
)

// claudeSessionNamespace seeds the UUIDv5 derivation that maps an AO
Expand Down Expand Up @@ -220,8 +212,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
}
info := ports.SessionInfo{
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
Title: session.Metadata[claudeTitleMetadataKey],
Summary: session.Metadata[claudeSummaryMetadataKey],
Title: session.Metadata[ports.MetadataKeyTitle],
Summary: session.Metadata[ports.MetadataKeySummary],
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
Expand Down
8 changes: 4 additions & 4 deletions backend/internal/adapters/agent/claudecode/claudecode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) {
if m := matcherForCommand(config.Hooks["UserPromptSubmit"], "ao hooks claude-code user-prompt-submit"); m != nil {
t.Fatalf("UserPromptSubmit matcher = %v, want none", m)
}
// Notification and SessionEnd install with no matcher; the handler filters
// on the payload.
// Notification and SessionEnd install with no matcher (they fire for all
// sub-types; the handler filters on the payload).
if m := matcherForCommand(config.Hooks["Notification"], "ao hooks claude-code notification"); m != nil {
t.Fatalf("Notification matcher = %v, want none", m)
}
Expand Down Expand Up @@ -303,8 +303,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path",
Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "claude-native-1",
claudeTitleMetadataKey: "Fix login redirect",
claudeSummaryMetadataKey: "Updated the auth callback and tests.",
ports.MetadataKeyTitle: "Fix login redirect",
ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
Expand Down
30 changes: 4 additions & 26 deletions backend/internal/adapters/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"

"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

Expand Down Expand Up @@ -56,7 +57,8 @@ var claudeStartupMatcher = "startup"
// activity-state signals back into AO's store (see DeriveActivityState).
// Notification and SessionEnd carry no matcher: each installs once and fires
// for every sub-type, and the handler filters on the payload's
// notification_type / reason field.
// notification_type / reason field — installing one command under multiple
// matchers would trip the per-command dedup in claudeHookCommandExists.
var claudeManagedHooks = []claudeHookSpec{
{Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"},
Expand Down Expand Up @@ -233,36 +235,12 @@ func writeClaudeSettings(settingsPath string, topLevel, rawHooks map[string]json
return fmt.Errorf("encode %s: %w", settingsPath, err)
}
data = append(data, '\n')
if err := atomicWriteFile(settingsPath, data, 0o600); err != nil {
if err := hookutil.AtomicWriteFile(settingsPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", settingsPath, err)
}
return nil
}

// atomicWriteFile writes data to path via a temp file in the same directory
// followed by a rename, so a crash or signal mid-write can't leave a truncated
// or empty file that Claude Code then fails to parse (silently disabling hooks).
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}

// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so
// each event's settings array is rewritten once.
func groupClaudeHooksByEvent() map[string][]claudeHookSpec {
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/adapters/agent/codex/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"

// DeriveActivityState maps a Codex hook event onto an AO activity state. The
// bool is false when the event carries no activity signal.
//
// event is the AO hook sub-command name installed in codexManagedHooks
// ("user-prompt-submit", "permission-request", "stop", ...), not the native
// Codex event name. Codex currently has no SessionEnd/Notification equivalent
// in the adapter, so runtime exit still falls back to the reaper.
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event {
case "user-prompt-submit":
Expand Down
9 changes: 2 additions & 7 deletions backend/internal/adapters/agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

const (
codexTitleMetadataKey = "title"
codexSummaryMetadataKey = "summary"
)

// Plugin is the Codex agent adapter. It is safe for concurrent use; the binary
// path is resolved once and cached under binaryMu.
type Plugin struct {
Expand Down Expand Up @@ -132,8 +127,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
}
info := ports.SessionInfo{
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
Title: session.Metadata[codexTitleMetadataKey],
Summary: session.Metadata[codexSummaryMetadataKey],
Title: session.Metadata[ports.MetadataKeyTitle],
Summary: session.Metadata[ports.MetadataKeySummary],
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/adapters/agent/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path",
Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "thread-123",
codexTitleMetadataKey: "Fix login redirect",
codexSummaryMetadataKey: "Updated the auth callback and tests.",
ports.MetadataKeyTitle: "Fix login redirect",
ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
Expand Down
28 changes: 3 additions & 25 deletions backend/internal/adapters/agent/codex/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"

"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

Expand Down Expand Up @@ -229,35 +230,12 @@ func writeCodexHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMes
return fmt.Errorf("encode %s: %w", hooksPath, err)
}
data = append(data, '\n')
if err := atomicWriteFile(hooksPath, data, 0o600); err != nil {
if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", hooksPath, err)
}
return nil
}

// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
// write can't leave a truncated/empty file that Codex then fails to parse.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}

// groupCodexHooksByEvent groups the managed hook specs by their Codex event so
// each event's array is rewritten once.
func groupCodexHooksByEvent() map[string][]codexHookSpec {
Expand Down Expand Up @@ -382,7 +360,7 @@ func ensureCodexHooksFeatureEnabled(workspacePath string) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil {
return fmt.Errorf("create .codex directory: %w", err)
}
if err := atomicWriteFile(configPath, []byte(content), 0o600); err != nil {
if err := hookutil.AtomicWriteFile(configPath, []byte(content), 0o600); err != nil {
return fmt.Errorf("write config.toml: %w", err)
}
return nil
Expand Down
37 changes: 37 additions & 0 deletions backend/internal/adapters/agent/hookutil/hookutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Package hookutil holds small filesystem helpers shared by the agent hook
// installers (claude-code, codex, opencode). It centralizes the atomic-write
// primitive so every adapter writes hook config the same crash-safe way.
package hookutil

import (
"os"
"path/filepath"
)

// AtomicWriteFile writes data to path via a temp file in the same directory
// followed by a rename, so a crash or signal mid-write can't leave a truncated
// or empty file that the agent then fails to parse (silently disabling hooks).
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
Loading
Loading