polish(v0.5.3): v0.4 lease-prompt hint, invocation-name in user-facing commands, smoke-checklist fixes

Three independent v0.5.3 polish items surfaced during manual WSL smoke testing,
bundled into one commit since they're all string/UX touches with no behavior change.

1. v0.4 lease-prompt tightening (`internal/session/{lease,run,run_preflight}.go`,
   `cmd/entry.go`): when ctask is about to enter direct mode on a workspace that
   already has a live tmux session, the "Continue anyway?" prompt now suggests
   `ctask attach <slug>` as the reattach path. Threaded via
   `PreflightOpts.ActiveLeaseHint` / `LaunchOpts.ActiveLeaseHint`; computed in
   `cmd/entry.go::directModeTmuxHint` (best-effort: silent when no tmux on PATH
   or no session for the workspace). Closes the footgun where a user forgot to
   `export CTASK_SESSION_MODE=persistent` in a second terminal and hit the v0.4
   coexistence prompt without realizing tmux passive-reattach was the intended
   path.

2. Invocation-name in user-facing command hints (`cmd/invocation.go` new,
   `cmd/{persistent,entry,resume,doctor}.go`): the binary name printed in
   bypass / restore / "create one with" suggestions now reflects
   `filepath.Base(os.Args[0])` instead of a hard-coded "ctask". Local-build
   PowerShell users running `.\ctask.exe` see `ctask.exe new <ws> --direct`,
   matching what they need to type. Installed contexts continue to see `ctask`.
   Test seam (`invocationNameOverride`) pins the name to "ctask" in unit tests
   so substring assertions stay stable across Go test binary names. Descriptive
   prose ("ctask persistent mode requires...") and the ssh-remote hint
   (`ssh -t <host> ctask <subcmd>`) intentionally keep the literal "ctask" —
   they refer to the program identity / remote invocation, not the local
   command form. Affected tests: `cmd/{persistent,resume}_test.go` tightened to
   check the full `"<binary> <subcmd> <workspace> --direct"` form.

3. Smoke-checklist fixes (`docs/.../2026-05-08-v0.5.3-smoke-test-checklist.md`):
   six issues caught during the run -- S2 now exports CTASK_SESSION_MODE in
   both terminals (previously only WSL-A had it, which routed WSL-B's
   secondary resume through direct mode instead of passive reattach); O3/A2
   tmux-ls expectations corrected (tmux doesn't emit a literal "(detached)"
   token); P2 expected behavior rewritten (passive reattach detach returns to
   prompt immediately -- AttachExisting calls only shell.AttachSession with
   no PollSessionEnd, the owner is responsible for finalize); A1 no longer
   asks for Ctrl-C in WSL-B; A6 path now uses a glob (was hardcoded to the
   checklist's authoring date, broke on v0.5.1 local-time directory naming);
   M1 PATH-hide rewritten to `$HOME/.local/bin` only (was `:/bin` which is a
   symlink to /usr/bin on usrmerge systems, did not hide tmux) and uses
   `command -v` instead of `which` (which is itself in /usr/bin, unreachable
   under the minimal PATH); M3/M4 reordered so the no-workspace verification
   runs after PATH is restored (was silently failing under minimal PATH);
   T1 wording made "substring match" explicit; T2 wording made N/A
   unambiguous; section 10 split into 10a-10f, each a separate input, to
   work around PSReadLine multi-line paste parsing.
This commit is contained in:
2026-05-14 18:22:27 -04:00
parent 548e292310
commit c204d87b47
12 changed files with 344 additions and 72 deletions
+1 -1
View File
@@ -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++
}
+39 -12
View File
@@ -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 <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 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")
}
+39
View File
@@ -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
// <slug>") 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 ("<name> new <workspace> --direct",
// "<name> attach <slug>", 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])
}
+2 -1
View File
@@ -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 + " <workspace> --direct"
bin := invocationName()
bypass := " " + bin + " " + commandName + " <workspace> --direct"
if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" {
return "", fmt.Errorf(
+18 -4
View File
@@ -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 <workspace> --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 <workspace> --direct") {
t.Errorf("commandName must appear in bypass hint: %v", err)
}
}
+2 -2
View File
@@ -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")
}
+6
View File
@@ -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)
@@ -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": "<today>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 <host> ctask resume <workspace>`
- `ctask resume <workspace> --direct`
- `resume <workspace> --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 <workspace> --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 <workspace> --direct`). Both forms satisfy the
> "resume <workspace> --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/<you>/.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 <workspace> --direct
ctask.exe new <workspace> --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
+19 -1
View File
@@ -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 <slug> 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()
}
+40 -1
View File
@@ -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) {
+11 -4
View File
@@ -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)
+8 -1
View File
@@ -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 <slug>` 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
}