diff --git a/cmd/attach.go b/cmd/attach.go index a199ac5..a2be156 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -43,16 +43,16 @@ func runAttach(cmd *cobra.Command, args []string) error { return fmt.Errorf("updating metadata: %w", err) } - agent := attachAgent - if agent == "" { - agent = ws.Meta.Agent.Type + resolved, err := resolveEntryAgent(ws.Meta.Agent, attachAgent) + if err != nil { + return err } return runWorkspaceEntry(WorkspaceEntryOptions{ WsPath: ws.Path, WsRoot: ws.Root, WsMeta: ws.Meta, - Agent: agent, + ResolvedAgent: resolved, Shell: false, // attach defaults to agent Force: attachForce, AlwaysPersistent: true, // attach is always tmux, regardless of env diff --git a/cmd/entry.go b/cmd/entry.go index 7f65c6c..33725c4 100644 --- a/cmd/entry.go +++ b/cmd/entry.go @@ -7,6 +7,7 @@ import ( "path/filepath" "runtime" + "github.com/warrenronsiek/ctask/internal/agent" "github.com/warrenronsiek/ctask/internal/config" "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" @@ -23,14 +24,14 @@ type WorkspaceEntryOptions struct { WsPath string // absolute workspace directory WsRoot string // top-level root (used for CTASK_ROOT env var) WsMeta *workspace.TaskMeta // workspace metadata - Agent string - Shell bool // launch interactive shell (open / new --shell) - Force bool // bypass v0.4 Layer 1/3 prompts (owner-create only) - Direct bool // user passed --direct - AlwaysPersistent bool // ctask attach: ignore CTASK_SESSION_MODE - CommandName string // for hint rendering: "new" | "resume" | "open" | "attach" - TmuxPath string // pre-resolved tmux path; if empty in persistent mode, runWorkspaceEntry resolves - NewlyCreated bool // forwarded to LaunchOpts.NewlyCreated + ResolvedAgent *agent.Resolved // launch-ready agent (command + args + env) + Shell bool // launch interactive shell (open / new --shell) + Force bool // bypass v0.4 Layer 1/3 prompts (owner-create only) + Direct bool // user passed --direct + AlwaysPersistent bool // ctask attach: ignore CTASK_SESSION_MODE + CommandName string // for hint rendering: "new" | "resume" | "open" | "attach" + TmuxPath string // pre-resolved tmux path; if empty in persistent mode, runWorkspaceEntry resolves + NewlyCreated bool // forwarded to LaunchOpts.NewlyCreated } // runWorkspaceEntry is the test seam for the persistent-mode dispatcher. @@ -114,6 +115,18 @@ func defaultRunWorkspaceEntry(opts WorkspaceEntryOptions) error { return fmt.Errorf("internal: unreachable persistent dispatch") } +// resolveEntryAgent builds the launch-ready agent for an entry command +// (resume / last / open / attach). It starts from the workspace's +// AgentSpec, applies an optional one-shot agent.command override (the +// --agent flag — a command override, NOT a type selector, per v0.6 +// Open Question 1), then resolves against the user-level default_agent. +func resolveEntryAgent(spec workspace.AgentSpec, commandOverride string) (*agent.Resolved, error) { + if commandOverride != "" { + spec.Command = commandOverride + } + return agent.Resolve(spec, config.LoadResolver().DefaultAgent().Value) +} + func entryEnvVars(opts WorkspaceEntryOptions) map[string]string { return config.EnvVars( opts.WsMeta.Slug, opts.WsMeta.Mode, @@ -127,7 +140,7 @@ func invokeDirectRun(opts WorkspaceEntryOptions) error { return session.Run(session.LaunchOpts{ WsDir: opts.WsPath, EnvVars: entryEnvVars(opts), - Agent: opts.Agent, + ResolvedAgent: opts.ResolvedAgent, Mode: opts.WsMeta.Mode, Slug: opts.WsMeta.Slug, Shell: opts.Shell, @@ -175,35 +188,35 @@ func formatDirectModeTmuxHint(slug string) string { func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { return session.Run(session.LaunchOpts{ - WsDir: opts.WsPath, - EnvVars: entryEnvVars(opts), - Agent: opts.Agent, - Mode: opts.WsMeta.Mode, - Slug: opts.WsMeta.Slug, - Shell: opts.Shell, - LaunchDir: opts.WsMeta.LaunchDir, - Category: opts.WsMeta.Category, - SessionMode: "persistent", - SessionName: sessionName, - TmuxPath: tmuxPath, - Force: opts.Force, - NewlyCreated: opts.NewlyCreated, + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + ResolvedAgent: opts.ResolvedAgent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + SessionMode: "persistent", + SessionName: sessionName, + TmuxPath: tmuxPath, + Force: opts.Force, + NewlyCreated: opts.NewlyCreated, }) } func invokePersistentAdoption(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { return session.AdoptExistingPersistentSession(tmuxPath, sessionName, opts.WsPath, session.LaunchOpts{ - WsDir: opts.WsPath, - EnvVars: entryEnvVars(opts), - Agent: opts.Agent, - Mode: opts.WsMeta.Mode, - Slug: opts.WsMeta.Slug, - Shell: opts.Shell, - LaunchDir: opts.WsMeta.LaunchDir, - Category: opts.WsMeta.Category, - SessionMode: "persistent", - SessionName: sessionName, - TmuxPath: tmuxPath, + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + ResolvedAgent: opts.ResolvedAgent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + SessionMode: "persistent", + SessionName: sessionName, + TmuxPath: tmuxPath, }) } diff --git a/cmd/entry_test.go b/cmd/entry_test.go index 62a2021..bf69a1e 100644 --- a/cmd/entry_test.go +++ b/cmd/entry_test.go @@ -4,6 +4,7 @@ import ( "path/filepath" "testing" + "github.com/warrenronsiek/ctask/internal/agent" "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -63,12 +64,12 @@ func TestRunWorkspaceEntryIsInjectable(t *testing.T) { t.Cleanup(func() { runWorkspaceEntry = orig }) want := WorkspaceEntryOptions{ - WsPath: "/tmp/ws", - WsRoot: "/tmp", - WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}}, - Agent: "claude", - Shell: true, - CommandName: "test", + WsPath: "/tmp/ws", + WsRoot: "/tmp", + WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}}, + ResolvedAgent: &agent.Resolved{Command: "claude"}, + Shell: true, + CommandName: "test", } if err := runWorkspaceEntry(want); err != nil { t.Fatalf("runWorkspaceEntry: %v", err) diff --git a/cmd/new.go b/cmd/new.go index 1242d73..8e355c7 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -5,6 +5,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/agent" "github.com/warrenronsiek/ctask/internal/config" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" @@ -53,9 +54,12 @@ func runNew(cmd *cobra.Command, args []string) error { return err } - agent := newAgent - if agent == "" { - agent = config.ResolveAgent() + // Task 3: minimal wiring — the --agent type-selector rework lands in + // Task 4. For now the resolved type drives both the workspace AgentSpec + // and the launch-time agent.Resolve. + agentType := newAgent + if agentType == "" { + agentType = config.ResolveAgent() } title := "" @@ -92,7 +96,7 @@ func runNew(cmd *cobra.Command, args []string) error { Title: title, Category: category, Mode: "local", - AgentSpec: workspace.AgentSpec{Type: agent}, + AgentSpec: workspace.AgentSpec{Type: agentType}, IsProject: newProject, SeedDir: config.ResolveSeedDir(), SkipCategoryDir: skipCategoryDir, @@ -127,20 +131,25 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } + resolved, err := agent.Resolve(workspace.AgentSpec{Type: agentType}, agentType) + if err != nil { + return err + } + // Re-set the workspace's root: workspace.Create returned ws but our // computed `root` is what should be exported via CTASK_ROOT (it may // differ from ws-derived defaults when --project + CTASK_PROJECT_ROOT // are in play). return runWorkspaceEntry(WorkspaceEntryOptions{ - WsPath: ws.Path, - WsRoot: root, - WsMeta: ws.Meta, - Agent: agent, - Shell: newShell, - Direct: newDirect, - CommandName: "new", - TmuxPath: tmuxPath, - NewlyCreated: true, + WsPath: ws.Path, + WsRoot: root, + WsMeta: ws.Meta, + ResolvedAgent: resolved, + Shell: newShell, + Direct: newDirect, + CommandName: "new", + TmuxPath: tmuxPath, + NewlyCreated: true, }) } diff --git a/cmd/open.go b/cmd/open.go index e1f40d1..f385dff 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -47,14 +47,19 @@ func runOpen(cmd *cobra.Command, args []string) error { return fmt.Errorf("updating metadata: %w", err) } + resolved, err := resolveEntryAgent(ws.Meta.Agent, "") + if err != nil { + return err + } + return runWorkspaceEntry(WorkspaceEntryOptions{ - WsPath: ws.Path, - WsRoot: ws.Root, - WsMeta: ws.Meta, - Agent: ws.Meta.Agent.Type, - Shell: true, // open always launches a shell - Force: openForce, - Direct: openDirect, - CommandName: "open", + WsPath: ws.Path, + WsRoot: ws.Root, + WsMeta: ws.Meta, + ResolvedAgent: resolved, + Shell: true, // open always launches a shell + Force: openForce, + Direct: openDirect, + CommandName: "open", }) } diff --git a/cmd/resume.go b/cmd/resume.go index 44134cf..bb757b3 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -84,20 +84,20 @@ func doResume(query string, container, useShell, force bool, agentOverride strin return fmt.Errorf("updating metadata: %w", err) } - agent := agentOverride - if agent == "" { - agent = ws.Meta.Agent.Type + resolved, err := resolveEntryAgent(ws.Meta.Agent, agentOverride) + if err != nil { + return err } return runWorkspaceEntry(WorkspaceEntryOptions{ - WsPath: ws.Path, - WsRoot: ws.Root, - WsMeta: ws.Meta, - Agent: agent, - Shell: useShell, - Force: force, - Direct: directFlag, - CommandName: "resume", + WsPath: ws.Path, + WsRoot: ws.Root, + WsMeta: ws.Meta, + ResolvedAgent: resolved, + Shell: useShell, + Force: force, + Direct: directFlag, + CommandName: "resume", }) } diff --git a/internal/session/adopt.go b/internal/session/adopt.go index 3e35f3e..7030d0d 100644 --- a/internal/session/adopt.go +++ b/internal/session/adopt.go @@ -68,7 +68,7 @@ func AdoptExistingPersistentSession(tmuxPath, sessionName, wsDir string, opts La fmt.Fprintf(os.Stderr, "[ctask] warning: failed to remove orphaned lease: %v\n", rmErr) } - lease := NewLease(startTime, opts.Agent, opts.Mode) + lease := NewLease(startTime, leaseAgentCommand(opts), opts.Mode) if err := WriteLease(leasePath, lease); err != nil { return fmt.Errorf("writing lease: %w", err) } @@ -152,12 +152,12 @@ func finalizeAdopted(opts LaunchOpts, wsDir string, startManifest *Manifest, sta } diff := DiffManifests(startManifest, endManifest) - agent := opts.Agent + agentCmd := leaseAgentCommand(opts) if opts.Shell { - agent = "shell" + agentCmd = "shell" } info := &SessionInfo{ - Agent: agent, + Agent: agentCmd, Mode: opts.Mode, StartTime: startTime, EndTime: endTime, @@ -170,7 +170,7 @@ func finalizeAdopted(opts LaunchOpts, wsDir string, startManifest *Manifest, sta } summary := SummarizeFromDiff( - sessionID, currentHostname(), agent, opts.Mode, + sessionID, currentHostname(), agentCmd, opts.Mode, startTime, endTime, diff, endManifest, ) summary.EndReason = "tmux_session_ended" diff --git a/internal/session/adopt_test.go b/internal/session/adopt_test.go index f9ac4ad..69bb459 100644 --- a/internal/session/adopt_test.go +++ b/internal/session/adopt_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/warrenronsiek/ctask/internal/agent" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -76,14 +77,14 @@ func (fx *adoptionFixture) stubSeams(t *testing.T) { func defaultAdoptionOpts(wsDir string) LaunchOpts { return LaunchOpts{ - WsDir: wsDir, - Agent: "claude", - Mode: "local", - Slug: "demo", - Category: "projects", - SessionMode: "persistent", - SessionName: "ctask-projects-demo-abc123", - TmuxPath: "/usr/bin/tmux", + WsDir: wsDir, + ResolvedAgent: &agent.Resolved{Command: "claude"}, + Mode: "local", + Slug: "demo", + Category: "projects", + SessionMode: "persistent", + SessionName: "ctask-projects-demo-abc123", + TmuxPath: "/usr/bin/tmux", } } diff --git a/internal/session/run.go b/internal/session/run.go index 9e1e7eb..c10012a 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -7,19 +7,57 @@ import ( "path/filepath" "time" + "github.com/warrenronsiek/ctask/internal/agent" "github.com/warrenronsiek/ctask/internal/lockfile" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) +// execAgent is the test seam for the direct-mode child launch. Tests +// replace this to capture the (command, args, wsDir, env) tuple Run +// would have passed to shell.ExecAgent. Restored via t.Cleanup. +var execAgent = shell.ExecAgent + +// mergeAgentEnv overlays agentEnv on top of base, returning a new map. +// Per v0.6 spec §5 ("merged into the agent's environment, after ctask's +// own exported vars"), agentEnv keys WIN on collision — the spec is +// explicit that user-supplied env vars take precedence. Callers that +// want a warning for shadowed CTASK_* keys must emit it themselves +// (ctask agents check surfaces this). +func mergeAgentEnv(base, agentEnv map[string]string) map[string]string { + out := make(map[string]string, len(base)+len(agentEnv)) + for k, v := range base { + out[k] = v + } + for k, v := range agentEnv { + out[k] = v + } + return out +} + +// leaseAgentCommand returns the command string recorded in leases and +// summaries for diagnostics. A nil ResolvedAgent yields "" (only happens +// in tests that do not exercise a launch). +func leaseAgentCommand(opts LaunchOpts) string { + if opts.ResolvedAgent != nil { + return opts.ResolvedAgent.Command + } + return "" +} + // LaunchOpts configures a session launch. type LaunchOpts struct { WsDir string EnvVars map[string]string - Agent string - Mode string - Slug string - Shell bool // true = interactive shell, false = agent + + // ResolvedAgent is the launch-ready agent (command + args + env), + // produced by the cmd layer via agent.Resolve. Required in agent mode; + // in shell mode it is still populated for lease/summary diagnostics. + ResolvedAgent *agent.Resolved + + Mode string + Slug string + Shell bool // true = interactive shell, false = agent // LaunchDir is the workspace-relative launch directory (v0.5). Empty for // tasks and pre-v0.5 projects. When set, Run resolves the absolute path @@ -127,7 +165,7 @@ func Run(opts LaunchOpts) error { leasePath := LeasePath(opts.WsDir) ownLease := !preflight.ActiveLeaseFound if ownLease { - lease := NewLease(startTime, opts.Agent, opts.Mode) + lease := NewLease(startTime, leaseAgentCommand(opts), opts.Mode) skipped, lockErr := lockfile.WithLock( ctaskWriteLockPath(opts.WsDir), sessionWriteLockTimeout, sessionWriteLockStaleAfter, @@ -187,6 +225,15 @@ func Run(opts LaunchOpts) error { fmt.Fprintln(os.Stderr, launchWarn) } + // Agent-launch branches dereference ResolvedAgent; a nil here is an + // internal wiring bug (the cmd layer must always Resolve before Run). + if !opts.Shell && opts.ResolvedAgent == nil { + if hb != nil { + hb.Stop() + } + return fmt.Errorf("internal error: LaunchOpts.ResolvedAgent is nil in agent mode") + } + // ---- Run the child ---- var childErr error switch { @@ -213,7 +260,9 @@ func Run(opts LaunchOpts) error { if opts.Shell { childErr = shell.ExecTmuxShell(opts.TmuxPath, opts.SessionName, launchAbs, opts.EnvVars) } else { - childErr = shell.ExecTmuxAgent(opts.TmuxPath, opts.SessionName, launchAbs, opts.EnvVars, opts.Agent) + childErr = shell.ExecTmuxAgent(opts.TmuxPath, opts.SessionName, launchAbs, + mergeAgentEnv(opts.EnvVars, opts.ResolvedAgent.Env), + opts.ResolvedAgent.Command, opts.ResolvedAgent.Args) } case opts.Shell: childErr = shell.ExecShell(launchAbs, opts.EnvVars, opts.Slug, opts.Mode) @@ -221,7 +270,8 @@ func Run(opts LaunchOpts) error { for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir, opts.LaunchDir) { fmt.Println(line) } - childErr = shell.ExecAgent(opts.Agent, launchAbs, opts.EnvVars) + childErr = execAgent(opts.ResolvedAgent.Command, opts.ResolvedAgent.Args, launchAbs, + mergeAgentEnv(opts.EnvVars, opts.ResolvedAgent.Env)) } if hb != nil { @@ -284,12 +334,12 @@ func finalize(opts LaunchOpts, startManifest *Manifest, startTime, endTime time. } diff := DiffManifests(startManifest, endManifest) - agent := opts.Agent + agentCmd := leaseAgentCommand(opts) if opts.Shell { - agent = "shell" + agentCmd = "shell" } info := &SessionInfo{ - Agent: agent, + Agent: agentCmd, Mode: opts.Mode, StartTime: startTime, EndTime: endTime, @@ -307,7 +357,7 @@ func finalize(opts LaunchOpts, startManifest *Manifest, startTime, endTime time. } summary := SummarizeFromDiff( - sessionID, currentHostname(), agent, opts.Mode, + sessionID, currentHostname(), agentCmd, opts.Mode, startTime, endTime, diff, endManifest, ) if opts.SessionMode == "persistent" { diff --git a/internal/session/run_env_test.go b/internal/session/run_env_test.go new file mode 100644 index 0000000..552d24f --- /dev/null +++ b/internal/session/run_env_test.go @@ -0,0 +1,72 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + + "github.com/warrenronsiek/ctask/internal/agent" +) + +func TestMergeAgentEnvOverlays(t *testing.T) { + base := map[string]string{"CTASK_WORKSPACE": "/ws", "PATH": "/bin"} + agentEnv := map[string]string{"OPENAI_API_KEY": "x", "CTASK_WORKSPACE": "shadowed"} + got := mergeAgentEnv(base, agentEnv) + + if got["OPENAI_API_KEY"] != "x" { + t.Errorf("OPENAI_API_KEY = %q, want x", got["OPENAI_API_KEY"]) + } + if got["PATH"] != "/bin" { + t.Errorf("PATH = %q, want /bin (untouched base)", got["PATH"]) + } + if got["CTASK_WORKSPACE"] != "shadowed" { + t.Errorf("CTASK_WORKSPACE = %q, want shadowed (agent.env wins per spec §5)", + got["CTASK_WORKSPACE"]) + } +} + +func TestRunMergesAgentEnvIntoChildEnvironment(t *testing.T) { + wsDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil { + t.Fatalf("mkdir .ctask: %v", err) + } + + var capturedEnv map[string]string + prev := execAgent + execAgent = func(command string, args []string, dir string, env map[string]string) error { + capturedEnv = env + return nil + } + t.Cleanup(func() { execAgent = prev }) + + err := Run(LaunchOpts{ + WsDir: wsDir, + EnvVars: map[string]string{ + "CTASK_WORKSPACE": "/ws-base", + "PATH": "/bin", + }, + Mode: "local", + Slug: "env-merge", + ResolvedAgent: &agent.Resolved{ + Command: "go", + Args: nil, + Env: map[string]string{ + "OPENAI_API_KEY": "x", + "CTASK_WORKSPACE": "shadowed-by-agent-env", + }, + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if capturedEnv["OPENAI_API_KEY"] != "x" { + t.Errorf("OPENAI_API_KEY = %q, want x", capturedEnv["OPENAI_API_KEY"]) + } + if capturedEnv["PATH"] != "/bin" { + t.Errorf("PATH = %q, want /bin", capturedEnv["PATH"]) + } + if capturedEnv["CTASK_WORKSPACE"] != "shadowed-by-agent-env" { + t.Errorf("CTASK_WORKSPACE = %q, want shadowed-by-agent-env (spec §5: agent.env wins)", + capturedEnv["CTASK_WORKSPACE"]) + } +} diff --git a/internal/session/run_integration_test.go b/internal/session/run_integration_test.go index 80db557..6f2124c 100644 --- a/internal/session/run_integration_test.go +++ b/internal/session/run_integration_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/warrenronsiek/ctask/internal/agent" ) func TestFinalizeWritesSummaryAndClearsLease(t *testing.T) { @@ -31,10 +33,10 @@ func TestFinalizeWritesSummaryAndClearsLease(t *testing.T) { end := time.Now().UTC().Truncate(time.Second) opts := LaunchOpts{ - WsDir: wsDir, - Agent: "claude", - Mode: "local", - Slug: "test", + WsDir: wsDir, + ResolvedAgent: &agent.Resolved{Command: "claude"}, + Mode: "local", + Slug: "test", } if err := finalize(opts, startManifest, start, end, true); err != nil { diff --git a/internal/shell/launch.go b/internal/shell/launch.go index c19d8bd..89683c3 100644 --- a/internal/shell/launch.go +++ b/internal/shell/launch.go @@ -54,14 +54,29 @@ func ContainerNotice() string { return "[ctask] container mode is not available in v0.1. Use local mode or see docs for manual container setup." } -// ExecAgent launches the agent command in the workspace directory. -func ExecAgent(agent string, wsDir string, envVars map[string]string) error { +// execAgentArgs is the pure helper: returns the (path, argv) pair that +// ExecAgent will hand to exec.Command. Extracted so unit tests can +// assert argv shape without spawning a real child. +func execAgentArgs(agent string, args []string) (string, []string, error) { path, err := exec.LookPath(agent) if err != nil { - return fmt.Errorf("agent command not found: %s", agent) + return "", nil, fmt.Errorf("agent command not found: %s", agent) + } + return path, args, nil +} + +// ExecAgent launches the agent command in the workspace directory. +// args is appended after the executable; envVars is merged into the +// process environment via BuildEnvList (which keeps os.Environ() as the +// base — agent.env entries layered into envVars by the caller take +// precedence on collision, per v0.6 spec §5). +func ExecAgent(agent string, args []string, wsDir string, envVars map[string]string) error { + path, argv, err := execAgentArgs(agent, args) + if err != nil { + return err } - cmd := exec.Command(path) + cmd := exec.Command(path, argv...) cmd.Dir = wsDir cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/internal/shell/launch_test.go b/internal/shell/launch_test.go index 421a18d..fd74683 100644 --- a/internal/shell/launch_test.go +++ b/internal/shell/launch_test.go @@ -6,6 +6,28 @@ import ( "testing" ) +func TestExecAgentArgsAppendedAfterPath(t *testing.T) { + // Use any executable guaranteed to exist on PATH. "go" works on dev + // machines; the test suite already assumes Go is present. + path, argv, err := execAgentArgs("go", []string{"version"}) + if err != nil { + t.Skipf("go not on PATH: %v", err) + } + if !strings.HasSuffix(path, "go") && !strings.HasSuffix(path, "go.exe") { + t.Errorf("path = %q, want suffix 'go' or 'go.exe'", path) + } + if len(argv) != 1 || argv[0] != "version" { + t.Errorf("argv = %v, want [version]", argv) + } +} + +func TestExecAgentArgsCommandNotFound(t *testing.T) { + _, _, err := execAgentArgs("definitely-not-on-path-zzz", nil) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err = %v, want command-not-found error", err) + } +} + func TestDefaultShell(t *testing.T) { cmd := DefaultShell() if runtime.GOOS == "windows" { diff --git a/internal/shell/tmux.go b/internal/shell/tmux.go index 4271e69..c281ff8 100644 --- a/internal/shell/tmux.go +++ b/internal/shell/tmux.go @@ -183,13 +183,15 @@ func pollSessionEndWith(tmuxPath, name string, interval time.Duration, hs func(s } // ExecTmuxAgent orchestrates the three-call pattern for agent mode: -// NewSession -> AttachSession -> PollSessionEnd. +// NewSession -> AttachSession -> PollSessionEnd. args (when non-empty) +// are appended after the agent command on the tmux session's child +// invocation. // // AttachSession failures abort early — the polling loop is meaningful only // after a successful attach (otherwise we'd block waiting for a session // the user never connected to). -func ExecTmuxAgent(tmuxPath, sessionName, launchAbs string, env map[string]string, agent string) error { - if err := NewSession(tmuxPath, sessionName, launchAbs, env, agent); err != nil { +func ExecTmuxAgent(tmuxPath, sessionName, launchAbs string, env map[string]string, agent string, args []string) error { + if err := NewSession(tmuxPath, sessionName, launchAbs, env, agent, args...); err != nil { return err } if err := AttachSession(tmuxPath, sessionName); err != nil { diff --git a/internal/shell/tmux_test.go b/internal/shell/tmux_test.go index 5b6405d..1d1efbe 100644 --- a/internal/shell/tmux_test.go +++ b/internal/shell/tmux_test.go @@ -11,6 +11,21 @@ import ( "time" ) +func TestTmuxArgsIncludesCommandArgs(t *testing.T) { + got := tmuxArgs("session-x", "/tmp/ws", map[string]string{"K": "v"}, "claude", "--debug", "--mode=foo") + // Tail of args must be: -- claude --debug --mode=foo + if len(got) < 4 { + t.Fatalf("argv too short: %v", got) + } + tail := got[len(got)-4:] + want := []string{"--", "claude", "--debug", "--mode=foo"} + for i, w := range want { + if tail[i] != w { + t.Fatalf("tail = %v, want %v", tail, want) + } + } +} + func TestParseTmuxVersionMajor(t *testing.T) { cases := []struct { raw string