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 ` 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 }