Files
ctask/cmd/entry.go
T
typebasedio b75b82e676 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.
2026-05-15 11:08:03 -04:00

255 lines
9.5 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"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"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// WorkspaceEntryOptions captures everything the persistent-mode dispatcher
// needs to enter a workspace. Callers (runNew, runResume, runOpen,
// runAttach) populate this struct after they have resolved or created the
// workspace, then call runWorkspaceEntry. The helper handles preflight
// (when not pre-resolved), session-name computation, lease inspection,
// dispatch decision, and the fresh_remote confirmation prompt.
type WorkspaceEntryOptions struct {
WsPath string // absolute workspace directory
WsRoot string // top-level root (used for CTASK_ROOT env var)
WsMeta *workspace.TaskMeta // workspace metadata
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.
// Production code calls defaultRunWorkspaceEntry; tests override this
// variable to capture invocations or simulate the dispatch outcome.
//
// Do NOT mark tests that override this in t.Parallel() — it is a package
// global. Each test must restore via t.Cleanup.
var runWorkspaceEntry = defaultRunWorkspaceEntry
// dispatchDecision enumerates the three persistent-mode entry paths.
type dispatchDecision int
const (
dispatchOwnerCreate dispatchDecision = iota
dispatchPassive
dispatchAdopted
)
// dispatchPersistent is the pure decision function — no I/O, no globals,
// trivially testable.
func dispatchPersistent(hasTmuxSession bool, leaseState session.LeaseState) dispatchDecision {
if !hasTmuxSession {
return dispatchOwnerCreate
}
if leaseState == session.LeaseStateFreshLocal {
return dispatchPassive
}
return dispatchAdopted
}
func defaultRunWorkspaceEntry(opts WorkspaceEntryOptions) error {
// v0.6: if the resolver downgraded a configured persistent mode to
// direct via the native-Windows platform override, surface the fact
// to the user once before any launch work happens. attach
// (AlwaysPersistent=true) is excluded — it has no direct-mode
// fallback and refuses on native Windows via preflightPersistentEntry.
emitPlatformOverrideWarningIfNeeded(opts.AlwaysPersistent)
mode := config.ResolveSessionMode()
persistent := opts.AlwaysPersistent || (mode == "persistent" && !opts.Direct)
// Direct flag with persistent env: confirm if a tmux session exists.
if !persistent && mode == "persistent" && opts.Direct {
if err := confirmDirectBypass(opts); err != nil {
return err
}
}
if !persistent {
return invokeDirectRun(opts)
}
tmuxPath := opts.TmuxPath
if tmuxPath == "" {
var err error
tmuxPath, err = preflightPersistentEntry(opts.CommandName)
if err != nil {
return err
}
}
absWs, _ := filepath.Abs(opts.WsPath)
sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs)
hasSession := shell.HasSession(tmuxPath, sessionName)
leaseState := session.InspectLease(opts.WsPath)
switch dispatchPersistent(hasSession, leaseState) {
case dispatchOwnerCreate:
return invokePersistentRun(opts, tmuxPath, sessionName)
case dispatchPassive:
return session.AttachExisting(tmuxPath, sessionName)
case dispatchAdopted:
if leaseState == session.LeaseStateFreshRemote {
if err := confirmFreshRemoteAdoption(opts.WsPath); err != nil {
return err
}
}
return invokePersistentAdoption(opts, tmuxPath, sessionName)
}
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,
opts.WsRoot, opts.WsPath,
opts.WsMeta.Category, workspace.EffectiveType(opts.WsMeta),
opts.WsMeta.LaunchDir,
)
}
func invokeDirectRun(opts WorkspaceEntryOptions) error {
return session.Run(session.LaunchOpts{
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,
Force: opts.Force,
NewlyCreated: opts.NewlyCreated,
ActiveLeaseHint: directModeTmuxHint(opts),
})
}
// directModeTmuxHint returns a Layer-1 prompt suggestion when ctask is
// about to enter direct mode on a workspace that already has a live tmux
// session — pointing the user at `ctask attach <slug>` as the reattach
// path. Returns "" when no hint is appropriate (no tmux on PATH, no
// session for this workspace, or native Windows without WSL).
//
// This is a best-effort UX nudge: the lookup is silent on error so a
// missing/broken tmux never blocks the direct-mode path.
func directModeTmuxHint(opts WorkspaceEntryOptions) string {
if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" {
return ""
}
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
return ""
}
absWs, _ := filepath.Abs(opts.WsPath)
sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs)
if !shell.HasSession(tmuxPath, sessionName) {
return ""
}
return formatDirectModeTmuxHint(opts.WsMeta.Slug)
}
// formatDirectModeTmuxHint builds the hint string itself, with no tmux
// or filesystem checks. Split out so unit tests can verify that the
// command-form line uses invocationName() without needing a live tmux
// session set up against a real workspace.
func formatDirectModeTmuxHint(slug string) string {
return fmt.Sprintf(
"Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n %s attach %s",
invocationName(), slug)
}
func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error {
return session.Run(session.LaunchOpts{
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),
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,
})
}
// confirmDirectBypass is invoked when the user passes --direct under
// persistent mode. If a tmux session exists for the workspace, prompt for
// confirmation. Otherwise, print a one-line warning and proceed.
func confirmDirectBypass(opts WorkspaceEntryOptions) error {
// Native Windows / no WSL: no tmux can exist; silent proceed.
if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" {
return nil
}
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
fmt.Fprintln(os.Stderr,
"[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)")
return nil
}
absWs, _ := filepath.Abs(opts.WsPath)
sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs)
if !shell.HasSession(tmuxPath, sessionName) {
fmt.Fprintln(os.Stderr,
"[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)")
return nil
}
fmt.Fprintf(os.Stderr,
"A persistent tmux session exists for this workspace:\n %s\n\n"+
"Opening a direct-mode shell may create conflicting workspace activity.\n"+
"The recommended path is:\n %s attach %s\n\n"+
"Continue with --direct anyway? [y/N] ",
sessionName, invocationName(), opts.WsMeta.Slug)
if !session.ConfirmYN(os.Stdin, os.Stderr, "", false) {
return fmt.Errorf("canceled by user")
}
return nil
}