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
32 changes: 32 additions & 0 deletions backend/internal/adapters/agent/activitydispatch/dispatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// 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.
package activitydispatch

import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

// DeriveFunc maps a native agent hook event and its raw stdin payload onto an AO
// activity state. ok=false means the event carries no activity signal.
type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool)

// Derivers maps the agent token in `ao hooks <agent> <event>` to its deriver.
var Derivers = map[string]DeriveFunc{
"claude-code": claudecode.DeriveActivityState,
"codex": codex.DeriveActivityState,
"opencode": opencode.DeriveActivityState,
}

// 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.
func Derive(agent, event string, payload []byte) (domain.ActivityState, bool) {
derive, found := Derivers[agent]
if !found {
return "", false
}
return derive(event, payload)
}
51 changes: 51 additions & 0 deletions backend/internal/adapters/agent/claudecode/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package claudecode

import (
"encoding/json"

"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.
func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) {
switch event {
case "user-prompt-submit":
return domain.ActivityActive, true
case "stop":
return domain.ActivityIdle, true
case "notification":
return notificationState(payload)
case "session-end":
return sessionEndState(payload)
default:
return "", false
}
}

func notificationState(payload []byte) (domain.ActivityState, bool) {
var p struct {
NotificationType string `json:"notification_type"`
}
_ = json.Unmarshal(payload, &p)
switch p.NotificationType {
case "idle_prompt", "permission_prompt":
return domain.ActivityWaitingInput, true
default:
return "", false
}
}

func sessionEndState(payload []byte) (domain.ActivityState, bool) {
var p struct {
Reason string `json:"reason"`
}
_ = json.Unmarshal(payload, &p)
switch p.Reason {
case "clear", "resume":
return "", false
default:
return domain.ActivityExited, true
}
}
40 changes: 40 additions & 0 deletions backend/internal/adapters/agent/claudecode/activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package claudecode

