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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.11] - 2026-07-23

### Added

- **Buzz orchestrator**: Detection for Block's Buzz (`AgentTypeBuzz`), a chat/agent platform whose `buzz-acp` harness spawns Claude Code / Codex / Goose over ACP. Detects via `ORCHESTRATOR_ENV=buzz` (generic hatch), `BUZZ_ACP_AGENT_COMMAND` (best-effort env secondary), or — the reliable, config-independent signal — the `buzz-acp` process appearing in the ancestry chain. Deliberately does not sniff the workstation-scoped `BUZZ_RELAY_URL` / `BUZZ_PRIVATE_KEY`, which would false-tag a plain agent session in a Buzz user's shell.
- **`Environment.ProcessAncestry()`**: New interface method returning ancestor process executable base names (parent → PID 1). Unix walks a single `ps` snapshot in memory (no new dependency, works on macOS/BSD/Linux); Windows is a stub (falls back to env-var signals), matching ox's own `internal/proc` approach. Enables detecting orchestrators that spawn agents without setting a marker env var.

## [0.1.10] - 2026-05-08

### Added
Expand Down
2 changes: 2 additions & 0 deletions agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ const (
AgentTypeOpenClaw AgentType = "openclaw"
AgentTypeConductor AgentType = "conductor"
AgentTypeGasCity AgentType = "gascity"
AgentTypeBuzz AgentType = "buzz"
)

// SupportedAgents is the canonical list of coding agents and orchestrators
Expand Down Expand Up @@ -159,6 +160,7 @@ var SupportedAgents = []AgentType{
AgentTypeOpenClaw,
AgentTypeConductor,
AgentTypeGasCity,
AgentTypeBuzz,
}

// AgentIdentity provides basic agent identification.
Expand Down
1 change: 1 addition & 0 deletions agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func TestSupportedAgents(t *testing.T) {
AgentTypeOpenClaw,
AgentTypeConductor,
AgentTypeGasCity,
AgentTypeBuzz,
}

assert.Equal(t, len(expected), len(SupportedAgents), "SupportedAgents count mismatch")
Expand Down
20 changes: 20 additions & 0 deletions environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ type Environment interface {

// Stat returns file info.
Stat(path string) (os.FileInfo, error)

// ProcessAncestry returns the executable base names of ancestor processes,
// starting from the current process's parent and walking toward PID 1.
// It exists so detectors can identify an orchestrator that spawns agents
// without setting a marker env var (e.g. Buzz's buzz-acp harness, which is
// always an ancestor of the agent it launches). Best-effort: returns an
// empty slice, not an error, on platforms or hosts where the process tree
// can't be read, so callers can cleanly fall back to env-var signals.
ProcessAncestry() ([]string, error)
}

// SystemEnvironment implements Environment using the real system.
Expand Down Expand Up @@ -203,6 +212,11 @@ func (e *SystemEnvironment) Stat(path string) (os.FileInfo, error) {
return os.Stat(path)
}

func (e *SystemEnvironment) ProcessAncestry() ([]string, error) {
// Delegates to a build-tagged helper (unix uses `ps`, windows is a stub).
return processAncestry()
}

// MockEnvironment is a test implementation of Environment.
type MockEnvironment struct {
EnvVars map[string]string
Expand All @@ -225,6 +239,8 @@ type MockEnvironment struct {
RemoveErrors map[string]error // inject remove errors by path
DirEntries map[string][]os.DirEntry // mock ReadDir results
StatErrors map[string]error // inject stat errors by path
Ancestry []string // mock ProcessAncestry result (parent-first)
AncestryError error // inject a ProcessAncestry error
}

// NewMockEnvironment creates a mock environment for testing.
Expand Down Expand Up @@ -404,3 +420,7 @@ func (e *MockEnvironment) Stat(path string) (os.FileInfo, error) {
}
return nil, os.ErrNotExist
}

func (e *MockEnvironment) ProcessAncestry() ([]string, error) {
return e.Ancestry, e.AncestryError
}
79 changes: 79 additions & 0 deletions environment_ancestry_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//go:build !windows

package agentx

import (
"os"
"os/exec"
"strconv"
"strings"
)

// processAncestry returns the executable base names of ancestor processes,
// starting from the current process's parent and walking toward PID 1.
//
// It takes a single `ps` snapshot (pid, ppid, comm) and walks the parent map in
// memory rather than exec-ing once per level, and it does not read /proc — so it
// works on macOS and the BSDs as well as Linux. This mirrors the approach in
// ox's internal/proc and is deliberately dependency-free (no golang.org/x/sys).
//
// Best-effort by contract: any failure returns (nil, err) and the caller falls
// back to env-var signals.
func processAncestry() ([]string, error) {
out, err := exec.Command("ps", "-A", "-o", "pid=,ppid=,comm=").Output()
if err != nil {
return nil, err
}
return walkAncestry(string(out), os.Getppid()), nil
}

