diff --git a/cmd/doctor.go b/cmd/doctor.go index e20c956..7dc9972 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -175,7 +175,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { passed++ } else { fmt.Printf(" [FAIL] No workspaces found\n") - fmt.Printf(" Fix: create one with: ctask new \"my first task\"\n") + fmt.Printf(" Fix: create one with: %s new \"my first task\"\n", invocationName()) failed++ } diff --git a/cmd/entry.go b/cmd/entry.go index 96c3633..11be00d 100644 --- a/cmd/entry.go +++ b/cmd/entry.go @@ -118,19 +118,46 @@ func entryEnvVars(opts WorkspaceEntryOptions) map[string]string { func invokeDirectRun(opts WorkspaceEntryOptions) 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, - Force: opts.Force, - NewlyCreated: opts.NewlyCreated, + 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, + 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 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(), opts.WsMeta.Slug) +} + func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { return session.Run(session.LaunchOpts{ WsDir: opts.WsPath, @@ -189,9 +216,9 @@ func confirmDirectBypass(opts WorkspaceEntryOptions) error { 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 ctask attach %s\n\n"+ + "The recommended path is:\n %s attach %s\n\n"+ "Continue with --direct anyway? [y/N] ", - sessionName, opts.WsMeta.Slug) + sessionName, invocationName(), opts.WsMeta.Slug) if !session.ConfirmYN(os.Stdin, os.Stderr, "", false) { return fmt.Errorf("canceled by user") } diff --git a/cmd/invocation.go b/cmd/invocation.go new file mode 100644 index 0000000..acba7f5 --- /dev/null +++ b/cmd/invocation.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "os" + "path/filepath" +) + +// invocationNameOverride lets tests fix the binary name surfaced in +// user-facing hint and refusal messages. Production code leaves it empty +// so the live os.Args[0] basename is used. +// +// Tests that assert on specific hint substrings (e.g. "ctask attach +// ") must set this to "ctask" via t.Cleanup; otherwise the test +// binary's name (e.g. "cmd.test") will surface in the hint and the +// substring will not match. Do not run such tests in t.Parallel — this +// is a package global. +var invocationNameOverride string + +// invocationName returns the binary name to render in user-facing +// command suggestions (" new --direct", +// " attach ", etc.). It returns the basename of os.Args[0] +// so the hint reads cleanly regardless of invocation form: `./ctask`, +// `.\ctask.exe`, `/usr/local/bin/ctask`, and an installed `ctask` on +// PATH all surface as `ctask` (or `ctask.exe` on Windows). The slight +// loss of paste-ability for explicit-path invocations (the user has to +// re-prepend their `./` or `.\`) is the trade for a clean, predictable +// hint that matches the canonical install case. +// +// Falls back to "ctask" when argv is empty (a degenerate state — should +// not happen in normal execution, but defensive against odd embeddings). +func invocationName() string { + if invocationNameOverride != "" { + return invocationNameOverride + } + if len(os.Args) == 0 || os.Args[0] == "" { + return "ctask" + } + return filepath.Base(os.Args[0]) +} diff --git a/cmd/persistent.go b/cmd/persistent.go index b58d9a7..3d46f03 100644 --- a/cmd/persistent.go +++ b/cmd/persistent.go @@ -36,7 +36,8 @@ func defaultIsTTYCheck() bool { // commandName ("new", "resume", "last", "open", "attach") is rendered into // the bypass hint so each command tells the user the right form. func preflightPersistentEntry(commandName string) (string, error) { - bypass := " ctask " + commandName + " --direct" + bin := invocationName() + bypass := " " + bin + " " + commandName + " --direct" if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" { return "", fmt.Errorf( diff --git a/cmd/persistent_test.go b/cmd/persistent_test.go index fd674c6..01fa89e 100644 --- a/cmd/persistent_test.go +++ b/cmd/persistent_test.go @@ -22,6 +22,18 @@ func withTTYCheck(t *testing.T, fn func() bool) { t.Cleanup(func() { isTTYCheck = orig }) } +// withInvocationName pins the binary name surfaced in user-facing hints +// to a fixed value (typically "ctask") for the duration of the test, so +// substring assertions against rendered hints stay stable regardless of +// the Go test binary's name. Must NOT run in parallel — mutates a +// package global. +func withInvocationName(t *testing.T, name string) { + t.Helper() + orig := invocationNameOverride + invocationNameOverride = name + t.Cleanup(func() { invocationNameOverride = orig }) +} + func TestPreflightRefusesNativeWindows(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("native-Windows refusal applies only on Windows") @@ -64,6 +76,7 @@ func TestPreflightRefusesNonTTY(t *testing.T) { } os.Unsetenv("TMUX") withTTYCheck(t, func() bool { return false }) + withInvocationName(t, "ctask") _, err := preflightPersistentEntry("resume") if err == nil { t.Fatal("expected refusal when not a TTY") @@ -74,8 +87,8 @@ func TestPreflightRefusesNonTTY(t *testing.T) { if !strings.Contains(err.Error(), "ssh -t") { t.Errorf("error should mention ssh -t: %v", err) } - if !strings.Contains(err.Error(), "ctask resume") { - t.Errorf("error should mention command-specific bypass: %v", err) + if !strings.Contains(err.Error(), "ctask resume --direct") { + t.Errorf("error should mention command-specific bypass form: %v", err) } } @@ -85,9 +98,10 @@ func TestPreflightCommandNameRendersInHints(t *testing.T) { } os.Unsetenv("TMUX") withTTYCheck(t, func() bool { return false }) + withInvocationName(t, "ctask") _, err := preflightPersistentEntry("attach") - if err == nil || !strings.Contains(err.Error(), "ctask attach") { - t.Errorf("commandName must appear in hint: %v", err) + if err == nil || !strings.Contains(err.Error(), "ctask attach --direct") { + t.Errorf("commandName must appear in bypass hint: %v", err) } } diff --git a/cmd/resume.go b/cmd/resume.go index df92c66..b738dcd 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -59,8 +59,8 @@ func doResume(query string, container, useShell, force bool, agentOverride strin if ws.Meta.Status == "archived" { fmt.Fprintf(os.Stderr, - "[ctask] error: workspace %q is archived\n\nTo restore it:\n ctask restore %s\n", - query, query) + "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", + query, invocationName(), query) return fmt.Errorf("workspace archived") } diff --git a/cmd/resume_test.go b/cmd/resume_test.go index 126035d..ecae37f 100644 --- a/cmd/resume_test.go +++ b/cmd/resume_test.go @@ -41,6 +41,12 @@ func callDoResumeArchived(t *testing.T, root, query string) (stderr string, err } func TestResumeArchivedWorkspaceShowsRestoreHint(t *testing.T) { + // Pin the binary name surfaced in user-facing hints so the substring + // assertion below ("ctask restore resume-archived") is stable across + // Go test binary naming. + invocationNameOverride = "ctask" + t.Cleanup(func() { invocationNameOverride = "" }) + root := t.TempDir() wsDir := filepath.Join(root, "general", "2026-04-22_resume-archived") os.MkdirAll(wsDir, 0755) diff --git a/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md b/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md index 3fcf586..a04b8c7 100644 --- a/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md +++ b/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md @@ -52,13 +52,17 @@ ctask --version # Expect: ctask v0.5.3 which tmux && tmux -V # Expect: /usr/bin/tmux + tmux 3.5a (or similar) ``` -### S2. (WSL-A and WSL-B both) Set the smoke-test root +### S2. (WSL-A and WSL-B both) Set the smoke-test root and session mode -Run in **BOTH WSL-A AND WSL-B** (a fresh shell doesn't inherit env vars): +Run in **BOTH WSL-A AND WSL-B** (a fresh shell doesn't inherit env vars, and +`CTASK_SESSION_MODE` is the **only** trigger for persistent mode — without it +in WSL-B, the secondary `ctask resume` in section P1 falls back to direct mode +and hits the v0.4 "Continue anyway?" lease prompt instead of passive reattach): ```bash export PATH="$HOME/.local/bin:$PATH" export CTASK_ROOT=/tmp/ctask-053-smoke +export CTASK_SESSION_MODE=persistent mkdir -p "$CTASK_ROOT" ``` @@ -68,25 +72,32 @@ This keeps your real workspaces untouched — cleanup is one `rm -rf` later. ## Owner-create path (Step 3) -### O1. (WSL-A) Enable persistent mode and create the project +### O1. (WSL-A) Create the project under persistent mode + +`CTASK_SESSION_MODE=persistent` is already exported in both terminals from S2. Run in **WSL-A**: ```bash -export CTASK_SESSION_MODE=persistent ctask new --project --agent bash ctask-053-smoke ``` **What you'll see (and the expected reality, not the misleading v1 text):** +The `YYYY-MM-DD` shown below is **today's local-time date** (v0.5.1 — the +workspace directory uses `time.Now()`, not UTC). If you're running this +checklist on 2026-05-14, the path will read `2026-05-14_ctask-053-smoke`, +not `2026-05-08_ctask-053-smoke` (which was the day the checklist was +written). + - For ~50ms, the outer terminal will print three lines (you may not have time to read them — tmux clears the screen on attach): - - `[ctask] created projects/2026-05-08_ctask-053-smoke` + - `[ctask] created projects/YYYY-MM-DD_ctask-053-smoke` - `[ctask] local :: ctask-053-smoke` - - `[ctask] /tmp/ctask-053-smoke/projects/2026-05-08_ctask-053-smoke` + - `[ctask] /tmp/ctask-053-smoke/projects/YYYY-MM-DD_ctask-053-smoke` - `[ctask] project dir: ctask-053-smoke/` - Then tmux's alternate screen takes over. You see: - - A bash prompt like `warren@DESKTOP-VGJVN77:/tmp/ctask-053-smoke/projects/2026-05-08_ctask-053-smoke/ctask-053-smoke$` + - A bash prompt like `warren@DESKTOP-VGJVN77:/tmp/ctask-053-smoke/projects/YYYY-MM-DD_ctask-053-smoke/ctask-053-smoke$` (the path includes the project subdir — that's the v0.5 `launch_dir`). - A green status bar at the bottom showing something like `[ctask-projects-ctask-053-smoke-XXXXXX:bash*]` on the left, with @@ -131,7 +142,11 @@ tmux ls ``` **Expected:** A line containing `ctask-projects-ctask-053-smoke-XXXXXX` -followed by `(1 windows) ... (attached)` or `(detached)`. +followed by `(1 windows) ...`. The line may include `(attached)` if a +client is still connected (WSL-A's `ctask new` is still attaching to the +session at this point), or no marker at all if no client is connected. +**tmux does not emit a literal `(detached)` token** — the absence of +`(attached)` is how you know nothing is attached. PASS / FAIL: ___ @@ -164,10 +179,18 @@ PASS / FAIL: ___ Press `Ctrl-b d` in WSL-B. -WSL-B's shell prompt also won't return (same polling-loop behavior). That's -fine for now — both WSL-A and WSL-B are sitting in polling loops on the -same tmux session. Both will exit cleanly when the tmux session ends in -section A3 below. +**What you'll see (passive-reattach detach behavior — different from O2):** + +- The tmux full-screen alternate display closes; WSL-B's outer terminal is + restored. +- **WSL-B's shell prompt returns immediately.** Unlike WSL-A's owner-create + path, passive reattach does NOT enter a polling loop on detach. + `AttachExisting` (`internal/session/attach.go`) only calls + `shell.AttachSession` — no `PollSessionEnd`, no finalize. Passive viewers + detach freely because the **owner** is the one responsible for finalize + when the session ends. +- The owner in WSL-A is still locked in its polling loop on the same tmux + session; that's correct. PASS / FAIL: ___ @@ -192,8 +215,8 @@ non-responsive cursor) and press `Ctrl-C`. - WSL-A's shell prompt finally returns. -In **WSL-B** (which is now also in polling-loop mode after the P2 detach), -also press `Ctrl-C`. Its prompt should return too. +(No Ctrl-C needed in WSL-B — passive reattach already exited cleanly after +the P2 detach; WSL-B has been at its shell prompt since then.) ### A2. (WSL-A) Verify the tmux session survived @@ -201,7 +224,10 @@ also press `Ctrl-C`. Its prompt should return too. tmux ls ``` -**Expected:** still shows `ctask-projects-ctask-053-smoke-XXXXXX (... detached)`. +**Expected:** still shows `ctask-projects-ctask-053-smoke-XXXXXX: 1 windows ...`. +No `(attached)` marker now (both clients have detached). As noted in O3, +tmux does not print a literal `(detached)` token; absence of `(attached)` +is the signal. ### A3. (WSL-A) Wait for the lease to go stale @@ -245,8 +271,12 @@ detects session end and runs adoption finalize. WSL-A's prompt returns. ### A6. (WSL-A) Inspect the summary +The workspace directory uses **today's local-time date** (v0.5.1). Use a +glob so this works on any day: + ```bash -WS=$CTASK_ROOT/projects/2026-05-08_ctask-053-smoke +WS=$(ls -d "$CTASK_ROOT"/projects/*_ctask-053-smoke 2>/dev/null | head -1) +echo "$WS" # expect: /tmp/ctask-053-smoke/projects/YYYY-MM-DD_ctask-053-smoke cat "$WS/.ctask/last-session-summary.json" | python3 -m json.tool ``` @@ -256,7 +286,7 @@ cat "$WS/.ctask/last-session-summary.json" | python3 -m json.tool "end_reason": "tmux_session_ended", "detected_via": "polling", "session_ownership": "adopted", -"adopted_from_orphan_at": "2026-05-08T..." +"adopted_from_orphan_at": "T..." ``` (Other fields like session_id, hostname, files_added etc. will also be @@ -276,10 +306,23 @@ Run in **WSL-A** (now back at a normal prompt): echo "" | ctask resume ctask-053-smoke ``` -**Expected:** A multi-line refusal message containing all of: +**Expected:** A multi-line refusal message containing **(substring match, +not exact)** all three of: - `ctask persistent mode requires an interactive terminal` - `ssh -t ctask resume ` -- `ctask resume --direct` +- `resume --direct` + +Wording around these phrases (headings like "Over SSH, use:", a trailing +period, etc.) is incidental — PASS as long as all three substrings are +present. + +> **Note on the bypass hint:** the binary name in the printed bypass +> line reflects `basename(os.Args[0])`. On this WSL setup that resolves +> to `ctask` (so the line reads `ctask resume --direct`, +> matching the third substring). On Windows when running a local build +> as `.\ctask.exe` it resolves to `ctask.exe` (line reads +> `ctask.exe resume --direct`). Both forms satisfy the +> "resume --direct" substring above. Exit code: 1 (`echo $?` to verify). @@ -287,7 +330,11 @@ PASS / FAIL: ___ ### T2. (Optional, WSL-A) SSH-without-t equivalent -Skip this if `sshd` is not running on localhost. +**Skip with N/A if `ssh localhost` fails with "Connection refused"** — +that just means sshd isn't running on this WSL distro (the common case). +The test only exercises the same non-TTY refusal path that T1 already +covers, via a different no-TTY transport; not running it does not gate +v0.5.3 sign-off. ```bash ssh localhost -- bash -lc "PATH=\$HOME/.local/bin:\$PATH CTASK_SESSION_MODE=persistent CTASK_ROOT=$CTASK_ROOT ctask resume ctask-053-smoke" @@ -295,7 +342,7 @@ ssh localhost -- bash -lc "PATH=\$HOME/.local/bin:\$PATH CTASK_SESSION_MODE=pers **Expected:** Same refusal message. (No `-t` means no TTY allocation.) -PASS / FAIL (or N/A): ___ +PASS / FAIL / N/A: ___ --- @@ -404,18 +451,33 @@ half-initialized workspace on disk. ### M1. (WSL-A) Hide tmux from PATH (carefully) -We need to remove `/usr/bin` from PATH but keep `~/.local/bin/ctask` -reachable. Run in **WSL-A**: +We need `~/.local/bin/ctask` reachable but `tmux` NOT findable on PATH. + +On modern Debian / Ubuntu / WSL distros (the "usrmerge" change), `/bin` is +a symlink to `/usr/bin`, so listing `/bin` on PATH still surfaces tmux. +The cleanest fix is to set PATH to only `~/.local/bin`. + +A separate gotcha: once PATH is reduced this far, the `which` utility +itself is no longer reachable (it lives in `/usr/bin`). Use the POSIX +shell built-in `command -v` instead — it's a bash built-in, so it works +regardless of PATH. ```bash ORIG_PATH=$PATH -export PATH="$HOME/.local/bin:/bin" # excludes /usr/bin where tmux lives -which tmux 2>/dev/null && echo "ERROR: tmux still on PATH" -which ctask # should print $HOME/.local/bin/ctask +export PATH="$HOME/.local/bin" # only ctask reachable; nothing else +command -v tmux && echo "ERROR: tmux still on PATH" # expect: NO output (no path, no ERROR) +command -v ctask # expect: /home//.local/bin/ctask ``` -If `which tmux` finds tmux at e.g. `/bin/tmux` or `~/.local/bin/tmux`, -adjust accordingly — the goal is `which tmux` returns nothing. +ctask is a pure-Go binary that does not shell out to anything except tmux, +so a PATH containing only its own directory is sufficient for the M2 / M3 +refusal-path check. The earlier `PATH=$HOME/.local/bin:/bin` form and the +`which` invocation were both checklist bugs — neither correctly verifies +that tmux is hidden on a usrmerge system with a minimal PATH. + +If `command -v tmux` still prints a path (e.g., `~/.local/bin/tmux`), some +copy of tmux remains reachable — remove or rename that file for the +duration of this section. ### M2. (WSL-A) Try `ctask new` with persistent mode @@ -440,35 +502,82 @@ To disable persistent mode: unset CTASK_SESSION_MODE ``` -### M3. (WSL-A) Verify NO workspace was created +(The binary name in the bypass line is `basename(os.Args[0])` — in WSL +with an installed `~/.local/bin/ctask` on PATH, the shell-resolved +absolute path basenames to `ctask`, so the printed line matches the form +shown above.) -```bash -ls "$CTASK_ROOT/projects/" 2>/dev/null -``` +### M3. (WSL-A) Restore PATH -**Expected:** only `2026-05-08_ctask-053-smoke` (from earlier sections), -NO `ctask-053-no-tmux`. - -PASS / FAIL: ___ - -### M4. (WSL-A) Restore PATH +Restore PATH **before** the directory-listing verification — under the +minimal PATH from M1, `/usr/bin/ls` isn't reachable and the verification +silently fails (bash prints "ls: command not found" to stderr, which a +naive `2>/dev/null` would swallow). ```bash export PATH="$ORIG_PATH" -which tmux # Expected: /usr/bin/tmux +command -v tmux # Expected: an absolute path to tmux, + # e.g. /usr/bin/tmux OR /bin/tmux — both are + # the same file on usrmerge systems where + # /bin is a symlink to /usr/bin. ``` +### M4. (WSL-A) Verify NO workspace was created + +```bash +ls "$CTASK_ROOT/projects/" +``` + +**Expected:** only the `YYYY-MM-DD_ctask-053-smoke` directory created +earlier in this run, NO `ctask-053-no-tmux` and NO +`YYYY-MM-DD_ctask-053-no-tmux`. (If you ran the older version of this +step under the minimal PATH and got "no output", that was the +`ls`-not-on-PATH bug — it does not mean the workspace was created +silently. Redo after the PATH restore.) + +PASS / FAIL: ___ + --- ## Native Windows refusal (Step 10) -Run in **PS-C** (Windows PowerShell, NOT WSL): +Run in **PS-C** (Windows PowerShell 7, NOT WSL). + +> **Important:** run **each numbered block below as its own input** — +> press Enter after each one and let PowerShell complete it before +> typing the next. Do NOT paste all five at once. With PSReadLine / +> Oh-My-Posh, multi-line paste is parsed as a single script block, and +> the execution order can confuse the user (PowerShell will happily run +> `.\ctask.exe` before `cd` has taken effect, producing "term not +> recognized" plus "go.mod not found" errors). + +### Step 10a. cd into the repo ```powershell cd C:\Users\Warren\claude_tasks\ctask +``` + +Verify with `Get-Location` — expect the path to end in `\ctask`. + +### Step 10b. Build the Windows binary + +```powershell go build -o ctask.exe . +``` + +Expect no output (success). The repo's `go.mod` lives at the root, so +this only works after step 10a. + +### Step 10c. Set the persistent-mode env vars + +```powershell $env:CTASK_SESSION_MODE = "persistent" $env:WSL_DISTRO_NAME = $null +``` + +### Step 10d. Trigger the native-Windows refusal + +```powershell .\ctask.exe new --no-launch ctask-053-windows ``` @@ -482,14 +591,19 @@ Recommended: sudo apt install tmux Or bypass persistent mode: - ctask new --direct + ctask.exe new --direct ``` -NO workspace created (verify under `%USERPROFILE%\ai-workspaces\`). +The bypass line reflects `basename(os.Args[0])` — running this section as +`.\ctask.exe` means the printed binary name is `ctask.exe`. (Running an +installed `ctask` would print `ctask.exe` too on Windows, since the OS +resolves it to the same `.exe`.) -### --direct under persistent on Windows +NO workspace created (verify under `%USERPROFILE%\ai-workspaces\` — +typical location for default Windows installs; the `ctask-053-windows` +directory should not exist). -Still in **PS-C**: +### Step 10e. --direct under persistent on Windows ```powershell .\ctask.exe new --no-launch --direct ctask-053-win-direct @@ -498,7 +612,7 @@ Still in **PS-C**: **Expected:** workspace created with one warning line: `[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)` -### Cleanup PS-C state +### Step 10f. Cleanup PS-C state ```powershell $leftover = Get-ChildItem -Path "$env:USERPROFILE\ai-workspaces\general" -Filter "*ctask-053-win-direct*" -ErrorAction SilentlyContinue diff --git a/internal/session/lease.go b/internal/session/lease.go index dc33d0f..4da4e83 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -159,7 +159,15 @@ func CleanupStaleLease(path string, staleAfter time.Duration) (*Lease, error) { // FormatActiveWarning renders the human-readable warning printed when a // fresh active lease is detected on session start. -func FormatActiveWarning(l *Lease, now time.Time) string { +// +// hint, when non-empty, is rendered between the "may cause conflicts" line +// and the "Continue anyway?" prompt. Each non-empty line of hint is +// indented with two spaces to match the rest of the block; callers should +// supply plain text (no leading indent, no trailing newline required). +// Used to surface "a tmux session exists; ctask attach is the +// reattach path" when ctask is about to enter direct mode on a workspace +// that already has a persistent tmux session. +func FormatActiveWarning(l *Lease, now time.Time, hint string) string { startedAgo := now.Sub(l.StartedAt) lastSeenAgo := now.Sub(l.LastHeartbeatAt) @@ -174,6 +182,16 @@ func FormatActiveWarning(l *Lease, now time.Time) string { fmt.Fprintf(&b, " Last seen: %s ago\n", FormatAgoShort(lastSeenAgo)) b.WriteString("\n") b.WriteString(" Opening a second session may cause conflicts.\n") + if hint != "" { + b.WriteString("\n") + for _, line := range strings.Split(strings.TrimRight(hint, "\n"), "\n") { + if line == "" { + b.WriteString("\n") + continue + } + fmt.Fprintf(&b, " %s\n", line) + } + } b.WriteString(" Continue anyway? [y/N] ") return b.String() } diff --git a/internal/session/lease_test.go b/internal/session/lease_test.go index 3f4dc92..e4ca084 100644 --- a/internal/session/lease_test.go +++ b/internal/session/lease_test.go @@ -251,7 +251,7 @@ func TestFormatActiveWarning(t *testing.T) { LastHeartbeatAt: lastSeen, } - got := FormatActiveWarning(lease, now) + got := FormatActiveWarning(lease, now, "") for _, want := range []string{ "[ctask] This workspace has an active session:", @@ -270,6 +270,45 @@ func TestFormatActiveWarning(t *testing.T) { t.Errorf("FormatActiveWarning missing %q in:\n%s", want, got) } } + if strings.Contains(got, "Tip:") || strings.Contains(got, "ctask attach") { + t.Errorf("FormatActiveWarning should not render a hint when none supplied:\n%s", got) + } +} + +func TestFormatActiveWarningWithHint(t *testing.T) { + now := time.Date(2026, 4, 21, 16, 45, 10, 0, time.UTC) + lease := &Lease{ + SessionID: "warren-desktop-12345-20260421143022", + Hostname: "warren-desktop", + Agent: "claude", + StartedAt: now.Add(-time.Hour), + LastHeartbeatAt: now.Add(-15 * time.Second), + } + hint := "Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n ctask attach ctask-053-smoke" + + got := FormatActiveWarning(lease, now, hint) + + for _, want := range []string{ + "[ctask] This workspace has an active session:", + "Opening a second session may cause conflicts.", + " Tip: a tmux session exists for this workspace.", + " To reattach instead of starting a second direct-mode session, run:", + " ctask attach ctask-053-smoke", + "Continue anyway? [y/N]", + } { + if !strings.Contains(got, want) { + t.Errorf("FormatActiveWarning with hint missing %q in:\n%s", want, got) + } + } + + // Hint must appear between the conflict warning and the y/N prompt. + conflictIdx := strings.Index(got, "Opening a second session may cause conflicts.") + tipIdx := strings.Index(got, "Tip: a tmux session exists") + promptIdx := strings.Index(got, "Continue anyway? [y/N]") + if !(conflictIdx < tipIdx && tipIdx < promptIdx) { + t.Errorf("hint placement wrong: conflict=%d tip=%d prompt=%d\nOutput:\n%s", + conflictIdx, tipIdx, promptIdx, got) + } } func TestFormatStaleCleanupNoticeRenders(t *testing.T) { diff --git a/internal/session/run.go b/internal/session/run.go index df37cc3..9e1e7eb 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -60,6 +60,12 @@ type LaunchOpts struct { // workspace is treated as provisional and removed on exit — see // handleProvisional. Set to false by resume/open/last. NewlyCreated bool + + // ActiveLeaseHint, when non-empty, is forwarded to PreflightOpts so the + // Layer-1 "Continue anyway?" prompt can render a contextual suggestion. + // Populated by the cmd-layer dispatcher when about to enter direct mode + // on a workspace that already has a live tmux session. + ActiveLeaseHint string } // manifestStartPath returns the path to the start manifest file. @@ -99,10 +105,11 @@ const ( func Run(opts LaunchOpts) error { // ---- Preflight (Layers 3 + 1) ---- preflight, err := PreflightFull(PreflightOpts{ - WsDir: opts.WsDir, - Force: opts.Force, - In: os.Stdin, - Out: os.Stderr, + WsDir: opts.WsDir, + Force: opts.Force, + In: os.Stdin, + Out: os.Stderr, + ActiveLeaseHint: opts.ActiveLeaseHint, }) if err != nil { fmt.Fprintf(os.Stderr, "[ctask] Warning: preflight check failed: %v\n", err) diff --git a/internal/session/run_preflight.go b/internal/session/run_preflight.go index 049e531..3fcfa1a 100644 --- a/internal/session/run_preflight.go +++ b/internal/session/run_preflight.go @@ -15,6 +15,13 @@ type PreflightOpts struct { Force bool In io.Reader Out io.Writer + + // ActiveLeaseHint, when non-empty, is rendered inside the Layer-1 + // "Continue anyway?" prompt — between the conflict warning and the + // y/N line. The cmd-layer dispatcher populates it (e.g., when + // entering direct mode on a workspace that has a live tmux session, + // suggest `ctask attach ` as the reattach path). + ActiveLeaseHint string } // PreflightResult reports whether the session should proceed and whether an @@ -98,7 +105,7 @@ func runActiveLeaseCheck(opts PreflightOpts) (bool, bool, error) { if opts.Force { return true, true, nil } - fmt.Fprint(opts.Out, FormatActiveWarning(existing, time.Now())) + fmt.Fprint(opts.Out, FormatActiveWarning(existing, time.Now(), opts.ActiveLeaseHint)) if !ConfirmYN(opts.In, opts.Out, "", false) { return false, false, nil }