import (
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

func TestDeriveActivityState(t *testing.T) {
tests := []struct {
name string
event string
payload string
want domain.ActivityState
wantOK bool
}{
{"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true},
{"stop -> idle", "stop", `{}`, domain.ActivityIdle, true},
{"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 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 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},
{"session-start -> no signal", "session-start", `{}`, "", false},
{"unknown event -> no signal", "frobnicate", `{}`, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := DeriveActivityState(tt.event, []byte(tt.payload))
if got != tt.want || ok != tt.wantOK {
t.Fatalf("DeriveActivityState(%q, %q) = (%q, %v), want (%q, %v)",
tt.event, tt.payload, got, ok, tt.want, tt.wantOK)
}
})
}
}
8 changes: 8 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ 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.
if m := matcherForCommand(config.Hooks["Notification"], "ao hooks claude-code notification"); m != nil {
t.Fatalf("Notification matcher = %v, want none", m)
}
if m := matcherForCommand(config.Hooks["SessionEnd"], "ao hooks claude-code session-end"); m != nil {
t.Fatalf("SessionEnd matcher = %v, want none", m)
}
}

func TestUninstallHooksRemovesClaudeHooks(t *testing.T) {
Expand Down
17 changes: 12 additions & 5 deletions backend/internal/adapters/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,27 @@ type claudeHookSpec struct {
var claudeStartupMatcher = "startup"

// claudeManagedHooks is the source of truth for the hooks AO installs:
// SessionStart (under the "startup" matcher), UserPromptSubmit, and Stop. Each
// reports normalized session metadata back into AO's store.
// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop,
// Notification, and SessionEnd. They report normalized session metadata and
// 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.
var claudeManagedHooks = []claudeHookSpec{
{Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"},
{Event: "Stop", Command: claudeHookCommandPrefix + "stop"},
{Event: "Notification", Command: claudeHookCommandPrefix + "notification"},
{Event: "SessionEnd", Command: claudeHookCommandPrefix + "session-end"},
}

// GetAgentHooks installs AO's Claude Code hooks into the worktree-local
// .claude/settings.local.json file (the per-session local settings, not the
// shared .claude/settings.json). The hooks (SessionStart, UserPromptSubmit,
// Stop) report normalized session metadata back into AO's store. Existing
// hooks and unrelated settings are preserved, and duplicate AO commands
// are not appended, so the install is idempotent.
// Stop, Notification, SessionEnd) report normalized session metadata and
// activity-state signals back into AO's store. Existing hooks and unrelated
// settings are preserved, and duplicate AO commands are not appended, so
// the install is idempotent.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
if err := ctx.Err(); err != nil {
return err
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/codex/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package codex

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.
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event {
case "user-prompt-submit":
return domain.ActivityActive, true
case "permission-request":
return domain.ActivityWaitingInput, true
case "stop":
return domain.ActivityIdle, true
default:
return "", false
}
}
31 changes: 31 additions & 0 deletions backend/internal/adapters/agent/codex/activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package codex

import (
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

func TestDeriveActivityState(t *testing.T) {
tests := []struct {
name string
event string
want domain.ActivityState
wantOK bool
}{
{"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true},
{"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true},
{"stop -> idle", "stop", domain.ActivityIdle, true},
{"session start -> no signal", "session-start", "", false},
{"unknown event -> no signal", "frobnicate", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
if got != tt.want || ok != tt.wantOK {
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
tt.event, got, ok, tt.want, tt.wantOK)
}
})
}
}
15 changes: 13 additions & 2 deletions backend/internal/adapters/agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
}

// GetLaunchCommand builds the argv to start a new Codex session, applying the
// no-update-check and approval flags, optional system-prompt instructions, and
// the initial prompt (passed after `--` so a leading "-" is not read as a flag).
// no-update-check, hook-trust bypass, and approval flags, optional
// system-prompt instructions, and the initial prompt (passed after `--` so a
// leading "-" is not read as a flag).
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.codexBinary(ctx)
if err != nil {
Expand All @@ -71,6 +72,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (

cmd = []string{binary}
appendNoUpdateCheckFlag(&cmd)
appendHookTrustBypassFlag(&cmd)
appendApprovalFlags(&cmd, cfg.Permissions)

if cfg.SystemPromptFile != "" {
Expand Down Expand Up @@ -116,6 +118,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
cmd = make([]string, 0, 8)
cmd = append(cmd, binary, "resume")
appendNoUpdateCheckFlag(&cmd)
appendHookTrustBypassFlag(&cmd)
appendApprovalFlags(&cmd, cfg.Permissions)
cmd = append(cmd, agentSessionID)
return cmd, true, nil
Expand Down Expand Up @@ -227,6 +230,14 @@ func appendNoUpdateCheckFlag(cmd *[]string) {
*cmd = append(*cmd, "-c", "check_for_update_on_startup=false")
}

func appendHookTrustBypassFlag(cmd *[]string) {
// AO installs deterministic workspace-local Codex hooks immediately before
// launch/restore. Without this flag, a fresh per-session worktree can skip
// those hooks until an interactive /hooks trust review happens, leaving AO
// without activity signals.
*cmd = append(*cmd, "--dangerously-bypass-hook-trust")
}

func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
switch normalizePermissionMode(permissions) {
case ports.PermissionModeDefault:
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/adapters/agent/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) {
want := []string{
"codex",
"-c", "check_for_update_on_startup=false",
"--dangerously-bypass-hook-trust",
"--dangerously-bypass-approvals-and-sandbox",
"-c", "model_instructions_file=" + filepath.Join("tmp", "prompt with spaces.md"),
"--", "-fix this",
Expand Down Expand Up @@ -249,6 +250,7 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
"codex",
"resume",
"-c", "check_for_update_on_startup=false",
"--dangerously-bypass-hook-trust",
"--ask-for-approval", "on-request",
"-c", `approvals_reviewer="auto_review"`,
"thread-123",
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/adapters/agent/codex/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type codexHookFile struct {
}

type codexMatcherGroup struct {
Matcher *string `json:"matcher"`
Matcher *string `json:"matcher,omitempty"`
Hooks []codexHookEntry `json:"hooks"`
}

Expand All @@ -56,6 +56,7 @@ type codexHookSpec struct {
var codexManagedHooks = []codexHookSpec{
{Event: "SessionStart", Command: codexHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: codexHookCommandPrefix + "user-prompt-submit"},
{Event: "PermissionRequest", Command: codexHookCommandPrefix + "permission-request"},
{Event: "Stop", Command: codexHookCommandPrefix + "stop"},
}

Expand Down
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/opencode/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package opencode

import "github.com/aoagents/agent-orchestrator/backend/internal/domain"

// DeriveActivityState maps an opencode plugin hook event onto an AO activity
// state. The bool is false when the event carries no activity signal.
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event {
case "session-start":
return domain.ActivityActive, true
case "user-prompt-submit":
return domain.ActivityActive, true
Comment thread
yyovil marked this conversation as resolved.
case "stop":
return domain.ActivityIdle, true
default:
return "", false
}
}
30 changes: 30 additions & 0 deletions backend/internal/adapters/agent/opencode/activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package opencode

import (
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

func TestDeriveActivityState(t *testing.T) {
tests := []struct {
name string
event string
want domain.ActivityState
wantOK bool
}{
{"session start -> active", "session-start", domain.ActivityActive, true},
{"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true},
{"stop -> idle", "stop", domain.ActivityIdle, true},
{"unknown event -> no signal", "frobnicate", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
if got != tt.want || ok != tt.wantOK {
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
tt.event, got, ok, tt.want, tt.wantOK)
}
})
}
}
4 changes: 2 additions & 2 deletions backend/internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (c *commandContext) doJSON(ctx context.Context, method, path string, body,
reader = bytes.NewReader(payload)
}
url := fmt.Sprintf("http://%s:%d/api/v1/%s", config.LoopbackHost, info.Port, path)
req, err := http.NewRequestWithContext(ctx, method, url, reader)
req, err := http.NewRequestWithContext(ctx, method, url, reader) // #nosec G704 -- daemon host is fixed loopback; path is an internal API route.
if err != nil {
return err
}
Expand All @@ -105,7 +105,7 @@ func (c *commandContext) doJSON(ctx context.Context, method, path string, body,
// give daemon API calls far more headroom than the 2s status-probe timeout.
client := *c.deps.HTTPClient
client.Timeout = commandTimeout
resp, err := client.Do(req)
resp, err := client.Do(req) // #nosec G704 -- request target is the fixed loopback daemon URL above.
if err != nil {
return fmt.Errorf("call daemon: %w", err)
}
Expand Down
Loading
Loading