diff --git a/cmd/entry.go b/cmd/entry.go index 11be00d..91927c7 100644 --- a/cmd/entry.go +++ b/cmd/entry.go @@ -153,9 +153,17 @@ func directModeTmuxHint(opts WorkspaceEntryOptions) string { if !shell.HasSession(tmuxPath, sessionName) { return "" } + return formatDirectModeTmuxHint(opts.WsMeta.Slug) +} + +// formatDirectModeTmuxHint builds the hint string itself, with no tmux +// or filesystem checks. Split out so unit tests can verify that the +// command-form line uses invocationName() without needing a live tmux +// session set up against a real workspace. +func formatDirectModeTmuxHint(slug string) string { return fmt.Sprintf( "Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n %s attach %s", - invocationName(), opts.WsMeta.Slug) + invocationName(), slug) } func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { diff --git a/cmd/info.go b/cmd/info.go index 27bb2d4..cc2579d 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" ) var infoCmd = &cobra.Command{ @@ -44,6 +45,8 @@ func runInfo(cmd *cobra.Command, args []string) error { fmt.Printf("Updated: %s\n", m.UpdatedAt.Local().Format("2006-01-02 15:04:05")) fmt.Printf("Path: %s\n", ws.Path) + printSessionBlock(ws.Path, m.Slug) + if m.LaunchDir != "" { // Per spec amendment: stat the expected path directly instead of // inferring existence from ResolveLaunch's fallback behavior. info @@ -81,3 +84,60 @@ func runInfo(cmd *cobra.Command, args []string) error { return nil } + +// printSessionBlock renders the v0.5.4 Session block for `ctask info`. +// +// Layout (values align at column 14 across the block): +// +// Session: +// Mode: (omitted when malformed) +// Owner: [host / ]pid N (Active; "Last owner:" when stale) +// Attach: attach (Active + persistent only) +// Note: (stale or malformed only) +// +// The hostname is omitted from the Owner/Last-owner line when it matches +// the local machine, matching the spec's "omit when local" rule. +// +// All command-form text uses invocationName() so the hint reflects how +// the user actually invoked the binary (./ctask vs ctask.exe vs ctask). +// SessionStatus itself stays neutral and never builds a command string. +func printSessionBlock(wsPath, slug string) { + s := session.SessionStatus(wsPath) + fmt.Println() + + switch s.State { + case session.SessionStateNone: + fmt.Println("Session: none") + return + case session.SessionStateStale: + // Malformed lease: SessionStatus reports state=stale, mode empty, + // diagnostic set. Render only the Note so we don't pretend to + // know the mode/owner when the file couldn't be parsed. + if s.Diagnostic != "" { + fmt.Println("Session: stale") + fmt.Printf(" Note: %s\n", s.Diagnostic) + return + } + } + + fmt.Printf("Session: %s\n", s.State) + fmt.Printf(" Mode: %s\n", s.Mode) + + ownerValue := fmt.Sprintf("pid %d", s.PID) + if s.Hostname != "" && s.Hostname != session.CurrentHostname() { + ownerValue = s.Hostname + " / " + ownerValue + } + if s.State == session.SessionStateActive { + fmt.Printf(" Owner: %s\n", ownerValue) + } else { + fmt.Printf(" Last owner: %s\n", ownerValue) + } + + if s.State == session.SessionStateActive && s.Mode == "persistent" { + fmt.Printf(" Attach: %s attach %s\n", invocationName(), slug) + } + + if s.State == session.SessionStateStale { + fmt.Println(" Note: lease expired; workspace may be available") + } +} diff --git a/cmd/info_session_test.go b/cmd/info_session_test.go new file mode 100644 index 0000000..a3c38b9 --- /dev/null +++ b/cmd/info_session_test.go @@ -0,0 +1,225 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// withInvocationName is provided by persistent_test.go in this package. + +// makeInfoSessionWorkspace writes a workspace under root with the given +// slug and returns the workspace directory path. The workspace metadata +// is minimal — info only needs a parsable task.yaml. +func makeInfoSessionWorkspace(t *testing.T, root, slug string) string { + t.Helper() + dirName := "2026-05-14_" + slug + wsDir := filepath.Join(root, "general", dirName) + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: slug, Title: slug, + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + return wsDir +} + +func TestInfoNoSession(t *testing.T) { + root := t.TempDir() + makeInfoSessionWorkspace(t, root, "no-sess") + + out, err := runInfoCapture(t, root, "no-sess") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: none") { + t.Errorf("expected 'Session: none' in output:\n%s", out) + } + for _, mustNot := range []string{" Mode:", " Owner:", " Attach:", " Note:"} { + if strings.Contains(out, mustNot) { + t.Errorf("none-state info should not contain %q:\n%s", mustNot, out) + } + } +} + +func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) { + withInvocationName(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "active-persist") + + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 12345, + Hostname: session.CurrentHostname(), + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "active-persist") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + for _, want := range []string{ + "Session: active", + "Mode: persistent", + "Owner: pid 12345", // local host -> hostname omitted + "Attach: ctask attach active-persist", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output:\n%s", want, out) + } + } +} + +func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) { + withInvocationName(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "active-direct") + + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 8888, + Hostname: session.CurrentHostname(), + Mode: "direct", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "active-direct") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: active") { + t.Errorf("expected 'Session: active' in output:\n%s", out) + } + if !strings.Contains(out, "Mode: direct") { + t.Errorf("expected 'Mode: direct' in output:\n%s", out) + } + if strings.Contains(out, "Attach:") { + t.Errorf("direct active session must NOT show Attach hint:\n%s", out) + } +} + +func TestInfoShowsRemoteHostnameInOwnerLine(t *testing.T) { + withInvocationName(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "remote-active") + + other := "some-other-host-not-this-one" + if other == session.CurrentHostname() { + other = "different-" + other + } + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 7777, + Hostname: other, + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "remote-active") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + wantOwner := "Owner: " + other + " / pid 7777" + if !strings.Contains(out, wantOwner) { + t.Errorf("expected %q in output:\n%s", wantOwner, out) + } +} + +func TestInfoShowsStaleSessionWithLastOwner(t *testing.T) { + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "stale-sess") + + heartbeat := time.Now().UTC().Add(-10 * time.Minute) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 4242, + Hostname: session.CurrentHostname(), + Mode: "direct", + StartedAt: heartbeat, + LastHeartbeatAt: heartbeat, + }) + + out, err := runInfoCapture(t, root, "stale-sess") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + for _, want := range []string{ + "Session: stale", + "Mode: direct", + "Last owner: pid 4242", + "Note: lease expired; workspace may be available", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output:\n%s", want, out) + } + } + if strings.Contains(out, "Attach:") { + t.Errorf("stale session must NOT show Attach hint:\n%s", out) + } +} + +func TestInfoShowsMalformedLeaseAsStaleWithDiagnostic(t *testing.T) { + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "broken-lease") + + if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(session.LeasePath(wsDir), []byte("{not json"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + out, err := runInfoCapture(t, root, "broken-lease") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: stale") { + t.Errorf("malformed-lease info should show 'Session: stale':\n%s", out) + } + if !strings.Contains(out, "Note: lease exists but could not be read") { + t.Errorf("malformed-lease info should surface the diagnostic:\n%s", out) + } + // Mode/Owner/Attach are deliberately suppressed for the malformed case + // — we don't have a parsed lease to read those values from. The + // indented " " prefix scopes the assertion to the session block so + // we don't false-positive on the workspace-metadata "Mode: local" + // header line. + for _, mustNot := range []string{" Mode:", " Owner:", " Last owner:", " Attach:"} { + if strings.Contains(out, mustNot) { + t.Errorf("malformed-lease info should not contain %q:\n%s", mustNot, out) + } + } +} + +// writeLeaseAtForCmdTest writes a Lease as JSON to wsDir's lease path. +// Local to the cmd package — internal/session has its own writeLeaseAt +// that we cannot reach across packages. +func writeLeaseAtForCmdTest(t *testing.T, wsDir string, l *session.Lease) { + t.Helper() + if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + data, err := json.MarshalIndent(l, "", " ") + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(session.LeasePath(wsDir), data, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} diff --git a/cmd/invocation_audit_test.go b/cmd/invocation_audit_test.go new file mode 100644 index 0000000..dad7016 --- /dev/null +++ b/cmd/invocation_audit_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "strings" + "testing" +) + +// v0.5.4 invocation-name audit: spec §2 codifies that command-form +// hints use invocationName() while product-identity references stay +// literal. The pre-existing tests already cover most paths individually +// (resume restore hint in resume_test.go, persistent bypass hints in +// persistent_test.go). These tests pin down the remaining piece — the +// Layer-1 active-session prompt's attach hint — and re-assert the +// resume restore-hint contract against an explicitly non-canonical +// invocation name so a regression that hard-codes "ctask" anywhere +// upstream of the hint format will fail loudly. + +func TestInvocationNameInActiveSessionPrompt(t *testing.T) { + // directModeTmuxHint composes the Layer-1 prompt's attach suggestion + // from formatDirectModeTmuxHint. The format-only helper is the right + // surface to test: it isolates the rendering decision from the tmux + // presence checks (which are environment-dependent) but exercises the + // exact string the user will see. + withInvocationName(t, "my-bin") + + got := formatDirectModeTmuxHint("demo-ws") + + if !strings.Contains(got, "my-bin attach demo-ws") { + t.Errorf("Layer-1 attach hint should use invocation name; got:\n%s", got) + } + if strings.Contains(got, "ctask attach demo-ws") { + t.Errorf("Layer-1 attach hint must NOT hard-code 'ctask attach' when invocation name differs; got:\n%s", got) + } +} + +func TestInvocationNameInRestoreHintNonCanonical(t *testing.T) { + // Complement to TestResumeArchivedShowsRestoreHint in resume_test.go, + // which pins invocationName to "ctask" — that protects against test + // binary noise but cannot detect a regression that hard-codes "ctask" + // in the format string. Pinning a non-default name flushes that out. + withInvocationName(t, "my-bin") + + got := formatResumeRestoreHint("my-archived-ws") + + if !strings.Contains(got, "my-bin restore my-archived-ws") { + t.Errorf("resume restore hint should use invocation name; got:\n%s", got) + } + if strings.Contains(got, "ctask restore") { + t.Errorf("resume restore hint must NOT hard-code 'ctask restore' when invocation name differs; got:\n%s", got) + } +} diff --git a/cmd/list.go b/cmd/list.go index cc348d3..e888ac2 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -100,8 +101,9 @@ func runList(cmd *cobra.Command, args []string) error { date = dirName[:10] } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ws.Meta.Status, + sessionColumn(ws.Path, ws.Meta.Status), workspace.EffectiveType(ws.Meta), ws.Meta.Mode, ws.Meta.Category, @@ -113,3 +115,43 @@ func runList(cmd *cobra.Command, args []string) error { return nil } + +// noSessionDisplay is the placeholder shown in the SESSION column when +// no session lease is present (or for archived workspaces, which are +// always rendered as no-session for display simplicity per v0.5.4 spec). +// Em dash matches the spec's example output. +const noSessionDisplay = "—" // — + +// sessionColumn returns the SESSION column value for ctask list. +// +// Archived workspaces always render as the em-dash placeholder — the +// spec calls this a display simplification, not a lifecycle invariant. +// Archive guards against active sessions, but a crash or manual file +// manipulation could theoretically leave a lease behind; ctask info +// will surface that diagnostic, ctask list will not. +// +// Active workspaces map SessionStatus to a single token: +// - state=none -> em dash +// - state=stale -> "stale" +// - state=active -> the lease's mode ("direct" or "persistent") +// +// SessionStatus reads only the lease file — no tmux invocation, no PID +// liveness — so this adds at most one short file read per workspace. +func sessionColumn(wsPath, wsStatus string) string { + if wsStatus == "archived" { + return noSessionDisplay + } + s := session.SessionStatus(wsPath) + switch s.State { + case session.SessionStateNone: + return noSessionDisplay + case session.SessionStateStale: + return "stale" + case session.SessionStateActive: + if s.Mode == "" { + return "active" + } + return s.Mode + } + return noSessionDisplay +} diff --git a/cmd/list_session_test.go b/cmd/list_session_test.go new file mode 100644 index 0000000..1ef27a3 --- /dev/null +++ b/cmd/list_session_test.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// makeListSessionWorkspace writes a workspace beneath root and returns +// the workspace directory. Mirrors makeInfoSessionWorkspace but allows +// the caller to control category, status, and slug for list-fixture +// scenarios. +func makeListSessionWorkspace(t *testing.T, root, category, dirName, slug, status string) string { + t.Helper() + wsDir := filepath.Join(root, category, dirName) + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: slug, Title: slug, + CreatedAt: now, UpdatedAt: now, + Status: status, Category: category, Type: "task", + Mode: "local", Agent: "claude", + } + if status == "archived" { + meta.ArchivedAt = &now + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + return wsDir +} + +func TestListSessionColumnShowsModeAndStaleAndDash(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + host := session.CurrentHostname() + + persistWS := makeListSessionWorkspace(t, root, "general", "2026-05-14_persist-ws", "persist-ws", "active") + directWS := makeListSessionWorkspace(t, root, "general", "2026-05-13_direct-ws", "direct-ws", "active") + staleWS := makeListSessionWorkspace(t, root, "general", "2026-05-12_stale-ws", "stale-ws", "active") + makeListSessionWorkspace(t, root, "general", "2026-05-11_idle-ws", "idle-ws", "active") + + writeLeaseAtForCmdTest(t, persistWS, &session.Lease{ + PID: 1, Hostname: host, Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + writeLeaseAtForCmdTest(t, directWS, &session.Lease{ + PID: 2, Hostname: host, Mode: "direct", + StartedAt: now, LastHeartbeatAt: now, + }) + heartbeat := now.Add(-10 * time.Minute) + writeLeaseAtForCmdTest(t, staleWS, &session.Lease{ + PID: 3, Hostname: host, Mode: "direct", + StartedAt: heartbeat, LastHeartbeatAt: heartbeat, + }) + + out, _, err := runListCapture(t, root, false, false, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + + type expectation struct { + slug string + session string + } + for _, exp := range []expectation{ + {"persist-ws", "persistent"}, + {"direct-ws", "direct"}, + {"stale-ws", "stale"}, + {"idle-ws", noSessionDisplay}, + } { + line := findLineContaining(out, exp.slug) + if line == "" { + t.Errorf("output missing line for slug %q:\n%s", exp.slug, out) + continue + } + if !columnTokenPresent(line, exp.session) { + t.Errorf("slug %q line should contain session token %q, got line %q", exp.slug, exp.session, line) + } + } +} + +func TestListSessionColumnArchivedAlwaysDash(t *testing.T) { + // Archived workspaces show the em-dash placeholder in the SESSION + // column even if a lease file exists. ctask info still surfaces the + // raw lease state for diagnostic purposes; list keeps the simpler + // view. + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + + archivedWS := makeListSessionWorkspace(t, root, "general", "2026-05-10_archived-ws", "archived-ws", "archived") + writeLeaseAtForCmdTest(t, archivedWS, &session.Lease{ + PID: 99, Hostname: session.CurrentHostname(), Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + + out, _, err := runListCapture(t, root, true, false, false) + if err != nil { + t.Fatalf("runList --all: %v", err) + } + + line := findLineContaining(out, "archived-ws") + if line == "" { + t.Fatalf("output missing line for archived-ws:\n%s", out) + } + if !columnTokenPresent(line, noSessionDisplay) { + t.Errorf("archived-ws line should contain em-dash session token, got %q", line) + } + if columnTokenPresent(line, "persistent") { + t.Errorf("archived-ws line must NOT show 'persistent' even with a lease file present, got %q", line) + } +} + +func TestListNamesUnchangedHasNoSessionColumn(t *testing.T) { + // Spec invariant: ctask list --names is machine-readable, one + // basename per line, no session column. Adding the SESSION column + // to the formatted view must not leak into --names output. + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + host := session.CurrentHostname() + + persistWS := makeListSessionWorkspace(t, root, "general", "2026-05-14_persist-ws", "persist-ws", "active") + writeLeaseAtForCmdTest(t, persistWS, &session.Lease{ + PID: 1, Hostname: host, Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + makeListSessionWorkspace(t, root, "general", "2026-05-13_idle-ws", "idle-ws", "active") + + out, err := runListNamesCapture(t, root, false) + if err != nil { + t.Fatalf("runList --names: %v", err) + } + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + for _, line := range lines { + if line == "" { + continue + } + // Each line must be a bare basename, no whitespace, no session token. + if strings.ContainsAny(line, " \t") { + t.Errorf("--names output line must not contain whitespace, got %q", line) + } + for _, tok := range []string{"persistent", "direct", "stale", noSessionDisplay} { + if strings.Contains(line, tok) { + t.Errorf("--names output line %q must not contain session token %q", line, tok) + } + } + } + + // And the basenames we expect must still be there. + for _, want := range []string{"2026-05-14_persist-ws", "2026-05-13_idle-ws"} { + found := false + for _, line := range lines { + if line == want { + found = true + break + } + } + if !found { + t.Errorf("--names output missing %q:\n%s", want, out) + } + } +} + +// findLineContaining returns the first line of out that contains substr, +// or "" if no such line exists. +func findLineContaining(out, substr string) string { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, substr) { + return line + } + } + return "" +} + +// columnTokenPresent reports whether token appears in line as a +// whitespace-separated field. This avoids substring false positives like +// "stale" matching inside another token. +func columnTokenPresent(line, token string) bool { + for _, f := range strings.Fields(line) { + if f == token { + return true + } + } + return false +} diff --git a/cmd/resume.go b/cmd/resume.go index b738dcd..c1943b3 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "path/filepath" @@ -38,8 +39,21 @@ func init() { rootCmd.AddCommand(resumeCmd) } +// errArchivedWorkspace is the sentinel doResume returns when the user +// asks to resume an archived workspace. doResume already prints the +// full [ctask] diagnostic + restore hint to stderr, so the cmd-layer +// wrapper flips SilenceErrors to suppress Cobra's redundant trailing +// "Error: workspace archived" line. All other errors (lookup failure, +// metadata write failure, etc.) flow through Cobra's default +// rendering unchanged. +var errArchivedWorkspace = errors.New("workspace archived") + func runResume(cmd *cobra.Command, args []string) error { - return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) + err := doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) + if errors.Is(err, errArchivedWorkspace) { + cmd.SilenceErrors = true + } + return err } // doResume is the shared resume logic used by both `resume` and `last`. @@ -58,10 +72,8 @@ func doResume(query string, container, useShell, force bool, agentOverride strin ws := resolveOne(roots, query, true) if ws.Meta.Status == "archived" { - fmt.Fprintf(os.Stderr, - "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", - query, invocationName(), query) - return fmt.Errorf("workspace archived") + fmt.Fprint(os.Stderr, formatResumeRestoreHint(query)) + return errArchivedWorkspace } // updated_at bump (existing v0.4 behavior). @@ -88,3 +100,19 @@ func doResume(query string, container, useShell, force bool, agentOverride strin CommandName: "resume", }) } + +// formatResumeRestoreHint builds the multi-line stderr block printed +// when `ctask resume ` resolves to an archived workspace. +// Extracted so the v0.5.4 invocation-name audit can verify the +// command-form line uses invocationName() without depending on the +// surrounding fmt.Fprintf machinery. +// +// The "[ctask]" diagnostic prefix is intentionally a literal product +// reference (spec §2: product-identity references stay literal). The +// `restore ` line is the command-form portion and uses +// invocationName(). +func formatResumeRestoreHint(query string) string { + return fmt.Sprintf( + "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", + query, invocationName(), query) +} diff --git a/cmd/resume_archived_polish_test.go b/cmd/resume_archived_polish_test.go new file mode 100644 index 0000000..253bfb3 --- /dev/null +++ b/cmd/resume_archived_polish_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// TestResumeArchivedHintNoDuplicateError exercises resume through Cobra +// (not just runResume directly) so the SilenceErrors path is observed +// end-to-end. After v0.5.4 the [ctask] diagnostic block must print on +// its own — Cobra's default trailing "Error: workspace archived" line +// is suppressed via the conditional SilenceErrors set inside runResume. +// +// This guards against: +// - Reverting to a generic fmt.Errorf("workspace archived") that no +// longer matches the sentinel. +// - Removing the SilenceErrors flip. +// - A future runResume refactor that returns the error before the +// conditional check runs. +func TestResumeArchivedHintNoDuplicateError(t *testing.T) { + withInvocationName(t, "ctask") + + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-04-22_archived-poll") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + archived := now.Add(-time.Hour) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "archived-poll", Title: "archived-poll", + CreatedAt: now, UpdatedAt: archived, + ArchivedAt: &archived, + Status: "archived", + Category: "general", + Type: "task", + Mode: "local", + Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + // resumeCmd is a package global; restore SilenceErrors after the test + // so other tests against the same command see the original setting. + prevSilence := resumeCmd.SilenceErrors + t.Cleanup(func() { resumeCmd.SilenceErrors = prevSilence }) + + // Drive a fresh Cobra parent so we don't invoke unrelated commands + // during the test. AddCommand removes from the previous parent first. + parent := &cobra.Command{Use: "ctask-test"} + parent.AddCommand(resumeCmd) + t.Cleanup(func() { rootCmd.AddCommand(resumeCmd) }) + + parent.SetArgs([]string{"resume", "archived-poll"}) + + // Cobra writes the trailing "Error: ..." line to its configured + // error output, NOT to os.Stderr by default. Capture both: the + // [ctask] diagnostic goes to os.Stderr (via fmt.Fprint), while the + // would-be Cobra trailing line would go to parent's err output. + var cobraErrBuf bytes.Buffer + parent.SetErr(&cobraErrBuf) + parent.SetOut(&bytes.Buffer{}) + + stderrR, stderrW, _ := os.Pipe() + prevStderr := os.Stderr + os.Stderr = stderrW + defer func() { os.Stderr = prevStderr }() + + execErr := parent.Execute() + + stderrW.Close() + var directStderr bytes.Buffer + directStderr.ReadFrom(stderrR) + + if execErr == nil { + t.Fatal("expected error from resuming archived workspace") + } + if !errors.Is(execErr, errArchivedWorkspace) { + t.Errorf("Execute should return errArchivedWorkspace, got %v", execErr) + } + + // The [ctask] diagnostic must be present (printed to os.Stderr by + // runResume's stderr write). + gotStderr := directStderr.String() + if !strings.Contains(gotStderr, "[ctask] error: workspace") { + t.Errorf("[ctask] diagnostic missing from stderr:\n%s", gotStderr) + } + if !strings.Contains(gotStderr, "ctask restore archived-poll") { + t.Errorf("restore hint missing from stderr:\n%s", gotStderr) + } + + // The Cobra-default "Error: workspace archived" line must NOT appear. + // It would normally land on parent's error writer. + cobraOut := cobraErrBuf.String() + if strings.Contains(cobraOut, "Error:") { + t.Errorf("Cobra default Error: line was not suppressed:\n%s", cobraOut) + } +} diff --git a/cmd/root.go b/cmd/root.go index fcf5d8a..6fec2a1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -var version = "0.5.3" +var version = "0.5.4" var rootCmd = &cobra.Command{ Use: "ctask", diff --git a/docs/commands.md b/docs/commands.md index d9ec3d2..800df45 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,576 +1,764 @@ -# Commands +# ctask Commands Reference -## ctask new +This document describes every ctask command, its flags, the workspace layout +ctask creates, the environment variables that influence its behavior, how +queries resolve to workspaces, the two session modes, and how to install +shell completion. -Create a new task or project workspace and launch the agent. +It is a practical reference, not a tutorial. Examples use the canonical +`ctask` command name; the binary may surface as `ctask.exe` or +`./ctask` in your shell, depending on how it was invoked. -``` -ctask new [title] [flags] -``` +## Table of contents -If title is omitted, generates `task-HHMMSS`. +- [Workspace layout](#workspace-layout) +- [Workspace creation and entry](#workspace-creation-and-entry) + - [`ctask new`](#ctask-new) + - [`ctask resume`](#ctask-resume) + - [`ctask last`](#ctask-last) + - [`ctask open`](#ctask-open) + - [`ctask attach`](#ctask-attach) +- [Inspection](#inspection) + - [`ctask info`](#ctask-info) + - [`ctask list`](#ctask-list) + - [`ctask notes`](#ctask-notes) + - [`ctask path`](#ctask-path) +- [Lifecycle](#lifecycle) + - [`ctask archive`](#ctask-archive) + - [`ctask restore`](#ctask-restore) + - [`ctask delete`](#ctask-delete) +- [Environment](#environment) + - [`ctask doctor`](#ctask-doctor) + - [`ctask completion`](#ctask-completion) +- [Environment variables](#environment-variables) +- [Query resolution](#query-resolution) +- [Session modes](#session-modes) +- [Shell completion](#shell-completion) +- [Exit codes](#exit-codes) + +--- + +## Workspace layout + +A task workspace: + + workspace-root/ + ├── task.yaml # workspace metadata (managed by ctask) + ├── CLAUDE.md # agent instructions (seeded by ctask, edited by agent) + ├── notes.md # working context (maintained by agent) + ├── logs/ + │ └── sessions.log # append-only session history + └── .ctask/ # ctask internals (do not edit) + ├── session.json # active session lease (v0.4+) + ├── write.lock # metadata write lock (v0.4+) + ├── manifest-start.json # session start snapshot (v0.2+) + └── last-session-summary.json # session summary (v0.4+) + +A project workspace adds a project subdirectory and (optionally) a git repo: + + workspace-root/ + ├── task.yaml # includes launch_dir: "" + ├── CLAUDE.md + ├── notes.md + ├── .git/ # single git repo at workspace root + ├── .gitignore # excludes .ctask/ and logs/sessions.log + ├── / # project subdirectory; agent launches here + │ └── ... # user's source code + ├── logs/ + │ └── sessions.log + └── .ctask/ + └── ... + +Files inside `.ctask/` are ctask state. Editing them by hand can confuse +session detection, archive guards, and the stale-workspace detector. + +--- + +## Workspace creation and entry + +### `ctask new` + +**Purpose:** Create a new task or project workspace and launch the agent. + +**Usage:** + + ctask new [title] [flags] + +If `title` is omitted, ctask generates `task-HHMMSS`. + +**Scenarios:** +- Starting a new piece of work from scratch. +- Standing up a longer-lived project workspace with `--project`. + +**Examples:** + + $ ctask new "fix auth bug" + [ctask] created general/2026-05-14_fix-auth-bug + + $ ctask new --project "billing service" + [ctask] created projects/2026-05-14_billing-service + + $ ctask new --no-launch "json cleanup" + [ctask] created general/2026-05-14_json-cleanup **Flags:** +- `--category`, `-c` — workspace category subdirectory. Default: `general` + for tasks, `projects` for projects. +- `--project` — create a project workspace. Uses `CTASK_PROJECT_ROOT` if + set, runs `git init`, seeds the project CLAUDE.md template. +- `--shell` — open an interactive shell instead of the agent. +- `--agent`, `-a` — override the agent command (default: `claude` or + `CTASK_AGENT`). +- `--no-launch` — create the workspace only; do not launch a session. +- `--direct` — bypass persistent session mode for this invocation. +- `--container` — deferred to a future release. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--category` | `-c` | `general` (task) / `projects` (project) | Workspace category subdirectory | -| `--project` | | off | Create a long-lived project workspace (uses `CTASK_PROJECT_ROOT` if set, runs `git init`, project CLAUDE.md) | -| `--shell` | | off | Open interactive shell instead of agent | -| `--agent` | `-a` | `claude` | Command to exec as the agent | -| `--no-launch` | | off | Create workspace only, do not launch | -| `--container` | | off | Deferred to a future release | +**Notes:** +- `--project` runs `git init` if `git` is on PATH and seeds a minimal + `.gitignore` (only when no `.gitignore` was provided by a seed). +- For project workspaces, the agent launches inside the project + subdirectory (controlled by `launch_dir` in `task.yaml`). +- If the session ends without producing any file changes (e.g. the user + cancels at the agent prompt), the workspace is removed automatically — + the "provisional workspace" cleanup gate. Persistent mode disables + this gate. -**Examples:** - -```powershell -ctask new "fix auth bug" -ctask new -c scripts "backup helper" -ctask new --no-launch "json cleanup" -ctask new --shell "test env" -ctask new --agent aider "refactor api" -ctask new -ctask new --project "billing service" -ctask new --project -c backend "billing service" -``` - -When `--no-launch` is used, no session is started and no session log is written. - -### Project mode (`--project`) - -`--project` is a thin variation on the normal task workflow for longer-lived work. It changes: - -- `task.yaml` records `type: project` and `launch_dir: ` (v0.5+) -- Default category becomes `projects` -- Workspace root falls back to `CTASK_PROJECT_ROOT` if set; otherwise `CTASK_ROOT` -- Built-in CLAUDE.md is the project-oriented template (overridable via seed directories) -- Seed order: built-in defaults -> general seed (`CTASK_SEED_DIR`) -> project seed (`CTASK_SEED_PROJECT_DIR`) -- `git init` runs if `git` is on PATH; a minimal `.gitignore` (`.ctask/` + `logs/sessions.log`) is created **only if no `.gitignore` was already provided by a seed** -- If `git` is not available, ctask prints `[ctask] git not found; skipped repository initialization` and continues -- A project subdirectory named after the final suffixed slug is created inside the workspace. ctask does not seed any files inside it -- the user places their own CLAUDE.md, source code, and project structure there. - -**Workspace layout (v0.5):** - -``` -2026-04-22_litlink-v2/ -├── .ctask/ ctask state (lease, manifest, summary, lock) -├── .git/ single git repo at workspace root -├── .gitignore -├── CLAUDE.md ctask workspace rules (managed by ctask + seed) -├── notes.md -├── task.yaml includes launch_dir: "litlink-v2" -├── context/ reference material, imported specs -├── output/ ctask deliverables -├── logs/ session logs -└── litlink-v2/ project subdirectory (user's codebase) -``` - -When `ctask resume litlink-v2` runs, the agent is launched inside `litlink-v2/`. Both the workspace CLAUDE.md (root) and any project CLAUDE.md the user places inside `litlink-v2/` apply to the session -- Claude Code reads CLAUDE.md hierarchically. - -**Project root semantics:** - -- `CTASK_PROJECT_ROOT` not set: workspace goes under `$CTASK_ROOT/projects/_` (default category `projects` is appended) -- `CTASK_PROJECT_ROOT` set, no `-c`: workspace goes directly under `$CTASK_PROJECT_ROOT/_` (no `projects/` subdirectory is appended) -- `CTASK_PROJECT_ROOT` set, explicit `-c `: workspace goes under `$CTASK_PROJECT_ROOT//_` - -**Single git repo rule:** - -Project workspaces use a single git repository initialized at the workspace root. -Do not create nested git repositories inside the workspace -- including inside -the project subdirectory. If your project code lives in a subdirectory, it is -tracked by the root repo. - -**Changing the launch directory:** - -The `launch_dir` field in `task.yaml` controls which subdirectory the agent is launched into on `ctask resume`, `ctask last`, and `ctask open`. By default it is set to the project slug. To launch from the workspace root instead, edit `task.yaml` and set `launch_dir: ""`. To launch from a deeper path (e.g., `backend/api`), set `launch_dir: "backend/api"`. No CLI command is provided -- manual edit is the only supported way to change it. - -If `launch_dir` points to a directory that does not exist (deleted, renamed) or is not a directory, ctask prints a warning and falls back to the workspace root. Absolute paths and paths that escape the workspace via `..` are errors and abort the session. - -### Seed directories - -On `ctask new`, after writing the built-in defaults, ctask copies the contents of an optional user seed directory into the workspace. Files in the seed directory overwrite the built-in defaults; subdirectories are preserved recursively. `task.yaml` and `.ctask/` at the seed root are always skipped. - -| Variable | Default (Unix) | Default (Windows) | -|----------|---------------|-------------------| -| `CTASK_SEED_DIR` | `~/.config/ctask/seed/` | `%APPDATA%\ctask\seed\` | -| `CTASK_SEED_PROJECT_DIR` | `~/.config/ctask/seed-project/` | `%APPDATA%\ctask\seed-project\` | - -The general seed is applied to every workspace. The project seed is applied **only** when `--project` is set, on top of the general seed (project seed wins). Both directories are optional; missing directories are silently ignored. +**Related:** `ctask resume`, `ctask attach`, `ctask doctor`. --- -## ctask list +### `ctask resume` -List workspaces in reverse-chronological order. +**Purpose:** Reopen an existing workspace and launch the agent. -``` -ctask list [flags] -``` +**Usage:** -By default, `ctask list` shows **all active workspaces** -- both tasks and projects. Use `--task` or `--projects` to narrow by type, and `--all` to include archived workspaces. + ctask resume [flags] + +**Scenarios:** +- Picking up where you left off in a previous session. +- Re-entering a long-lived project workspace. + +**Examples:** + + $ ctask resume auth-bug + [ctask] local :: fix-auth-bug + [ctask] ~/ai-workspaces/general/2026-05-14_fix-auth-bug + [ctask] Last session: 2026-05-13 14:30-15:45 (warren-desktop, claude) **Flags:** +- `--shell` — open an interactive shell instead of the agent. +- `--agent`, `-a` — override the agent command for this invocation. +- `--force` — skip both the active-session and stale-workspace warnings. + Use only when the human has already decided to proceed. +- `--direct` — bypass persistent session mode for this invocation. +- `--container` — deferred. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--all` | `-a` | off | Include archived workspaces | -| `--task` | | off | Show task workspaces only | -| `--projects` | | off | Show project workspaces only | -| `--category` | `-c` | all | Filter by category | -| `--limit` | `-n` | 20 | Maximum entries to show | +**Notes:** +- Resolves archived workspaces too, but refuses to launch them and + prints a `restore` hint instead. +- If multiple workspaces match the query, ctask prints all matches and + exits with an error. See [Query resolution](#query-resolution). +- Updates `updated_at` in `task.yaml` so `ctask last` recognises this + workspace as the most recent. -`--task` and `--projects` are mutually exclusive; passing both returns a usage error. - -**Examples:** - -```powershell -ctask list # active tasks AND projects -ctask list --all # everything (incl. archived) -ctask list --task # active tasks only -ctask list --task --all # all tasks (incl. archived) -ctask list --projects # active projects only -ctask list --projects --all # all projects (incl. archived) -ctask list -c scripts -n 5 -``` - -Output columns: status, type, mode, category, date, slug. - -Workspaces created before v0.3 (which have no `type` field in `task.yaml`) are treated as tasks. +**Related:** `ctask last`, `ctask attach`, `ctask restore`, `ctask info`. --- -## ctask resume +### `ctask last` -Reopen an existing workspace and launch the agent. +**Purpose:** Resume the most recently updated workspace, across both tasks +and projects. -``` -ctask resume [flags] -``` +**Usage:** -Resolves the workspace by query (exact directory name, exact slug, or case-insensitive substring). Archived workspaces are excluded by default. + ctask last [flags] + +**Scenarios:** +- Returning to whatever you were last working on without typing a name. + +**Examples:** + + $ ctask last + [ctask] local :: fix-auth-bug + [ctask] ... **Flags:** +- `--shell` — open an interactive shell instead of the agent. +- `--agent`, `-a` — override the agent command. +- `--force` — skip active-session and stale-workspace warnings. +- `--direct` — bypass persistent session mode for this invocation. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--shell` | | off | Open shell instead of agent | -| `--agent` | `-a` | from task.yaml | Override agent command | -| `--force` | | off | Skip active-session and stale-workspace warnings | -| `--container` | | off | Deferred to a future release | +**Notes:** +- Equivalent to `ctask resume` on whichever active workspace has the + latest `updated_at`. Archived workspaces are excluded. +- Exits non-zero with `No active workspaces found.` when there are no + active workspaces. -**Examples:** - -```powershell -ctask resume auth-bug -ctask resume backup -ctask resume --shell auth-bug -ctask resume --agent aider auth-bug -ctask resume --force auth-bug -``` - -If multiple workspaces match, prints all matches and exits. If none match, prints an error. - -Session logging runs automatically: file changes during the session are recorded in `logs/sessions.log`. +**Related:** `ctask resume`. --- -## ctask open +### `ctask open` -Open a workspace directory in an interactive shell without launching the agent. +**Purpose:** Open a workspace directory in an interactive shell without +launching the agent. -``` -ctask open [flags] -``` +**Usage:** -Spawns a new subshell in the workspace directory. Does not modify the caller's shell session. + ctask open [flags] + +**Scenarios:** +- Browsing the workspace, running git commands, editing files manually. + +**Examples:** + + $ ctask open auth-bug + # subshell rooted at the workspace; exit returns to your prior shell. **Flags:** +- `--all`, `-a` — include archived workspaces in query resolution. +- `--force` — skip active-session and stale-workspace warnings. +- `--direct` — bypass persistent session mode for this invocation. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--all` | `-a` | off | Include archived workspaces in query resolution | -| `--force` | | off | Skip active-session and stale-workspace warnings | +**Notes:** +- Spawns a subshell — the parent shell is unaffected. +- Same lease/manifest/summary lifecycle as `resume`, just running + `$SHELL` (or PowerShell on Windows) instead of the agent. -**Examples:** - -```powershell -ctask open auth-bug -``` +**Related:** `ctask resume`, `ctask path`. --- -## ctask info +### `ctask attach` -Display metadata and path for a workspace without entering it. +**Purpose:** Attach to a workspace via tmux. Always uses persistent +session mode regardless of `CTASK_SESSION_MODE`. -``` -ctask info [flags] -``` +**Usage:** + + ctask attach [flags] + +**Scenarios:** +- You have not enabled persistent mode globally but want tmux for one + workspace. +- A persistent tmux session already exists; you want to reattach + instead of starting a second direct-mode session. +- Scripts that need unambiguous "tmux always" behavior. + +**Examples:** + + $ ctask attach promptvolley-v3 + # owner-create OR passive reattach OR adopted reattach, + # depending on the workspace's current lease/tmux state. **Flags:** +- `--agent`, `-a` — override the agent command (used only on + owner-create, when ctask spawns the agent inside the new tmux session). +- `--force` — skip active-session and stale-workspace warnings. + Owner-create path only. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--all` | `-a` | off | Include archived workspaces in query resolution | +**Notes:** +- Active-only resolution: archived workspaces are not candidates. +- See [Session modes](#session-modes) for the three entry paths + (owner-create, passive reattach, adopted reattach). +- Native Windows is not supported. Run ctask under WSL. -**Examples:** - -```powershell -ctask info auth-bug -ctask info backup -``` - -Shows: slug, title, category, status, mode, agent, created/updated timestamps, path, and directory contents. For v0.5 project workspaces, also shows `Launch dir`, `Launch path`, and `Dir exists`. +**Related:** `ctask resume --direct`, `ctask doctor` (tmux check). --- -## ctask archive +## Inspection -Mark a workspace as archived. The workspace stays in place but is hidden from default listings and query resolution. +### `ctask info` -``` -ctask archive -``` +**Purpose:** Display metadata, path, and current session state for a +workspace without entering it. + +**Usage:** + + ctask info + +**Scenarios:** +- Diagnosing whether a workspace is currently in use. +- Checking the launch directory for a project workspace. **Examples:** -```powershell -ctask archive auth-bug -``` + $ ctask info promptvolley-v3 + Task: promptvolley-v3 + Title: Prompt Volley v3 + Category: projects + Status: active + Mode: local + Agent: claude + Created: 2026-04-24 09:12:03 + Updated: 2026-05-14 18:05:21 + Path: /home/warren/ai-workspaces/projects/2026-04-24_promptvolley-v3 -To see archived workspaces, use `ctask list --all`. To resolve archived workspaces in other commands, use the `--all` flag where available. + Session: active + Mode: persistent + Owner: pid 48291 + Attach: ctask attach promptvolley-v3 + + Launch dir: promptvolley-v3/ + Launch path: /home/warren/.../2026-04-24_promptvolley-v3/promptvolley-v3 + Dir exists: yes + + Contents: + .ctask/ + .git/ + CLAUDE.md + ... + +**Flags:** (none) + +**Notes:** +- Resolves archived workspaces by default — the `Status:` line shows + whether the workspace is active or archived. No `--all` flag needed. +- The `Session:` block is derived from the lease file (no tmux invocation, + no PID liveness checks). Possible states: `none`, `active`, `stale`. + Hostname is omitted when it matches the local machine. +- `Attach:` hint appears only for active+persistent sessions. +- For an archived workspace with a stranded lease (rare; theoretically + possible after a crash), `info` will still surface the raw session + state because `info` is the diagnostic command. `list` collapses + archived workspaces to a dash for simplicity. + +**Related:** `ctask list`, `ctask path`, `ctask notes`. --- -## ctask last +### `ctask list` -Resume the most recently updated workspace, considering **both tasks and projects**. Equivalent to `ctask resume` on whichever active workspace has the latest `updated_at` timestamp. Archived workspaces are excluded. +**Purpose:** List workspaces in reverse-chronological order. -``` -ctask last [flags] -``` +**Usage:** + + ctask list [flags] + +**Scenarios:** +- Surveying active work. +- Finding the basename to feed into another command. +- Driving shell scripting via `--names`. + +**Examples:** + + $ ctask list + active persistent project local projects 2026-04-24 promptvolley-v3 + active direct task local general 2026-05-14 fix-auth-bug + active — task local general 2026-05-12 json-cleanup + + $ ctask list --names + 2026-04-24_promptvolley-v3 + 2026-05-14_fix-auth-bug + 2026-05-12_json-cleanup **Flags:** +- `--all`, `-a` — include archived workspaces. +- `--task` — show task workspaces only. +- `--projects` — show project workspaces only. Mutually exclusive with + `--task`. +- `--category`, `-c` — filter by category. +- `--limit`, `-n` — maximum entries to show. Default: 20. +- `--names` — emit one workspace directory basename per line, no header. + Used by shell completion and scripting. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--shell` | | off | Open shell instead of agent | -| `--agent` | `-a` | from task.yaml | Override agent command | -| `--force` | | off | Skip active-session and stale-workspace warnings | +**Notes:** +- Output columns (default mode): status, session, type, mode, category, + date, slug. The `session` column shows `direct`, `persistent`, `stale`, + or em dash for no session. Archived workspaces always show em dash in + the session column (a display simplification — see `info` for the raw + state). +- `--names` output is intentionally minimal: bare basenames, no header, + empty stdout when nothing matches. Every emitted line is guaranteed + to resolve to exactly one workspace under the same archive policy. +- Workspaces created before v0.3 (no `type` field) are treated as tasks. + +**Related:** `ctask info`, `ctask path`, `ctask completion`. + +--- + +### `ctask notes` + +**Purpose:** Print a workspace's `notes.md` to stdout. + +**Usage:** + + ctask notes + +**Scenarios:** +- Quick read of working context without `cd`-ing into the workspace. +- Piping notes into pagers, search tools, or other agents. **Examples:** -```powershell -ctask last -ctask last --shell -``` + $ ctask notes auth-bug | less + $ ctask notes promptvolley-v3 | grep -i "decision" -If no active workspaces exist, prints an error and exits. +**Flags:** (none) + +**Notes:** +- Resolves archived workspaces too, mirroring `info`'s archive-inclusive + lookup. +- If `notes.md` is missing, prints `[ctask] no notes.md found in + workspace ""` to stderr and exits non-zero. + +**Related:** `ctask info`, `ctask path`. --- -## ctask doctor +### `ctask path` -Verify that ctask is correctly set up. Read-only -- never modifies anything. +**Purpose:** Print the absolute filesystem path of a workspace, with a +trailing newline. Designed for shell pipelines. -``` -ctask doctor -``` +**Usage:** -Checks: -1. Workspace root exists and is writable -2. Default agent command is found on PATH -3. Status-line helper script exists at the expected location -4. Claude Code `statusLine` is configured in `~/.claude/settings.json` -5. At least one workspace exists + ctask path -Exits 0 if all checks pass, 1 if any fail. Each failure includes a concrete fix instruction. +**Scenarios:** +- `cd "$(ctask path auth-bug)"` +- Feeding the path to other tools (rg, fd, editors). -`ctask doctor` also reports seed directory status: `[INFO]` if the `CTASK_SEED_DIR` / `CTASK_SEED_PROJECT_DIR` variable is unset (built-in defaults will be used), `[PASS]` if the variable is set and the path exists, `[FAIL]` if the variable is set but the path is missing. Only the configured-but-missing state counts as a failure. +**Examples:** -**Example output:** + $ ctask path auth-bug + /home/warren/ai-workspaces/general/2026-05-14_fix-auth-bug -``` - [PASS] Workspace root exists: C:\Users\Warren\ai-workspaces - [PASS] Default agent found: claude - [PASS] Status line helper found: C:\Users\Warren\AppData\Local\ctask\bin\ctask-statusline.sh - [PASS] Claude Code status line configured - [PASS] Workspaces found: 5 tasks (2 archived) - [INFO] General seed directory: not configured (using built-in defaults) - [INFO] Project seed directory: not configured (using built-in defaults) - [INFO] CTASK_PROJECT_ROOT: not set (projects discovered under C:\Users\Warren\ai-workspaces\projects) +**Flags:** (none) -5 checks passed, 0 failed -``` +**Notes:** +- Resolves archived workspaces too. +- Output uses native path separators (backslashes on Windows). + +**Related:** `ctask open`, `ctask info`. --- -## ctask delete +## Lifecycle -Permanently remove a workspace directory. +### `ctask archive` -``` -ctask delete [flags] -``` +**Purpose:** Mark a workspace as archived. The workspace stays on disk +but is hidden from default listings and most query resolution. + +**Usage:** + + ctask archive + +**Scenarios:** +- Putting a workspace away when the task is complete. +- Reducing default-listing noise. + +**Examples:** + + $ ctask archive auth-bug + [ctask] archived: general/2026-05-14_fix-auth-bug + +**Flags:** (none) + +**Notes:** +- Refuses to archive a workspace with an active session (fresh lease) + on non-TTY stdin. On a TTY, prompts for confirmation. +- After archiving, use `ctask list --all` to see archived workspaces and + `ctask restore` to bring one back. + +**Related:** `ctask restore`, `ctask list --all`, `ctask delete`. + +--- + +### `ctask restore` + +**Purpose:** Un-archive a workspace (set `status` back to `active`). + +**Usage:** + + ctask restore + +**Scenarios:** +- Resuming work on a previously archived task or project. + +**Examples:** + + $ ctask restore old-task + [ctask] restored: general/2026-04-22_old-task + [ctask] status: active + +**Flags:** (none) + +**Notes:** +- Errors if the target workspace is already active. +- Mirrors `archive`'s active-session lease guard: refuses on non-TTY, + prompts on TTY. +- Tab completion for `restore` surfaces archived workspaces only. + +**Related:** `ctask archive`, `ctask list --all`. + +--- + +### `ctask delete` + +**Purpose:** Permanently remove a workspace directory. + +**Usage:** + + ctask delete [flags] + +**Scenarios:** +- Reclaiming space from workspaces that are no longer needed. +- Removing accidentally created or trivial workspaces. + +**Examples:** + + $ ctask delete --force throwaway **Flags:** +- `--force`, `-f` — skip the confirmation prompt. +- `--all`, `-a` — include archived workspaces in query resolution. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--force` | `-f` | off | Skip confirmation prompt | -| `--all` | `-a` | off | Include archived workspaces in query resolution | +**Notes:** +- Confirmation is required by default. `--force` skips it but does NOT + override the active-session guard. +- Refuses to delete the workspace currently exported via + `CTASK_WORKSPACE` (catches same-session attempts) and refuses any + workspace with `.ctask/manifest-start.json` present (catches + cross-terminal attempts). Exit the session first. +- Prints a note before confirmation when deleting the most recently + updated workspace. + +**Related:** `ctask archive` (the safer alternative for completed work). + +--- + +## Environment + +### `ctask doctor` + +**Purpose:** Verify ctask is correctly set up. Read-only. + +**Usage:** + + ctask doctor + +**Scenarios:** +- First-run sanity check after install. +- Diagnosing a misconfigured agent, missing seed dir, or tmux problems. **Examples:** -```powershell -ctask delete old-task -ctask delete --force old-task -ctask delete --all --force archived-task -``` + $ ctask doctor + [PASS] Workspace root exists: /home/warren/ai-workspaces + [PASS] Default agent found: claude + [PASS] Status line helper found: /home/warren/.local/bin/ctask-statusline.sh + [PASS] Claude Code status line configured + [PASS] Workspaces found: 7 tasks (2 archived) + [INFO] General seed directory: not configured (using built-in defaults) + [INFO] Project seed directory: not configured (using built-in defaults) + [INFO] CTASK_PROJECT_ROOT: not set (projects discovered under /home/warren/ai-workspaces/projects) + [INFO] Session mode: persistent + [INFO] tmux found: tmux 3.4 (/usr/bin/tmux) -**Safety:** -- Confirmation is required by default. `--force` skips it. -- If the workspace has an active session (running in another terminal), deletion is refused even with `--force`. Exit the session first. -- If the workspace is the most recently updated one, a note is printed before confirmation. + 10 checks passed, 0 failed + +**Flags:** (none) + +**Notes:** +- Exits 0 on all-pass, 1 if any check fails. +- Each `[FAIL]` row includes a concrete fix line. +- Persistent-mode hosts also see two tmux rows; direct-mode hosts see a + single `[INFO] Session mode: direct (tmux not required)` row. + +**Related:** [`ctask completion`](#ctask-completion), [Session modes](#session-modes). --- -## Query Resolution +### `ctask completion` -Commands that take a `` argument (`resume`, `open`, `info`, `archive`, `delete`) resolve workspaces in this order: +**Purpose:** Emit shell-completion scripts for bash, zsh, fish, or +PowerShell. -1. Exact directory name match (e.g. `2026-04-06_auth-bug`) -2. Exact slug match (e.g. `auth-bug`) -3. Case-insensitive substring match (e.g. `auth`) +**Usage:** -If multiple workspaces match, all matches are printed and the command exits. If none match, an error is printed. + ctask completion {bash | zsh | fish | powershell} -Archived workspaces are excluded from matching by default. Use `--all` where supported to include them. +**Scenarios:** +- Setting up tab completion in your shell so workspace names complete + automatically. -By default, queries search `$CTASK_ROOT` (including its `projects/` subdirectory) plus `$CTASK_PROJECT_ROOT` when that variable is set. Projects created without `CTASK_PROJECT_ROOT` are therefore discoverable from any shell without additional configuration. Custom `CTASK_PROJECT_ROOT` paths must be set (recommended: at user scope) in any shell where you want those projects findable. +**Examples:** + + # bash, current shell only + $ source <(ctask completion bash) + + # zsh, persistent + $ ctask completion zsh > "${fpath[1]}/_ctask" + + # PowerShell, persistent + PS> ctask completion powershell | Out-String | Invoke-Expression + +**Flags:** (none — the shell is a positional argument.) + +**Notes:** +- See [Shell completion](#shell-completion) for installation patterns + per shell. +- Workspace name completion is provided via Cobra `ValidArgsFunction` + hooks: `resume`/`open`/`attach` complete on active workspaces, + `restore` completes on archived workspaces, `info`/`notes`/`path` + complete on any workspace. + +**Related:** [`ctask list --names`](#ctask-list). --- -## Environment Variables +## Environment variables + +ctask reads these to configure behavior: + +| Variable | Default | Description | +|----------|---------|-------------| +| `CTASK_ROOT` | `~/ai-workspaces` (Unix) / `%USERPROFILE%\ai-workspaces` (Windows) | Workspace root directory. | +| `CTASK_PROJECT_ROOT` | (none) | Workspace root for project workspaces. When set, projects go directly under this path with no `projects/` segment unless `-c` is also passed. | +| `CTASK_AGENT` | `claude` | Default agent command. | +| `CTASK_SEED_DIR` | `~/.config/ctask/seed/` (Unix) / `%APPDATA%\ctask\seed\` (Windows) | General user seed directory copied into every new workspace. | +| `CTASK_SEED_PROJECT_DIR` | `~/.config/ctask/seed-project/` (Unix) / `%APPDATA%\ctask\seed-project\` (Windows) | Project seed directory copied only for `--project` workspaces (overlay on top of the general seed). | +| `CTASK_SESSION_MODE` | `direct` | `direct` or `persistent`. See [Session modes](#session-modes). | ctask exports these into every child session: | Variable | Description | |----------|-------------| -| `CTASK_TASK` | Task slug | -| `CTASK_MODE` | Execution mode (`local`) | -| `CTASK_ROOT` | Resolved workspace root path | -| `CTASK_WORKSPACE` | Full workspace path | -| `CTASK_CATEGORY` | Category name | -| `CTASK_TYPE` | `task` or `project` | -| `CTASK_LAUNCH_DIR` | Project subdirectory (v0.5); empty for tasks and pre-v0.5 projects | - -Configure ctask behavior with: - -| Variable | Default | Description | -|----------|---------|-------------| -| `CTASK_ROOT` | `%USERPROFILE%\ai-workspaces` (Windows) / `~/ai-workspaces` (Unix) | Workspace root directory | -| `CTASK_AGENT` | `claude` | Default agent command | -| `CTASK_PROJECT_ROOT` | (none) | Workspace root for projects. When set, project workspaces are created directly under this path (no doubled `projects/` segment unless `-c` is passed). | -| `CTASK_SEED_DIR` | `%APPDATA%\ctask\seed\` (Windows) / `~/.config/ctask/seed/` (Unix) | General user seed directory copied into every new workspace. | -| `CTASK_SEED_PROJECT_DIR` | `%APPDATA%\ctask\seed-project\` (Windows) / `~/.config/ctask/seed-project/` (Unix) | Project seed directory copied only for `--project` workspaces (overlay on top of the general seed). | +| `CTASK_TASK` | Workspace slug. | +| `CTASK_MODE` | Execution mode (currently always `local`). | +| `CTASK_ROOT` | Resolved workspace root path. | +| `CTASK_WORKSPACE` | Full workspace path. | +| `CTASK_CATEGORY` | Category name. | +| `CTASK_TYPE` | `task` or `project`. | +| `CTASK_LAUNCH_DIR` | Project subdirectory; empty for tasks and pre-v0.5 projects. | --- -## Concurrency and safety +## Query resolution -ctask v0.4 protects workspaces from conflicts when multiple sessions (or manual file edits) touch the same workspace. +Commands that take a `` argument resolve workspaces in this order: -### Session lease +1. Exact directory basename match (e.g. `2026-05-14_fix-auth-bug`). +2. Exact slug match (e.g. `fix-auth-bug`). +3. Case-insensitive substring match against the slug (e.g. `auth`). -Each active `ctask resume`, `open`, `last`, or `new` writes a lease file at `/.ctask/session.json` identifying the ctask process, hostname, user, agent, mode, and a heartbeat timestamp. A background goroutine updates the heartbeat every 30 seconds. +If multiple workspaces match, ctask prints all matches and exits with +an error. If none match, ctask prints `No workspace matches "".` +and exits with an error. -On session start, if a fresh lease already exists (heartbeat within 60 seconds), ctask warns: +**Archive-inclusive lookup:** Some commands resolve archived workspaces +by default — `info`, `notes`, `path`, `restore`, and `resume`. Of those, +`resume` refuses to launch the archived workspace and prints a +`restore` hint. The other archive-inclusive commands operate on the +archived workspace as you would expect. -``` -[ctask] This workspace has an active session: - Session: - Host: - Agent: - Started: ( ago) - Last seen: ago +`open`, `delete`, and a few others gate archive inclusion behind +`--all`. `archive` itself is active-only by definition. - Opening a second session may cause conflicts. - Continue anyway? [y/N] -``` - -If the user answers `y`, the second session proceeds **without writing its own lease** (see "Known limitation: coexisting sessions" below). If the lease is older than 60 seconds (crash, lost connection), ctask cleans it up silently and proceeds. - -### Metadata write lock - -All ctask-owned file writes (`task.yaml`, `logs/sessions.log`, `.ctask/session.json`, `.ctask/manifest-start.json`, `.ctask/last-session-summary.json`) are serialized through `/.ctask/write.lock`. The lock is held for the duration of one write only. If the lock cannot be acquired within 2 seconds, the write is skipped with a warning rather than blocking. - -### Stale-workspace detection - -On session start, ctask compares the current workspace state against the end-state recorded by the previous session's summary. If anything changed outside a ctask session (another machine, manual edits), ctask warns: - -``` -[ctask] Workspace modified since last session ended: - - Last session: (, ) - - Modified since then: - notes.md (modified) - output/report.md (new file) - - These changes were not made during a ctask session. - Review before continuing? [Y/n] -``` - -Press Enter (or `y`) to proceed. Press `n` to exit without launching. - -This check is skipped silently for workspaces that have never completed a v0.4 session (no `last-session-summary.json`). - -### Session handoff summary - -At end of session, ctask writes `/.ctask/last-session-summary.json` containing: - -- `session_id`, `hostname`, `agent`, `mode` -- `started_at`, `ended_at`, `duration_seconds` -- `files_added`, `files_modified`, `files_deleted` -- `notes_updated` -- `end_manifest` (snapshot of workspace file list at session end -- used by the stale-workspace detector) - -The next session prints a short orientation banner from this file: - -``` -[ctask] local :: api-cleanup -[ctask] ~/ai-workspaces/general/2026-04-21_api-cleanup -[ctask] Last session: 2026-04-21 14:30-15:45 (warren-desktop, claude) -[ctask] Changed: notes.md, output/plan.md -``` - -### `--force` - -`--force` on `resume`, `open`, and `last` suppresses both the active-session warning and the stale-workspace warning. It does **not** disable the metadata write lock or the session summary -- those are always active. - -Use `--force` only for automation where the human has already decided to proceed. - -### Known limitation: coexisting sessions - -When the user confirms "Continue anyway?" on an active-session warning (or passes `--force`), the second session runs **without writing its own lease**. This keeps the lease model simple (one lease file per workspace), but has two consequences: - -1. A third session attempt will only see the original lease. The second (coexisting) session is invisible to lease-based detection. -2. If the original session exits and removes its lease before the coexisting session finishes, the coexisting session is unprotected for its remaining lifetime. - -The metadata write lock still serializes all ctask-owned file writes regardless of session count, so no state corruption can occur. If you need stronger guarantees, exit the existing session before starting another one. +**Search roots:** Queries always search `$CTASK_ROOT` plus +`$CTASK_PROJECT_ROOT` when set. When `CTASK_PROJECT_ROOT` is unset, +`$CTASK_ROOT/projects/` is searched as well, so projects created with +the default category are discoverable from any shell without +additional configuration. --- -## Exit Codes +## Session modes + +ctask supports two session modes, selected by `CTASK_SESSION_MODE`: + +- **`direct`** (default) — the agent or shell is the foreground process + of the ctask invocation. When the terminal closes, the agent exits. + This is the v0.4 behavior. +- **`persistent`** — workspace entry runs inside a deterministic + per-workspace tmux session named `ctask---`. + Multiple terminals (local + SSH) can attach to the same session, and + the agent survives terminal disconnection. + +Persistent mode requires: + +- tmux 3.0+ on PATH. +- An interactive terminal (over SSH, use `ssh -t`). +- Not running inside an existing tmux session (the `$TMUX` env var + must be unset). +- A Unix-like host or WSL — native Windows is not supported. + +When you run a persistent-mode entry command (`new`, `resume`, `last`, +`open`, or `attach`), ctask picks one of three paths: + +1. **Owner-create** — no tmux session exists for this workspace. ctask + launches the agent inside a new tmux session and starts a lease and + heartbeat. Equivalent to direct-mode entry plus a tmux wrapper. +2. **Passive reattach** — a tmux session exists and a fresh local lease + is heartbeating. ctask attaches the user's terminal to the existing + session and exits when the user detaches. No lease writes, no + manifest, no finalize — the original ctask owner is still managing + the workspace. +3. **Adopted reattach** — a tmux session exists but the lease is + missing, stale, or from another host (the original owner died). + ctask transfers ownership to itself, captures a fresh start + manifest, starts heartbeating, attaches the terminal, and runs + finalize when the session ends. + +`new`, `resume`, `last`, and `open` accept `--direct` to bypass +persistent mode for one invocation. When a persistent tmux session +exists for the workspace, ctask prompts the user to confirm. `--direct` +is a no-op under direct mode (allows scripts to use it defensively). + +`ctask attach` always uses persistent mode regardless of +`CTASK_SESSION_MODE`. + +`ctask doctor` validates session-mode prerequisites and reports tmux +availability. + +--- + +## Shell completion + +`ctask completion ` writes a Cobra-generated completion script +to stdout. Install it once per shell: + +**bash** (system-wide): + + ctask completion bash > /etc/bash_completion.d/ctask + +**bash** (per-user, current session): + + source <(ctask completion bash) + +**zsh** (assuming `compinit` and an `fpath` directory): + + ctask completion zsh > "${fpath[1]}/_ctask" + +**fish:** + + ctask completion fish > ~/.config/fish/completions/ctask.fish + +**PowerShell** (current session): + + ctask completion powershell | Out-String | Invoke-Expression + +**PowerShell** (persistent — append to your profile): + + ctask completion powershell >> $PROFILE + +Once installed, tab completion works on workspace-name arguments. The +completion source is `ctask list --names`, filtered per command: +`resume`/`open`/`attach` show active workspaces only, `restore` shows +archived workspaces only, and `info`/`notes`/`path` show all. + +--- + +## Exit codes | Code | Meaning | |------|---------| -| 0 | Success | -| 1 | General error (multiple matches, not found, invalid args, doctor failure) | -| 2 | Missing required argument | -| 127 | Agent command not found | - ---- - -## Persistent Session Mode (tmux) - -ctask v0.5.3+ supports an opt-in persistent session mode where workspace entry -runs inside a deterministic per-workspace tmux session. Multiple terminals -(local + SSH) attach to the same session, the agent and shell state survive -terminal disconnection, and the v0.4 lifecycle protections continue to apply -with one ctask process owning the workspace lifecycle while terminal -connections come and go. - -### Enabling persistent mode - -```bash -export CTASK_SESSION_MODE=persistent # in ~/.bashrc or equivalent -``` - -When unset or set to `direct`, ctask behaves as in v0.5.2 (no behavior change). - -Persistent mode requires: -- tmux 3.0+ on PATH (install via `apt install tmux`, `brew install tmux`, `pacman -S tmux`, or `dnf install tmux`) -- An interactive terminal (over SSH, use `ssh -t`) -- Not running inside an existing tmux session -- A Unix-like host or WSL — native Windows is not supported (use WSL) - -### Three entry paths - -When you run a persistent-mode entry command (`new`, `resume`, `last`, `open`, or `attach`), ctask picks one of three paths: - -1. **Owner-create** — no tmux session exists for this workspace yet. The command behaves like the direct path but launches the agent inside a new tmux session named `ctask---`. -2. **Passive reattach** — a tmux session exists and a fresh local lease is heartbeating. The command attaches the user's terminal to the existing session and exits when the user detaches. No lease writes, no manifest, no finalize — the original ctask owner is still managing the workspace. -3. **Adopted reattach** — a tmux session exists but the lease is missing, stale, or from another host (the original owner died). The command transfers ownership to itself, captures a fresh start manifest, starts heartbeating, attaches the terminal, and runs finalize when the session ends. - -### `ctask attach ` - -`ctask attach` always uses tmux regardless of `CTASK_SESSION_MODE`. Useful when you have not enabled persistent mode globally but want tmux for one workspace, or when shell scripts need unambiguous behavior. - -```bash -ctask attach promptvolley-v3 -``` - -The same three paths apply. - -### `--direct` bypass flag - -`new`, `resume`, `last`, and `open` accept `--direct` to bypass persistent mode for one invocation. When a persistent tmux session exists for the workspace, ctask prompts: - -``` -A persistent tmux session exists for this workspace: - ctask-projects-promptvolley-v3-a8f3c2 - -Opening a direct-mode shell may create conflicting workspace activity. -The recommended path is: - ctask attach promptvolley-v3 - -Continue with --direct anyway? [y/N] -``` - -`--direct` is a no-op under direct mode (allows scripts to use it defensively). - -### Doctor - -`ctask doctor` reports: - -``` -[INFO] Session mode: persistent -[INFO] tmux found: tmux 3.4 (/usr/bin/tmux) -``` - -or, on misconfiguration: - -``` -[INFO] Session mode: persistent -[FAIL] tmux not found on PATH - Fix: install tmux 3.0+ (apt/brew/pacman/dnf), or unset CTASK_SESSION_MODE -``` - -### Workflow examples - -**Local development** - -```bash -export CTASK_SESSION_MODE=persistent - -ctask new --project promptvolley-v3 -# -> workspace created, tmux session ctask-projects-promptvolley-v3-a8f3c2 started, attached. - -# Detach with Ctrl-B d. Terminal returns; tmux session keeps running. - -ctask resume promptvolley-v3 -# -> passive reattach. Same Claude Code session, scrollback intact. -``` - -**Remote access via SSH** - -```bash -ssh -t warren-desktop # -t is required -ctask resume promptvolley-v3 # -> passive reattach (concurrent with desktop) -``` - -### Native Windows note - -Persistent mode is not supported on native Windows (PowerShell). Run ctask under WSL and install tmux there. +| 0 | Success. | +| 1 | General error (multiple matches, not found, invalid args, doctor failure, refusal). | +| 2 | Missing required argument (Cobra default). | +| 127 | Agent command not found. | diff --git a/internal/session/lease.go b/internal/session/lease.go index 4da4e83..b12efa2 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -81,6 +81,12 @@ func currentHostname() string { return h } +// CurrentHostname is the exported form of currentHostname for callers in +// cmd/ that need to compare a lease's recorded hostname against the local +// machine (e.g., info's Owner-line "omit when local" rule). Keeps a +// single source of truth for the unknown-fallback semantics. +func CurrentHostname() string { return currentHostname() } + // currentTerminal is a best-effort terminal identifier based on common env vars. // Returns "unknown" if none are set. func currentTerminal() string { diff --git a/internal/session/status.go b/internal/session/status.go new file mode 100644 index 0000000..689bdc3 --- /dev/null +++ b/internal/session/status.go @@ -0,0 +1,94 @@ +package session + +import ( + "encoding/json" + "errors" + "os" + "time" +) + +// SessionState is the display-only classification surfaced by SessionStatus. +type SessionState string + +const ( + // SessionStateNone: no lease file is present. + SessionStateNone SessionState = "none" + // SessionStateActive: lease file is present and fresh (heartbeat within StaleLeaseAfter). + SessionStateActive SessionState = "active" + // SessionStateStale: lease file is present but stale, or present-and-malformed. + SessionStateStale SessionState = "stale" +) + +// Status is the derived display-only view of a workspace's session lease. +// +// It is intentionally narrower than InspectLease/LeaseState: callers that +// need behavioral decisions (adoption, dispatch) must keep using the +// existing primitives. Status exists for `ctask info` and `ctask list` to +// surface session visibility without touching tmux, PID liveness, or any +// lock state. +type Status struct { + State SessionState + Mode string // "direct" | "persistent" | "" when malformed + PID int + Hostname string + Diagnostic string // human-readable note for the malformed case; empty otherwise +} + +// SessionStatus returns the display-only session summary for wsDir. +// +// It performs only one file read (.ctask/session.json) and never invokes +// tmux, checks PID liveness, modifies lease state, acquires locks, or +// otherwise mutates the workspace. PID liveness is intentionally deferred +// to v0.6's lazy-cleanup redesign, where it will have behavioral +// consequences; building it display-only here would mean building it +// twice. +// +// Display-only contract: do NOT call SessionStatus from lifecycle or +// adoption code. The "missing Mode defaults to direct" rule and the +// malformed-lease "stale" classification are display choices, not +// behavioral truths. Use ReadLease / IsFresh / InspectLease for +// lifecycle decisions. +func SessionStatus(wsDir string) Status { + return statusAt(wsDir, time.Now()) +} + +// statusAt is the test entry point with an injected clock. Production +// code goes through SessionStatus. +func statusAt(wsDir string, now time.Time) Status { + data, err := os.ReadFile(LeasePath(wsDir)) + if errors.Is(err, os.ErrNotExist) { + return Status{State: SessionStateNone} + } + if err != nil { + return Status{ + State: SessionStateStale, + Diagnostic: "lease exists but could not be read", + } + } + var l Lease + if jsonErr := json.Unmarshal(data, &l); jsonErr != nil { + return Status{ + State: SessionStateStale, + Diagnostic: "lease exists but could not be read", + } + } + + state := SessionStateStale + if IsFresh(&l, now, StaleLeaseAfter) { + state = SessionStateActive + } + + // Pre-v0.5.3 leases predate the mode field; treat them as direct so + // `info` and `list` render a meaningful value rather than blank. + mode := l.Mode + if mode == "" { + mode = "direct" + } + + return Status{ + State: state, + Mode: mode, + PID: l.PID, + Hostname: l.Hostname, + } +} diff --git a/internal/session/status_test.go b/internal/session/status_test.go new file mode 100644 index 0000000..d4d9270 --- /dev/null +++ b/internal/session/status_test.go @@ -0,0 +1,204 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// writeLeaseAt is provided by lease_inspect_test.go in this package. + +func TestSessionStatusNone(t *testing.T) { + ws := t.TempDir() // no .ctask/session.json + + got := statusAt(ws, time.Now()) + + if got.State != SessionStateNone { + t.Errorf("State: got %q, want %q", got.State, SessionStateNone) + } + if got.Mode != "" { + t.Errorf("Mode: got %q, want empty", got.Mode) + } + if got.PID != 0 { + t.Errorf("PID: got %d, want 0", got.PID) + } + if got.Hostname != "" { + t.Errorf("Hostname: got %q, want empty", got.Hostname) + } + if got.Diagnostic != "" { + t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic) + } +} + +func TestSessionStatusFreshLocal(t *testing.T) { + ws := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAt(t, ws, &Lease{ + SessionID: "fakehost-1234-20260514120000", + PID: 1234, + Hostname: "fakehost", + Username: "tester", + Agent: "claude", + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + Terminal: "test", + }) + + got := statusAt(ws, now.Add(10*time.Second)) // well within StaleLeaseAfter + + if got.State != SessionStateActive { + t.Errorf("State: got %q, want %q", got.State, SessionStateActive) + } + if got.Mode != "persistent" { + t.Errorf("Mode: got %q, want persistent", got.Mode) + } + if got.PID != 1234 { + t.Errorf("PID: got %d, want 1234", got.PID) + } + if got.Hostname != "fakehost" { + t.Errorf("Hostname: got %q, want fakehost", got.Hostname) + } + if got.Diagnostic != "" { + t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic) + } +} + +func TestSessionStatusFreshRemote(t *testing.T) { + // SessionStatus is display-only; "remote" is just whatever hostname is + // recorded in the lease. The helper does not compare to currentHostname() + // — that comparison happens in the cmd display layer (info). + ws := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAt(t, ws, &Lease{ + PID: 4242, + Hostname: "some-other-host", + Mode: "direct", + StartedAt: now, + LastHeartbeatAt: now, + }) + + got := statusAt(ws, now.Add(10*time.Second)) + + if got.State != SessionStateActive { + t.Errorf("State: got %q, want %q", got.State, SessionStateActive) + } + if got.Hostname != "some-other-host" { + t.Errorf("Hostname: got %q, want some-other-host", got.Hostname) + } + if got.Mode != "direct" { + t.Errorf("Mode: got %q, want direct", got.Mode) + } + if got.PID != 4242 { + t.Errorf("PID: got %d, want 4242", got.PID) + } +} + +func TestSessionStatusStale(t *testing.T) { + ws := t.TempDir() + heartbeat := time.Now().UTC().Add(-5 * time.Minute) + writeLeaseAt(t, ws, &Lease{ + PID: 999, + Hostname: "oldhost", + Mode: "persistent", + StartedAt: heartbeat, + LastHeartbeatAt: heartbeat, + }) + + got := statusAt(ws, time.Now()) + + if got.State != SessionStateStale { + t.Errorf("State: got %q, want %q", got.State, SessionStateStale) + } + if got.Mode != "persistent" { + t.Errorf("Mode: got %q, want persistent", got.Mode) + } + if got.PID != 999 { + t.Errorf("PID: got %d, want 999", got.PID) + } + if got.Hostname != "oldhost" { + t.Errorf("Hostname: got %q, want oldhost", got.Hostname) + } + if got.Diagnostic != "" { + t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic) + } +} + +func TestSessionStatusMalformed(t *testing.T) { + ws := t.TempDir() + if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(LeasePath(ws), []byte("{not valid json"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := statusAt(ws, time.Now()) + + if got.State != SessionStateStale { + t.Errorf("State: got %q, want %q", got.State, SessionStateStale) + } + if got.Mode != "" { + t.Errorf("Mode: got %q, want empty (malformed lease)", got.Mode) + } + if got.Diagnostic == "" { + t.Errorf("Diagnostic: want non-empty for malformed lease, got empty") + } + // PID/Hostname should be zero values when the lease can't be parsed — + // no partial info from a corrupt source. + if got.PID != 0 { + t.Errorf("PID: got %d, want 0 (malformed lease)", got.PID) + } + if got.Hostname != "" { + t.Errorf("Hostname: got %q, want empty (malformed lease)", got.Hostname) + } +} + +func TestSessionStatusMissingMode(t *testing.T) { + // Leases written by pre-v0.5.3 ctask have no `mode` field. SessionStatus + // must default to "direct" so the display layer does not show a blank + // session mode for older workspaces. + ws := t.TempDir() + if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + // Write a lease JSON that omits the `mode` field entirely. + leaseJSON := []byte(`{ + "session_id": "fakehost-1234-20260514120000", + "pid": 1234, + "hostname": "fakehost", + "username": "tester", + "agent": "claude", + "started_at": "` + now.Format(time.RFC3339) + `", + "last_heartbeat_at": "` + now.Format(time.RFC3339) + `", + "terminal": "test" +}`) + if err := os.WriteFile(LeasePath(ws), leaseJSON, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := statusAt(ws, now.Add(10*time.Second)) + + if got.State != SessionStateActive { + t.Errorf("State: got %q, want %q", got.State, SessionStateActive) + } + if got.Mode != "direct" { + t.Errorf("Mode: got %q, want direct (default for missing-mode lease)", got.Mode) + } + if got.PID != 1234 { + t.Errorf("PID: got %d, want 1234", got.PID) + } +} + +func TestSessionStatusUsesProductionEntrypoint(t *testing.T) { + // Sanity check that the production entry point (which uses time.Now) + // returns SessionStateNone when the lease is missing — guards against + // a refactor that loses the SessionStatus -> statusAt forwarding. + ws := t.TempDir() + got := SessionStatus(ws) + if got.State != SessionStateNone { + t.Errorf("SessionStatus on empty workspace: got %q, want %q", got.State, SessionStateNone) + } +}