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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ agentrun (interfaces)
- **Separate examples module**: `examples/go.mod` avoids pulling example deps into library consumers
- **Platform build constraints**: Engine implementations using OS-specific features (signals, process groups) use `//go:build !windows` on implementation files. Interface and option files remain platform-agnostic.
- **Signal safety**: All process Signal/Kill calls use `signalProcess()` helper which handles `os.ErrProcessDone` — prevents errors on already-exited processes
- **Cross-cutting session controls**: `Mode` (plan/act), `HITL` (on/off), and `Effort` (low/medium/high/max) types live in root with `Valid()` methods. Root options and backend-specific options (e.g., `claude.OptionPermissionMode`) are independent control surfaces — root wins when set, backend used when absent. Effort validation runs at engine level (`Start()`) for symmetric spawn/resume coverage. See `resolvePermissionFlag()` in Claude backend and `resolveVariant()` in OpenCode backend.
- **Cross-cutting session controls**: `Mode` (plan/act), `HITL` (on/off), and `Effort` (low/medium/high) types live in root with `Valid()` methods. `OptionSessionName` is a cross-cutting naming option (Claude `--name`, OpenCode `--title`; takes precedence over backend-specific `opencode.OptionTitle`). Root options and backend-specific options (e.g., `claude.OptionPermissionMode`) are independent control surfaces — root wins when set, backend used when absent. Effort validation runs at engine level (`Start()`) for symmetric spawn/resume coverage. See `resolvePermissionFlag()` in Claude backend and `resolveVariant()` in OpenCode backend.
- **Session.Clone()**: Deep-copies Options and Env maps. Used by both CLI and ACP engines in `cloneSession()` — single implementation in root, no "keep in sync" duplication.
- **ValidateModeHITL**: Shared in `engine/cli/internal/optutil` with prefix parameter for error messages. Claude and Codex backends delegate to it.
- **Option parse helpers**: `ParsePositiveIntOption`, `ParseBoolOption`, `StringOption`, `ParseListOption` in `session_options.go` — backends use these instead of scattered `strconv` parsing. Both typed parsers validate null bytes and return `(value, ok, error)`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ session := agentrun.Session{
Options: map[string]string{
agentrun.OptionSystemPrompt: "You are a Go expert.",
agentrun.OptionMode: "act", // plan or act
agentrun.OptionEffort: "high", // low/medium/high/max
agentrun.OptionEffort: "high", // low/medium/high
agentrun.OptionThinkingBudget: "10000", // enable extended thinking
agentrun.OptionHITL: "off", // human-in-the-loop
agentrun.OptionAddDirs: "/shared/lib\n/shared/proto", // newline-separated
Expand Down
4 changes: 2 additions & 2 deletions agentrun_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,13 +335,13 @@ func TestSessionOptions_ResumeID_RoundTrip(t *testing.T) {
}

func TestEffortValid(t *testing.T) {
valid := []Effort{EffortLow, EffortMedium, EffortHigh, EffortMax}
valid := []Effort{EffortLow, EffortMedium, EffortHigh}
for _, e := range valid {
if !e.Valid() {
t.Errorf("Effort(%q).Valid() = false, want true", e)
}
}
invalid := []Effort{"", "invalid", "LOW", "Medium", "xhigh"}
invalid := []Effort{"", "invalid", "LOW", "Medium", "xhigh", "max"}
for _, e := range invalid {
if e.Valid() {
t.Errorf("Effort(%q).Valid() = true, want false", e)
Expand Down
1 change: 1 addition & 0 deletions cmd/agentrun-mcp/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ var allowedOptions = map[string]bool{
agentrun.OptionEffort: true,
agentrun.OptionAgentID: true,
agentrun.OptionAddDirs: true,
agentrun.OptionSessionName: true,
}

// validateOptions rejects any option key not in the allowlist.
Expand Down
1 change: 1 addition & 0 deletions cmd/agentrun-mcp/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ func TestOptionsAllowlist_Allows(t *testing.T) {
"max_turns": "5",
"thinking_budget": "10000",
"effort": "high",
"session_name": "my-session",
}
if err := validateOptions(opts); err != nil {
t.Fatalf("expected nil, got %v", err)
Expand Down
2 changes: 1 addition & 1 deletion engine/acp/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func (e *Engine) Start(ctx context.Context, session agentrun.Session, opts ...ag

// Validate cross-cutting options.
if e := agentrun.Effort(session.Options[agentrun.OptionEffort]); e != "" && !e.Valid() {
return nil, fmt.Errorf("acp: unknown effort %q: valid: low, medium, high, max", e)
return nil, fmt.Errorf("acp: unknown effort %q: valid: low, medium, high", e)
}

// Validate CWD.
Expand Down
16 changes: 16 additions & 0 deletions engine/acp/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,22 @@ func TestEngine_Start_InvalidEffort(t *testing.T) {
}
}

func TestEngine_Start_EffortMaxRejected(t *testing.T) {
engine := newEngine(t)
ctx, cancel := context.WithTimeout(context.Background(), integrationTimeout)
defer cancel()
_, err := engine.Start(ctx, agentrun.Session{
CWD: t.TempDir(),
Options: map[string]string{agentrun.OptionEffort: "max"},
})
if err == nil {
t.Fatal("expected error for effort 'max'")
}
if !strings.Contains(err.Error(), "unknown effort") {
t.Errorf("error = %v, want to contain 'unknown effort'", err)
}
}

func TestEngine_Validate(t *testing.T) {
t.Run("valid binary", func(t *testing.T) {
engine := newEngine(t)
Expand Down
129 changes: 127 additions & 2 deletions engine/cli/claude/args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1184,7 +1184,7 @@ func TestSpawnArgs_Effort(t *testing.T) {
{"low", "low", []string{"--effort", "low"}, nil},
{"medium", "medium", []string{"--effort", "medium"}, nil},
{"high", "high", []string{"--effort", "high"}, nil},
{"max_skipped", "max", nil, []string{"--effort"}},
{"max_invalid", "max", nil, []string{"--effort"}},
{"empty", "", nil, []string{"--effort"}},
{"invalid", "xhigh", nil, []string{"--effort"}},
}
Expand Down Expand Up @@ -1214,7 +1214,7 @@ func TestResumeArgs_Effort(t *testing.T) {
{"low", "low", []string{"--effort", "low"}, nil, false},
{"medium", "medium", []string{"--effort", "medium"}, nil, false},
{"high", "high", []string{"--effort", "high"}, nil, false},
{"max_skipped", "max", nil, []string{"--effort"}, false},
{"max_invalid", "max", nil, nil, true},
{"invalid", "xhigh", nil, nil, true},
}

Expand Down Expand Up @@ -1486,6 +1486,131 @@ func TestSpawnArgs_AllowedToolsPrecedence(t *testing.T) {
}
}

// --- SessionName option tests ---

func TestSpawnArgs_SessionName(t *testing.T) {
tests := []struct {
name string
sessName string
contains []string
excludes []string
}{
{"valid", "my-session", []string{"--name", "my-session"}, nil},
{"null_byte_skipped", "bad\x00name", nil, []string{"--name"}},
{"leading_dash_skipped", "-evil", nil, []string{"--name"}},
{"empty_skipped", "", nil, []string{"--name"}},
}

b := New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session := agentrun.Session{
Prompt: testPrompt,
Options: map[string]string{agentrun.OptionSessionName: tt.sessName},
}
_, args := b.SpawnArgs(session)
assertArgs(t, args, tt.contains, tt.excludes, testPrompt, false)
})
}
}

func TestStreamArgs_SessionName(t *testing.T) {
b := New()
session := agentrun.Session{
Options: map[string]string{agentrun.OptionSessionName: "stream-session"},
}
_, args := b.StreamArgs(session)
assertArgs(t, args, []string{"--name", "stream-session"}, nil, "", false)
}

func TestResumeArgs_SessionName(t *testing.T) {
b := New()
session := agentrun.Session{
Options: map[string]string{
agentrun.OptionResumeID: testResumeID,
agentrun.OptionSessionName: "resume-session",
},
}
_, args, err := b.ResumeArgs(session, testPrompt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertArgs(t, args, []string{"--name", "resume-session"}, nil, testPrompt, false)
}

// --- RemoteControl option tests ---

func TestSpawnArgs_RemoteControl(t *testing.T) {
tests := []struct {
name string
value string
contains []string
excludes []string
}{
{"true", "true", []string{"--remote-control"}, nil},
{"one", "1", []string{"--remote-control"}, nil},
{"on", "on", []string{"--remote-control"}, nil},
{"yes", "yes", []string{"--remote-control"}, nil},
{"false", "false", nil, []string{"--remote-control"}},
{"zero", "0", nil, []string{"--remote-control"}},
{"empty", "", nil, []string{"--remote-control"}},
{"invalid", "maybe", nil, []string{"--remote-control"}},
}

b := New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session := agentrun.Session{
Prompt: testPrompt,
Options: map[string]string{OptionRemoteControl: tt.value},
}
_, args := b.SpawnArgs(session)
assertArgs(t, args, tt.contains, tt.excludes, testPrompt, false)
})
}
}

func TestStreamArgs_RemoteControl(t *testing.T) {
b := New()
session := agentrun.Session{
Options: map[string]string{OptionRemoteControl: "true"},
}
_, args := b.StreamArgs(session)
assertArgs(t, args, []string{"--remote-control"}, nil, "", false)
}

func TestResumeArgs_RemoteControl(t *testing.T) {
b := New()
session := agentrun.Session{
Options: map[string]string{
agentrun.OptionResumeID: testResumeID,
OptionRemoteControl: "true",
},
}
_, args, err := b.ResumeArgs(session, testPrompt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertArgs(t, args, []string{"--remote-control"}, nil, testPrompt, false)
}

func TestResumeArgs_RemoteControl_Invalid(t *testing.T) {
b := New()
session := agentrun.Session{
Options: map[string]string{
agentrun.OptionResumeID: testResumeID,
OptionRemoteControl: "maybe",
},
}
_, _, err := b.ResumeArgs(session, testPrompt)
if err == nil {
t.Fatal("expected error for invalid remote_control value")
}
if !strings.Contains(err.Error(), "invalid remote_control") {
t.Errorf("error = %v, want to contain 'invalid remote_control'", err)
}
}

// --- Helpers ---

func assertArgs(t *testing.T, args, contains, excludes []string, last string, noNullByte bool) {
Expand Down
23 changes: 21 additions & 2 deletions engine/cli/claude/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const (
// of which mode is active. The CLI decides enforcement semantics.
// Backend-specific: only Claude CLI supports --allowedTools.
OptionAllowedTools = "claude.allowed_tools"

// OptionRemoteControl enables Claude Code's MCP remote control mode.
// When set to a truthy value ("true", "1", "on", "yes"), adds
// --remote-control to the CLI arguments.
// Backend-specific: only Claude CLI supports --remote-control.
OptionRemoteControl = "claude.remote_control"
)

// validResumeID matches safe Claude session identifiers.
Expand Down Expand Up @@ -250,14 +256,24 @@ func appendSessionArgs(args []string, session agentrun.Session) []string {
args = appendPositiveInt(args, session.Options, agentrun.OptionMaxTurns, "--max-turns")
args = appendPositiveInt(args, session.Options, agentrun.OptionThinkingBudget, "--max-thinking-tokens")

// Effort: Claude CLI supports low, medium, high (max has no equivalent).
if e := agentrun.Effort(session.Options[agentrun.OptionEffort]); e.Valid() && e != agentrun.EffortMax {
// Effort: Claude CLI supports low, medium, high.
if e := agentrun.Effort(session.Options[agentrun.OptionEffort]); e.Valid() {
args = append(args, "--effort", string(e))
}

// Additional directories.
args = optutil.AppendAddDirs(args, session.Options, "--add-dir")

// Session name.
if name := session.Options[agentrun.OptionSessionName]; name != "" && !jsonutil.ContainsNull(name) && !strings.HasPrefix(name, "-") {
args = append(args, "--name", name)
}

// Remote control.
if rc, ok, _ := agentrun.ParseBoolOption(session.Options, OptionRemoteControl); ok && rc {
args = append(args, "--remote-control")
}

// Allowed tools — orthogonal to permission mode (always appended when set).
for _, tool := range agentrun.ParseListOption(session.Options, OptionAllowedTools) {
if !strings.HasPrefix(tool, "-") {
Expand Down Expand Up @@ -330,6 +346,9 @@ func validateSessionOptions(opts map[string]string) error {
if err := validatePositiveIntOption(opts, agentrun.OptionThinkingBudget, "thinking budget"); err != nil {
return err
}
if _, _, err := agentrun.ParseBoolOption(opts, OptionRemoteControl); err != nil {
return fmt.Errorf("claude: invalid remote_control: %w", err)
}
return optutil.ValidateEffort("claude", opts)
}

Expand Down
3 changes: 3 additions & 0 deletions engine/cli/claude/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
//
// - [agentrun.OptionMode] — sets session intent ("plan" or "act")
// - [agentrun.OptionHITL] — controls human-in-the-loop ("on" or "off")
// - [agentrun.OptionSessionName] — sets --name (human-readable session name)
//
// When OptionMode or OptionHITL are set, they map to --permission-mode:
//
Expand All @@ -67,4 +68,6 @@
// Claude-specific options:
//
// - [OptionPermissionMode] — sets --permission-mode (use [PermissionMode] values)
// - [OptionAllowedTools] — sets --allowedTools (newline-separated tool names)
// - [OptionRemoteControl] — sets --remote-control (truthy boolean enables MCP remote control)
package claude
4 changes: 1 addition & 3 deletions engine/cli/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,10 @@ func buildResumeCommand(threadID string, session agentrun.Session) []string {
}

// codexEffort maps root Effort values to Codex model_reasoning_effort values.
// max → "xhigh" is a Codex-specific mapping.
var codexEffort = map[agentrun.Effort]string{
agentrun.EffortLow: "low",
agentrun.EffortMedium: "medium",
agentrun.EffortHigh: "high",
agentrun.EffortMax: "xhigh",
}

// appendCommonArgs appends flags available on both exec and exec resume.
Expand All @@ -233,7 +231,7 @@ func appendCommonArgs(args []string, session agentrun.Session) []string {
args = append(args, "--skip-git-repo-check")
}

// Effort: Codex supports low, medium, high, max (max → "xhigh").
// Effort: Codex supports low, medium, high.
if e := agentrun.Effort(session.Options[agentrun.OptionEffort]); e != "" {
if v, ok := codexEffort[e]; ok {
args = append(args, "-c", "model_reasoning_effort="+v)
Expand Down
1 change: 0 additions & 1 deletion engine/cli/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,6 @@ func TestSpawnArgs_Effort(t *testing.T) {
{"low", "low", "model_reasoning_effort=low"},
{"medium", "medium", "model_reasoning_effort=medium"},
{"high", "high", "model_reasoning_effort=high"},
{"max_to_xhigh", "max", "model_reasoning_effort=xhigh"},
}

b := New()
Expand Down
20 changes: 20 additions & 0 deletions engine/cli/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1707,6 +1707,26 @@ func TestStart_InvalidEffort(t *testing.T) {
}
}

func TestStart_EffortMaxRejected(t *testing.T) {
b := withResumer(testBackend{
spawnFn: func(_ agentrun.Session) (string, []string) {
return binEcho, []string{"x"}
},
parseFn: textParser,
})
eng := cli.NewEngine(b)
_, err := eng.Start(testCtx(t), agentrun.Session{
CWD: tempDir(t),
Options: map[string]string{agentrun.OptionEffort: "max"},
})
if err == nil {
t.Fatal("expected error for effort 'max'")
}
if !strings.Contains(err.Error(), "unknown effort") {
t.Errorf("error = %v, want to contain 'unknown effort'", err)
}
}

// ---------------------------------------------------------------------------
// Concurrency tests
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion engine/cli/internal/optutil/optutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func ValidateModeHITL(prefix string, opts map[string]string) error {
// The prefix is used in error messages (e.g., "claude", "codex").
func ValidateEffort(prefix string, opts map[string]string) error {
if e := agentrun.Effort(opts[agentrun.OptionEffort]); e != "" && !e.Valid() {
return fmt.Errorf("%s: unknown effort %q: valid: low, medium, high, max", prefix, e)
return fmt.Errorf("%s: unknown effort %q: valid: low, medium, high", prefix, e)
}
return nil
}
Expand Down
5 changes: 4 additions & 1 deletion engine/cli/opencode/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@
// - OptionAgentID → --agent <id>
// - OptionThinkingBudget → --thinking (boolean: any non-empty value)
//
// Cross-cutting (root package — session controls):
// - OptionSessionName → --title (takes precedence over OptionTitle)
//
// Cross-cutting (root package — session resume):
// - OptionResumeID → --session (auto-captured or explicit cold resume)
// Consumers capture the session ID from MessageInit.ResumeID.
//
// Backend-specific (namespaced with "opencode." prefix):
// - OptionVariant → --variant (VariantHigh, VariantMax, VariantMinimal, VariantLow)
// - OptionFork → --fork (fork session on resume)
// - OptionTitle → --title (session title, max 512 bytes)
// - OptionTitle → --title (session title, max 512 bytes; overridden by root OptionSessionName)
//
// # Event types
//
Expand Down
Loading