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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// - session-start / user-prompt-submit → active
// - stop → idle
// - permission-request → waiting_input
//
// permission-request maps to waiting_input, not blocked: none of the sharing
// adapters install the pre/post-tool-use trio, so a blocked state could never
// be cleared before the turn ends. waiting_input still suppresses automated
// nudges (NeedsInput) while leaving user-initiated sends deliverable.
func StandardDeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event {
case "session-start":
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/adapters/agent/autohand/autohand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ func TestDeriveActivityState(t *testing.T) {
{"session start -> active", "session-start", domain.ActivityActive, true},
{"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true},
{"stop -> idle", "stop", domain.ActivityIdle, true},
{"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true},
{"permission request -> waiting_input", "permission-request", domain.ActivityWaitingInput, true},
{"unknown event -> no signal", "frobnicate", "", false},
}
for _, tt := range tests {
Expand Down
50 changes: 40 additions & 10 deletions backend/internal/adapters/agent/claudecode/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo
switch event {
case "user-prompt-submit":
return domain.ActivityActive, true
case "pre-tool-use", "post-tool-use", "post-tool-use-failure":
// The agent is executing tools — active. These signals carry
// tool_name/tool_use_id on the wire, and lifecycle applies them under
// a precedence rule: they never demote a sticky state, EXCEPT the
// post of the exact tool whose permission dialog blocked the session
// (approval means the tool ran — the earliest observable "the
// decision was resolved" signal, since answering a dialog fires no
// hook of its own). Without the rule, parallel-subagent traffic would
// clear a live blocked — the failure that reverted the naive mapping
// in PR #5's review.
return domain.ActivityActive, true
case "permission-request":
// Fires when a permission dialog appears — earlier and richer than
// Notification(permission_prompt): the payload names the blocking
// tool, which lifecycle snapshots for the correlated clear above.
return domain.ActivityBlocked, true
case "stop":
// End of a turn (including a user interrupt): the agent is idle but
// alive (not exited). A following Notification(idle_prompt) also maps to
Expand All @@ -35,23 +51,37 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo
}
}

// notificationState reports waiting_input only when the agent is genuinely
// blocked on the user: a pending tool-permission prompt (permission_prompt).
// idle_prompt means the agent finished its turn and is sitting idle at the
// prompt awaiting the next instruction — that is Idle, not a blocking request,
// so a stop/interrupt reads Idle rather than "Input Needed". Other types
// (auth_success, elicitation_*) carry no activity meaning, as does a malformed
// payload.
// notificationState splits the notification types that mean "the agent is
// paused on the user" by what unblocks them:
// - idle_prompt: the agent finished its turn and sits idle at the prompt
// awaiting the next instruction — that is Idle, not a blocking request,
// so a stop/interrupt reads Idle rather than "Input Needed".
// - agent_needs_input: a request for user input that carries no tool
// identity — waiting_input (automated nudges stay suppressed via
// NeedsInput, user sends deliver). It must NOT map to blocked: without a
// tool to correlate, the block could only lift at a turn boundary,
// rejecting user sends long after the question was answered.
// - permission_prompt: a pending permission decision (blocked — a stray
// Enter could answer the dialog). It duplicates the earlier
// permission-request hook, whose payload names the blocking tool for the
// correlated clear.
// - agent_completed (fired by `claude agents` background sessions,
// CLI 2.1.198+): the turn finished — Stop semantics, idle but alive.
//
// 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"`
}
_ = json.Unmarshal(payload, &p)
switch p.NotificationType {
case "permission_prompt":
return domain.ActivityWaitingInput, true
case "idle_prompt":
case "idle_prompt", "agent_completed":
return domain.ActivityIdle, true
case "agent_needs_input":
return domain.ActivityWaitingInput, true
case "permission_prompt":
return domain.ActivityBlocked, true
default:
return "", false
}
Expand Down
10 changes: 9 additions & 1 deletion backend/internal/adapters/agent/claudecode/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ func TestDeriveActivityState(t *testing.T) {
wantOK bool
}{
{"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true},
// The tool-use trio reports active; lifecycle's precedence rule (not
// this deriver) decides whether it may demote a sticky state.
{"pre-tool-use -> active", "pre-tool-use", `{"tool_name":"Bash","tool_use_id":"toolu_1"}`, domain.ActivityActive, true},
{"post-tool-use -> active", "post-tool-use", `{"tool_name":"Bash","tool_use_id":"toolu_1"}`, domain.ActivityActive, true},
{"post-tool-use-failure -> active", "post-tool-use-failure", `{"tool_name":"Bash","tool_use_id":"toolu_1"}`, domain.ActivityActive, true},
{"permission-request -> blocked", "permission-request", `{"tool_name":"Bash"}`, domain.ActivityBlocked, true},
{"stop -> idle", "stop", `{}`, domain.ActivityIdle, true},
{"notification idle_prompt -> idle", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityIdle, true},
{"notification permission_prompt -> waiting_input", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityWaitingInput, true},
{"notification permission_prompt -> blocked", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityBlocked, true},
{"notification agent_needs_input -> waiting_input", "notification", `{"notification_type":"agent_needs_input"}`, domain.ActivityWaitingInput, true},
{"notification agent_completed -> idle", "notification", `{"notification_type":"agent_completed"}`, domain.ActivityIdle, 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},
Expand Down
12 changes: 12 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ func New() *Plugin {
return &Plugin{}
}

// EmitsSubmitActivity signals that Claude Code fires a user-prompt-submit hook
// under AO's launch, so Activity.State can flip to active after a prompt is
// accepted. See ports.ActivitySignaler.
func (p *Plugin) EmitsSubmitActivity() bool { return true }

// EmitsBlockedActivity signals that Claude Code fires both pre- and post-tool
// hooks, so Activity.State can flip to blocked mid-turn on a permission dialog
// and the guarded send loop can clear it once the tool completes. Only
// claude-code (and its hook-delegators) carry this trio; see
// ports.ActivitySignaler.
func (p *Plugin) EmitsBlockedActivity() bool { return true }

var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
Expand Down
20 changes: 19 additions & 1 deletion backend/internal/adapters/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,28 @@ const (
// its required "startup" matcher.
var claudeStartupMatcher = "startup"

// claudeManagedHooks is the source of truth for the hooks AO installs.
// claudeManagedHooks is the source of truth for the hooks AO installs:
// SessionStart (under the "startup" matcher), UserPromptSubmit, the tool-use
// trio (PreToolUse, PostToolUse, PostToolUseFailure), PermissionRequest,
// 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. The tool-use hooks also carry no matcher
// (fire for every tool): their payloads carry tool_name/tool_use_id, which
// lifecycle uses to clear a stale sticky `blocked` only when the specific
// approved tool finishes — the daemon-side precedence rule is what makes these
// signals safe against parallel-subagent traffic (the naive mapping without it
// was reverted in PR #5's review). PermissionRequest fires when a permission
// dialog appears and carries the blocking tool_name; `ao hooks` writes nothing
// to stdout, so installing it never injects a permission decision.
var claudeManagedHooks = []hooksjson.HookSpec{
{Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"},
{Event: "PreToolUse", Command: claudeHookCommandPrefix + "pre-tool-use"},
{Event: "PostToolUse", Command: claudeHookCommandPrefix + "post-tool-use"},
{Event: "PostToolUseFailure", Command: claudeHookCommandPrefix + "post-tool-use-failure"},
{Event: "PermissionRequest", Command: claudeHookCommandPrefix + "permission-request"},
{Event: "Stop", Command: claudeHookCommandPrefix + "stop"},
{Event: "Notification", Command: claudeHookCommandPrefix + "notification"},
{Event: "SessionEnd", Command: claudeHookCommandPrefix + "session-end"},
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/adapters/agent/codex/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
case "user-prompt-submit":
return domain.ActivityActive, true
case "permission-request":
// waiting_input, not blocked: codex installs no pre/post-tool-use
// hooks, so a blocked state could never be cleared before the turn
// ends. waiting_input still suppresses automated nudges.
return domain.ActivityWaitingInput, true
case "stop":
return domain.ActivityIdle, true
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/adapters/agent/codex/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func TestDeriveActivityState(t *testing.T) {
wantOK bool
}{
{"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true},
{"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, 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},
Expand Down
11 changes: 11 additions & 0 deletions backend/internal/adapters/agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ func New() *Plugin {
return &Plugin{}
}

// EmitsSubmitActivity signals Codex fires a user-prompt-submit hook under AO's
// launch. See ports.ActivitySignaler.
func (p *Plugin) EmitsSubmitActivity() bool { return true }

// EmitsBlockedActivity is false: codex reports permission prompts as
// waiting_input — it installs no post-tool-use hook, so a blocked state could
// never be cleared mid-turn. confirmActive must not nudge it (an Enter could
// answer a pending decision it cannot report as blocked). See
// ports.ActivitySignaler.
func (p *Plugin) EmitsBlockedActivity() bool { return false }

var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
Expand Down
8 changes: 6 additions & 2 deletions backend/internal/adapters/agent/droid/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ import (
// Droid's payload shapes differ from Claude Code's in one way that matters here:
// the Notification payload carries no notification_type discriminator (it only
// has a free-form message), but Droid only fires Notification when it needs a
// permission decision or has been idle awaiting input for 60s — both mean the
// agent is blocked on the user — so every Notification maps to waiting_input.
// permission decision or has been idle awaiting input for 60s. AO cannot tell
// which, so every Notification maps to waiting_input: it suppresses automated
// nudges (NeedsInput) in both cases, and blocked is reserved for harnesses that
// can clear it mid-turn via the pre/post-tool-use trio — droid installs no tool
// hooks, so a blocked state would linger until the turn boundary and reject
// user sends at a safely idle prompt.
func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) {
switch event {
case "user-prompt-submit":
Expand Down
7 changes: 4 additions & 3 deletions backend/internal/adapters/agent/droid/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ func TestDeriveActivityState(t *testing.T) {
}{
{"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true},
{"stop -> idle", "stop", `{}`, domain.ActivityIdle, true},
// Droid notifications fire only on permission-needed or 60s-idle, both of
// which mean the agent is blocked on the user — and the payload carries no
// notification_type to discriminate — so every notification is waiting_input.
// Droid notifications fire only on permission-needed or 60s-idle, and the
// payload carries no notification_type to discriminate — so every
// notification maps to waiting_input (an automated Enter must
// never answer a pending permission decision).
{"notification -> waiting_input", "notification", `{"message":"Droid needs your permission"}`, domain.ActivityWaitingInput, true},
{"notification empty payload -> waiting_input", "notification", `{}`, domain.ActivityWaitingInput, true},
{"notification malformed payload -> waiting_input", "notification", `not json`, domain.ActivityWaitingInput, true},
Expand Down
7 changes: 6 additions & 1 deletion backend/internal/adapters/runtime/conpty/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ func clientSendMessage(addr, message string) error {
}

// Brief pause before Enter (matches TS: Enter sent as a separate frame).
time.Sleep(ptyInputEnterDelay)
// Skipped for an empty message — an Enter-only nudge has no paste to let
// settle, and the pause would only widen the guard-read→Enter window
// (mirrors the tmux runtime's enterDelay contract).
if len(runes) > 0 {
time.Sleep(ptyInputEnterDelay)
}
frame, err := EncodeMessage(MsgTerminalInput, []byte("\r"))
if err != nil {
return err
Expand Down
73 changes: 54 additions & 19 deletions backend/internal/adapters/runtime/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import (
const (
defaultTimeout = 5 * time.Second
defaultChunkBytes = 16 * 1024
// defaultEnterDelay mirrors conpty's ptyInputEnterDelay: a pause after pasting
// a non-empty message, before the trailing Enter, so a large multiline paste
// does not absorb the Enter and leave the prompt unsubmitted (issue #2342).
defaultEnterDelay = 300 * time.Millisecond
)

var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
Expand All @@ -32,20 +36,22 @@ var getenv = os.Getenv
// Options configures a tmux Runtime. Every field has a sensible default (see
// New), so the zero value is usable.
type Options struct {
Binary string // default "tmux" (resolved via exec.LookPath)
Shell string // default $SHELL else /bin/sh
Timeout time.Duration // default 5s
ChunkSize int // default 16*1024
Binary string // default "tmux" (resolved via exec.LookPath)
Shell string // default $SHELL else /bin/sh
Timeout time.Duration // default 5s
ChunkSize int // default 16*1024
EnterDelay time.Duration // pause after pasting a non-empty message before pressing Enter; default defaultEnterDelay. Conpty already does this (ptyInputEnterDelay); tmux lacked it, so a large multiline paste could absorb the trailing Enter and leave the prompt unsubmitted (issue #2342).
}

// Runtime runs agent sessions inside tmux sessions, driving them via the tmux
// CLI. It implements ports.Runtime.
type Runtime struct {
binary string
shell string
timeout time.Duration
chunkSize int
runner runner
binary string
shell string
timeout time.Duration
chunkSize int
enterDelay time.Duration
runner runner
}

var _ ports.Runtime = (*Runtime)(nil)
Expand Down Expand Up @@ -90,12 +96,17 @@ func New(opts Options) *Runtime {
if chunkSize <= 0 {
chunkSize = defaultChunkBytes
}
enterDelay := opts.EnterDelay
if enterDelay <= 0 {
enterDelay = defaultEnterDelay
}
return &Runtime{
binary: binary,
shell: shellPath,
timeout: timeout,
chunkSize: chunkSize,
runner: execRunner{},
binary: binary,
shell: shellPath,
timeout: timeout,
chunkSize: chunkSize,
enterDelay: enterDelay,
runner: execRunner{},
}
}

Expand Down Expand Up @@ -190,7 +201,8 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool
}

// SendMessage sends literal text to the session (chunked via send-keys -l) then
// presses Enter to submit.
// presses Enter to submit. An empty message presses Enter alone (the nudge
// contract on ports.AgentMessenger).
//
// ponytail: send-keys -l chunked is simpler than load-buffer/paste-buffer; the
// ceiling is very large messages may be slower, but chunk size defaults to 16 KB
Expand All @@ -200,12 +212,35 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m
if err != nil {
return err
}
for _, chunk := range chunks(message, r.chunkSize) {
if _, err := r.run(ctx, sendKeysLiteralArgs(id, chunk)...); err != nil {
return fmt.Errorf("tmux runtime: send message %s: %w", id, err)
enterCtx := ctx
if message != "" {
for _, chunk := range chunks(message, r.chunkSize) {
if _, err := r.run(ctx, sendKeysLiteralArgs(id, chunk)...); err != nil {
return fmt.Errorf("tmux runtime: send message %s: %w", id, err)
}
}
// Give the target TUI a moment to accept the pasted text before the
// trailing Enter, mirroring conpty's ptyInputEnterDelay. Without it a
// large multiline paste can absorb the Enter and leave the prompt
// unsubmitted (issue #2342). Empty-message nudges skip this — there is
// no paste ahead of a catch-up Enter.
//
// From here on the chunks are already in the pane, so the pause and
// the Enter are detached from the caller's cancellation (bounded by
// their own timeout instead): abandoning mid-pause would strand an
// unsubmitted draft that a retried send would then double-paste.
var cancel context.CancelFunc
enterCtx, cancel = context.WithTimeout(context.WithoutCancel(ctx), r.enterDelay+5*time.Second)
defer cancel()
if r.enterDelay > 0 {
select {
case <-enterCtx.Done():
return enterCtx.Err()
case <-time.After(r.enterDelay):
}
}
}
if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil {
if _, err := r.run(enterCtx, sendEnterArgs(id)...); err != nil {
return fmt.Errorf("tmux runtime: send enter %s: %w", id, err)
}
return nil
Expand Down
Loading
Loading