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
+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