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
16 changes: 11 additions & 5 deletions internal/adapter/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import (

func New(backend adapter.Backend) adapter.AgentAdapter {
agent := adapter.NewAgent("copilot", "GitHub Copilot", []adapter.CommandCandidate{{Display: "copilot", Args: []string{"copilot"}}}, []string{"--yolo"}, backend)
agent.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative}
// copilot supports exact-session resume natively: --name seeds the new
// session's name with the uam id at dispatch, and --resume matches it
// exactly (case-insensitive) on resume.
// Copilot binds left arrow to its own pane navigation at an empty
// composer, so the attach client's quick detach would steal the key.
agent.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative, BackDetach: adapter.BackDetachDisabled}
// copilot supports exact-session resume natively. The uam id is a UUID,
// so --session-id pins the new Copilot session's primary id to it at
// dispatch and --resume then matches by session id — the most stable
// lookup the CLI offers. --name seeds the same value as the session name
// for display and as a fallback match: sessions dispatched by older uam
// versions carry the uam id only as a name, and --resume falls back to
// exact (case-insensitive) name matching for them.
agent.SessionArgs = func(req adapter.ResumeRequest, activity string) []string {
if req.ID == "" {
return nil
}
if activity == "dispatched" {
return []string{"--name", req.ID}
return []string{"--session-id", req.ID, "--name", req.ID}
}
return []string{"--resume=" + req.ID}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/adapter/copilot/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func TestDispatchSeedsCopilotSessionIDForFutureResume(t *testing.T) {
t.Fatalf("Dispatch: %v", err)
}
argv := be.CommandLog()
if !strings.Contains(argv, "--session-id "+sess.ID) {
t.Fatalf("copilot dispatch should pin the provider session id to the UAM id: %s", argv)
}
if !strings.Contains(argv, "--name "+sess.ID) {
t.Fatalf("copilot dispatch should name the provider session with the UAM id: %s", argv)
}
Expand Down
19 changes: 19 additions & 0 deletions internal/adapter/terminal_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,26 @@ type KeyProtocolPolicy string

const KeyProtocolNative KeyProtocolPolicy = "native"

// BackDetachPolicy is a provider's default for the attach client's quick
// detach (a bare left arrow while the input box is empty detaches). The
// gesture assumes left arrow is a no-op at an empty prompt; providers that
// bind it to their own UI (pane or tab navigation) disable the default.
// Profiles and session overrides still take precedence either way.
type BackDetachPolicy string

const (
// BackDetachDefault leaves the quick detach enabled (the zero value).
BackDetachDefault BackDetachPolicy = ""
// BackDetachDisabled turns the quick detach off unless a profile or
// override explicitly enables it.
BackDetachDisabled BackDetachPolicy = "disabled"
)

type ProviderTerminalPolicy struct {
Identity ProviderIdentity
OuterScreen OuterScreenPolicy
KeyProtocol KeyProtocolPolicy
BackDetach BackDetachPolicy
}

type TerminalPolicyAdapter interface {
Expand All @@ -44,6 +60,9 @@ func (p ProviderTerminalPolicy) Validate() error {
if p.KeyProtocol != KeyProtocolNative {
return fmt.Errorf("invalid provider key-protocol policy %q", p.KeyProtocol)
}
if p.BackDetach != BackDetachDefault && p.BackDetach != BackDetachDisabled {
return fmt.Errorf("invalid provider back-detach policy %q", p.BackDetach)
}
return nil
}

Expand Down
8 changes: 5 additions & 3 deletions internal/agents/provider_terminal_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import (

func TestProviderTerminalPolicyMatrix(t *testing.T) {
want := map[string]adapter.ProviderTerminalPolicy{
"claude": {Identity: adapter.ProviderClaude, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative},
"codex": {Identity: adapter.ProviderCodex, OuterScreen: adapter.OuterScreenPrimary, KeyProtocol: adapter.KeyProtocolNative},
"copilot": {Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative},
"claude": {Identity: adapter.ProviderClaude, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative},
"codex": {Identity: adapter.ProviderCodex, OuterScreen: adapter.OuterScreenPrimary, KeyProtocol: adapter.KeyProtocolNative},
// copilot binds left arrow to pane navigation at an empty composer,
// so the quick-detach gesture is disabled by default for it.
"copilot": {Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative, BackDetach: adapter.BackDetachDisabled},
"hermes": {Identity: adapter.ProviderHermes, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative},
// omp sets no mouse modes and never enters ?1049 — verified on a PTY —
// so like codex it owns the primary screen and its scrollback.
Expand Down
5 changes: 4 additions & 1 deletion internal/app/profile_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ func ResolveProfilePolicy(input ResolutionInput) (EffectivePolicy, error) {
scrollbackLines: defaultScrollbackLines, term: fixedTERM,
},
attachment: attachmentDefaults{
mouse: store.MousePolicyAuto, controlPrefix: defaultControlPrefix, backDetach: true,
mouse: store.MousePolicyAuto, controlPrefix: defaultControlPrefix,
// The provider's terminal policy supplies the quick-detach
// default; profiles and session overrides below still win.
backDetach: input.ProviderPolicy.BackDetach != adapter.BackDetachDisabled,
},
}
if policy.launch.provider == "" {
Expand Down
41 changes: 41 additions & 0 deletions internal/app/profile_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,44 @@ func allClientCapabilities() ClientCapabilities {
}

func pointer[T any](value T) *T { return &value }

func TestProviderBackDetachPolicySetsTheDefault(t *testing.T) {
cfg := store.DefaultConfig()
record := store.SessionRecord{Agent: "copilot"}
policy := adapter.ProviderTerminalPolicy{
Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM,
KeyProtocol: adapter.KeyProtocolNative, BackDetach: adapter.BackDetachDisabled,
}
effective, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: record, ProviderPolicy: policy})
if err != nil {
t.Fatal(err)
}
attachment, err := effective.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities())
if err != nil {
t.Fatal(err)
}
if attachment.BackDetach() {
t.Fatal("provider back-detach disabled policy should default the quick detach off")
}
}

func TestProfileBackDetachOverridesProviderPolicy(t *testing.T) {
cfg := store.DefaultConfig()
cfg.Profiles["gesture"] = store.Profile{BackDetach: pointer(true)}
record := store.SessionRecord{Agent: "copilot", Profile: "gesture"}
policy := adapter.ProviderTerminalPolicy{
Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM,
KeyProtocol: adapter.KeyProtocolNative, BackDetach: adapter.BackDetachDisabled,
}
effective, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: record, ProviderPolicy: policy})
if err != nil {
t.Fatal(err)
}
attachment, err := effective.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities())
if err != nil {
t.Fatal(err)
}
if !attachment.BackDetach() {
t.Fatal("an explicit profile back-detach must win over the provider default")
}
}
48 changes: 48 additions & 0 deletions internal/session/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ type host struct {
label string
state State
registry *clientRegistry
// providerFocused records whether the host has told the agent its
// terminal is focused (synthetic ?1004 focus events, guarded by mu). Two
// paths can observe the same attach — the attach initializer and the PTY
// pump seeing ?1004h turn on — and the flag keeps them from both firing.
providerFocused bool

child *exec.Cmd
// exited is closed once the agent process has been reaped; the kill
Expand Down Expand Up @@ -245,6 +250,24 @@ func runHost(dir string, spec hostLaunchSpec, ready *os.File) error {

// pumpPTY copies agent output into the emulator and to every attached client
// until the PTY reaches EOF (agent exit).
// focusIn and focusOut are the xterm focus-tracking events an application
// with ?1004 enabled expects from its terminal. The host synthesizes them at
// controller attach/detach boundaries (and on late ?1004 enablement) because
// the real terminal's focus events can only reach the agent while a client is
// attached — see initializeAttachClient and removeClient.
var focusIn = []byte("\x1b[I")
var focusOut = []byte("\x1b[O")

// writeFocusEvent injects a synthetic focus event into the agent's input.
// Like applyPTYSize it is deliberately unserialised against controller input:
// the event is a complete three-byte sequence, and callers may hold neither
// mutex (pumpPTY) or only controlMu (attach/drop paths).
func (h *host) writeFocusEvent(event []byte) {
if _, err := h.ptmx.Write(event); err != nil {
log.Debug("write synthetic focus event failed", "session", h.name, "error", err)
}
}

func (h *host) pumpPTY() {
buf := make([]byte, 32*1024)
for {
Expand All @@ -254,8 +277,23 @@ func (h *host) pumpPTY() {
copy(data, buf[:n])
h.mu.Lock()
_, _ = h.term.Write(data)
if !h.term.FocusReporting() {
// The mode is off (or was just turned off): a later re-enable
// deserves a fresh focus-in.
h.providerFocused = false
}
// An agent that enables ?1004 after a controller attached (every
// resume works this way: the client attaches while the replacement
// process is still starting) missed the attach-time focus-in.
focusGained := h.term.FocusReporting() && h.registry.controller != nil && !h.providerFocused
if focusGained {
h.providerFocused = true
}
clients := h.registry.readyClients()
h.mu.Unlock()
if focusGained {
h.writeFocusEvent(focusIn)
}
for _, client := range clients {
h.enqueueClient(client, serverMessage{kind: serverFramePTY, payload: data})
}
Expand Down Expand Up @@ -593,7 +631,17 @@ func (h *host) removeClient(client *attachClient, reason string) {
promotedReplay = h.term.Redraw()
}
}
// The counterpart of the attach-time synthetic focus-in: with no
// controller left there is no terminal whose focus the agent could hold.
focusLost := registered && wasController && h.registry.controller == nil &&
h.term != nil && h.term.FocusReporting() && h.providerFocused
if focusLost {
h.providerFocused = false
}
h.mu.Unlock()
if focusLost {
h.writeFocusEvent(focusOut)
}
if promotedSize.valid() {
h.applyPTYSize(promotedSize)
}
Expand Down
13 changes: 13 additions & 0 deletions internal/session/host_attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,20 @@ func (h *host) initializeAttachClient(client *attachClient, registration clientR
}
client.out <- serverMessage{kind: serverFramePTY, payload: append([]byte(titleSequence(label)), h.term.Redraw()...)}
client.ready = true
focusGained := controls && h.term.FocusReporting() && !h.providerFocused
if focusGained {
h.providerFocused = true
}
h.mu.Unlock()
if focusGained {
// The agent asked for focus events (?1004) but runs under a detached
// host, so no terminal ever tells it a window gained focus. A
// controller attaching is that event; without it agents that dim or
// lock their input box while unfocused stay that way. Client
// terminals that implement ?1004 may also send their own focus-in
// once the replay enables the mode — a duplicate is harmless.
h.writeFocusEvent(focusIn)
}
if !controls || !registration.size.valid() {
return
}
Expand Down
104 changes: 104 additions & 0 deletions internal/session/host_focus_events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package session

import (
"bufio"
"context"
"net"
"strings"
"testing"
)

// attachControllerConn dials the host socket and completes a v1 controller
// attach, returning the open connection. The caller owns closing it.
func attachControllerConn(t *testing.T, c *Client, name string) net.Conn {
t.Helper()
conn, err := net.Dial("unix", SocketPath(c.Dir, name))
if err != nil {
t.Fatal(err)
}
if err := writeJSONLine(conn, request{Op: opAttach, Cols: 120, Rows: 40}); err != nil {
t.Fatal(err)
}
var resp response
if err := readJSONLine(bufio.NewReader(conn), &resp); err != nil || !resp.OK {
t.Fatalf("attach resp: %+v %v", resp, err)
}
return conn
}

// A provider with focus reporting (?1004) enabled runs under a detached host,
// so no terminal ever tells it about focus changes. The host must synthesize
// focus-in when a controller attaches and focus-out when the last controller
// detaches. The fake agent enables ?1004 before echoing stdin via cat -v, so
// the synthesized events become visible in the capture as ^[[I / ^[[O once a
// newline flushes the canonical input buffer.
func TestHostSynthesizesFocusEventsAtAttachBoundaries(t *testing.T) {
c := newTestClient(t)
ctx := context.Background()
name := "uam-fake-56565656"
done := startInProcessHost(t, c, name, `printf '\033[?1004h'; echo armed; cat -v`)
defer func() {
_ = c.Kill(ctx, name)
<-done
}()
waitFor(t, "focus reporting armed", func() bool {
out, err := c.Capture(ctx, name, 50)
return err == nil && strings.Contains(out, "armed")
})

conn := attachControllerConn(t, c, name)
defer func() { _ = conn.Close() }()
// The synthetic focus-in has no newline; a carriage return flushes the
// agent's canonical input buffer so cat -v echoes what preceded it. Sent
// inside the poll because the attach response can race the host's
// initialization of the new client.
waitFor(t, "synthetic focus-in", func() bool {
_ = writeFrame(conn, frameStdin, []byte("\r"))
out, _ := c.Capture(ctx, name, 50)
return strings.Contains(out, "^[[I")
})

if err := writeFrame(conn, frameDetach, nil); err != nil {
t.Fatal(err)
}
waitFor(t, "synthetic focus-out", func() bool {
// Flushes out-of-band; rejected with SessionBusy until the host has
// actually dropped the controller, which the poll absorbs.
_ = c.SendLine(ctx, name, "")
captured, err := c.Capture(ctx, name, 50)
return err == nil && strings.Contains(captured, "^[[O")
})
}

// The resume path attaches the controller while the replacement provider
// process is still starting: ?1004 comes on only after the attach. The host
// must synthesize the missed focus-in the moment the mode turns on.
func TestHostSynthesizesFocusInWhenModeArmsAfterAttach(t *testing.T) {
c := newTestClient(t)
ctx := context.Background()
name := "uam-fake-78787878"
done := startInProcessHost(t, c, name, `echo waiting; read go; printf '\033[?1004h'; echo armed; cat -v`)
defer func() {
_ = c.Kill(ctx, name)
<-done
}()
waitFor(t, "agent waiting", func() bool {
out, err := c.Capture(ctx, name, 50)
return err == nil && strings.Contains(out, "waiting")
})

conn := attachControllerConn(t, c, name)
defer func() { _ = conn.Close() }()
if err := writeFrame(conn, frameStdin, []byte("go\r")); err != nil {
t.Fatal(err)
}
waitFor(t, "focus reporting armed after attach", func() bool {
out, _ := c.Capture(ctx, name, 50)
return strings.Contains(out, "armed")
})
waitFor(t, "late synthetic focus-in", func() bool {
_ = writeFrame(conn, frameStdin, []byte("\r"))
out, _ := c.Capture(ctx, name, 50)
return strings.Contains(out, "^[[I")
})
}
7 changes: 6 additions & 1 deletion internal/session/todo11_fake_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ func TestTodo11FakeProviderProcess(t *testing.T) {
t.Fatal(err)
}
pending = pending[:0]
if bytes.Equal(record, todo11ProviderExit) {
// The exit marker may carry a leading synthetic focus-in: the
// fixture provider enables ?1004, so the host injects \x1b[I
// when a controller attaches (and \x1b[O when the last one
// detaches), and those bytes merge into whatever record the
// next delimiter closes.
if bytes.HasSuffix(record, todo11ProviderExit) {
return
}
continue
Expand Down
7 changes: 5 additions & 2 deletions internal/session/todo8_terminal_policy_manual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,12 @@ func TestTodo8TerminalPolicyRealPTYFixture(t *testing.T) {
if _, err := first.ptmx.Write(payload); err != nil {
t.Fatal(err)
}
// The provider enabled ?1004, so the host synthesized a focus-in when the
// first controller attached; everything after it must be byte-exact.
expectedInput := append(append([]byte{}, focusIn...), payload...)
waitFor(t, "byte-exact provider input", func() bool {
got, err := os.ReadFile(providerInputPath)
return err == nil && bytes.Equal(got, payload)
return err == nil && bytes.Equal(got, expectedInput)
})
providerInput, err := os.ReadFile(providerInputPath)
if err != nil {
Expand Down Expand Up @@ -85,7 +88,7 @@ func TestTodo8TerminalPolicyRealPTYFixture(t *testing.T) {
SharedProviderMarker: bytes.Contains(firstLiveOutput, []byte("TASK8-READY")) && bytes.Contains(secondLiveOutput, []byte("TASK8-READY")),
FirstPreservedMouse: containsEnabledDECMode(firstLiveOutput, "1000") && containsEnabledDECMode(firstLiveOutput, "1006"),
SecondFilteredMouse: !containsEnabledDECMode(secondLiveOutput, "1000") && !containsEnabledDECMode(secondLiveOutput, "1006"),
ProviderInputExact: bytes.Equal(providerInput, payload),
ProviderInputExact: bytes.Equal(providerInput, expectedInput),
RuntimeClean: true,
}
if !assertion.SharedProviderMarker || !assertion.FirstPreservedMouse || !assertion.SecondFilteredMouse || !assertion.ProviderInputExact {
Expand Down
Loading
Loading