feat(v0.6): launch path carries ResolvedAgent (command + args + env)

LaunchOpts.Agent (string) and WorkspaceEntryOptions.Agent (string) are
replaced by *agent.Resolved, carrying Command, Args, and Env. The five
entry commands (new, resume, last, open, attach) each construct an
AgentSpec from the workspace metadata, apply --agent as a one-shot
agent.command override (Open Q 1 — keeps muscle memory for users
passing executable paths), call agent.Resolve, and pass the result
through. resolveEntryAgent centralises the resume/last/open/attach path.

shell.ExecAgent and shell.ExecTmuxAgent gain an args parameter; agent.env
is merged into the env map at the session.Run launch switch, AFTER
ctask's exported CTASK_* vars (per spec §5: agent.env wins on collision).
mergeAgentEnv is the centralised merge.

Lease, manifest, write lock, heartbeat, summary, and provisional
cleanup are unchanged. The Agent string fields on Lease, SessionSummary,
and SessionInfo continue to record the launched command for diagnostics.
This commit is contained in:
2026-05-15 11:08:03 -04:00
parent 24f213449e
commit b75b82e676
15 changed files with 317 additions and 110 deletions
+4 -4
View File
@@ -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
+46 -33
View File
@@ -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,
})
}
+7 -6
View File
@@ -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)
+22 -13
View File
@@ -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,
})
}
+13 -8
View File
@@ -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",
})
}
+11 -11
View File
@@ -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",
})
}
+5 -5
View File
@@ -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"
+9 -8
View File
@@ -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",
}
}
+61 -11
View File
@@ -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" {
+72
View File
@@ -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"])
}
}
+6 -4
View File
@@ -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 {
+19 -4
View File
@@ -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
+22
View File
@@ -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" {
+5 -3
View File
@@ -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 {
+15
View File
@@ -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