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:
+1
-1
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user