// walkAncestry parses `ps -A -o pid=,ppid=,comm=` output into a pid → parent map
// and returns the executable base names from startPPID up toward PID 1, capped at
// 20 levels. Extracted from processAncestry as a pure, deterministic function so
// the parsing and tree-walking logic is unit-testable without a real `ps`.
func walkAncestry(psOutput string, startPPID int) []string {
type parent struct {
ppid int
name string
}
procs := make(map[int]parent)
for _, line := range strings.Split(psOutput, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
pid, err := strconv.Atoi(fields[0])
if err != nil {
continue
}
ppid, err := strconv.Atoi(fields[1])
if err != nil {
continue
}
// comm can contain spaces (a full path on macOS); rejoin the remaining
// fields, then reduce to the base name.
name := strings.Join(fields[2:], " ")
if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:]
}
procs[pid] = parent{ppid: ppid, name: name}
}

var chain []string
pid := startPPID
for range 20 {
if pid <= 1 {
break
}
p, ok := procs[pid]
if !ok {
break
}
chain = append(chain, p.name)
if p.ppid == pid { // defensive: never loop on a self-parent
break
}
pid = p.ppid
}
return chain
}
132 changes: 132 additions & 0 deletions environment_ancestry_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//go:build !windows

package agentx

import (
"fmt"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestWalkAncestry(t *testing.T) {
// A realistic `ps -A -o pid=,ppid=,comm=` snapshot: an ox process launched
// under Claude, which was spawned by buzz-acp, under a login shell.
snapshot := strings.Join([]string{
" 1 0 launchd",
" 200 1 zsh",
" 300 200 buzz-acp",
" 400 300 claude-agent-acp",
" 500 400 /opt/homebrew/bin/ox", // full path -> base name
" 600 500 ps",
}, "\n")

tests := []struct {
name string
psOutput string
startPPID int
want []string
why string
}{
{
name: "full chain, PID 1 excluded, path base-named",
psOutput: snapshot,
startPPID: 500,
want: []string{"ox", "claude-agent-acp", "buzz-acp", "zsh"},
why: "walk climbs toward init but stops before PID 1 (never an orchestrator); a full path is reduced to its base name",
},
{
name: "start mid-chain",
psOutput: snapshot,
startPPID: 300,
want: []string{"buzz-acp", "zsh"},
why: "walk begins at startPPID, not the leaf, and still excludes PID 1",
},
{
name: "unknown start pid yields empty",
psOutput: snapshot,
startPPID: 9999,
want: nil,
why: "a pid absent from the snapshot must not fabricate a chain",
},
{
name: "empty output",
psOutput: "",
startPPID: 500,
want: nil,
why: "no ps data must yield an empty chain, not a panic",
},
{
name: "malformed lines are skipped",
psOutput: strings.Join([]string{
"garbage header line",
" abc 200 notanumber-pid", // non-numeric pid -> skip
" 300 xyz bad-ppid", // non-numeric ppid -> skip
" 200 1 zsh",
" 300 200 buzz-acp",
}, "\n"),
startPPID: 300,
want: []string{"buzz-acp", "zsh"},
why: "unparseable rows must be ignored, valid rows still walked",
},
{
name: "comm containing spaces is preserved then base-named",
psOutput: strings.Join([]string{
" 200 1 zsh",
" 300 200 /Applications/My App.app/Contents/MacOS/My App",
}, "\n"),
startPPID: 300,
want: []string{"My App", "zsh"},
why: "comm may contain spaces; base name is everything after the last slash",
},
{
name: "self-parent does not infinite-loop",
psOutput: strings.Join([]string{
" 300 300 stuck", // ppid == pid
}, "\n"),
startPPID: 300,
want: []string{"stuck"},
why: "a self-referential parent must terminate the walk after one hop",
},
{
name: "walk is capped at 20 levels",
psOutput: deepChain(30),
startPPID: 30,
want: cappedNames(30, 20),
why: "an unexpectedly deep tree must not walk unbounded",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := walkAncestry(tt.psOutput, tt.startPPID)
assert.Equal(t, tt.want, got, tt.why)
})
}
}

// deepChain builds a linear ps snapshot of n processes: pid k has ppid k-1,
// name "p<k>", with p1's parent being PID 1's sentinel (0).
func deepChain(n int) string {
var b strings.Builder
for k := 1; k <= n; k++ {
fmt.Fprintf(&b, "%d %d p%d\n", k, k-1, k)
}
return b.String()
}

// cappedNames returns the names the walk should collect starting at `start` and
// climbing (start, start-1, ...) for at most `cap` levels, stopping at pid<=1.
func cappedNames(start, cap int) []string {
var names []string
pid := start
for range cap {
if pid <= 1 {
break
}
names = append(names, fmt.Sprintf("p%d", pid))
pid--
}
return names
}
14 changes: 14 additions & 0 deletions environment_ancestry_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build windows

package agentx

// processAncestry is a stub on Windows. Walking the process tree there needs the
// Toolhelp32 snapshot API (golang.org/x/sys/windows), and agentx is intentionally
// dependency-free; ox's own internal/proc stubs Windows ancestry for the same
// reason. Buzz's buzz-acp harness is a macOS/Linux dev tool today, so on Windows
// BuzzAgent.Detect() cleanly falls back to its ORCHESTRATOR_ENV /
// BUZZ_ACP_AGENT_COMMAND env-var signals. Implement via Toolhelp32 if/when
// Buzz-on-Windows detection is needed.
func processAncestry() ([]string, error) {
return nil, nil
}
Loading
Loading