Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ CI runs `go vet ./...` then `go test -coverprofile=coverage.txt ./...` on every
- `picker/styles.go` and `preview/styles.go` both use ANSI 16-color palette (`lipgloss.Color("N")`) — do not introduce hex/RGB colors
- `preview.listDir()` calls `eza`/`ls --color=always` and forwards raw output; do not pass it through lipgloss
- `launcher` uses `syscall.Exec` (replaces the process), not `exec.Command` (subprocess)
- Claude sessions distinguish `CWD` (latest transcript cwd for display/filter/preview) from `LaunchCWD` (first cwd used for `claude --resume`); never substitute one for the other
- Title extraction: `applyTitleRules` strips skip-prefixes, takes the first line, handles the `"Implement the following plan:"` special case; `customTitle` records must also pass through `applyTitleRules` to strip embedded newlines
- CJK truncation: always use `display.TruncateWidth(s, maxCols, tail)` before passing to lipgloss — `Width(N)+MaxWidth(N)` has a known upstream bug where CJK characters at the truncation boundary produce N−1 columns

Expand Down
25 changes: 24 additions & 1 deletion docs/agent/notes-claude-code-references.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,33 @@ Claude Code stores session data under `~/.claude/projects/<project>/` where `<pr
`sanitizePath`: non-alphanumeric characters become `-`, with a hash suffix when the sanitized path
would exceed the filesystem limit (e.g. `-Users-sd-projects-dotfiles`).

`sanitizePath` is a lossy filename mapping, not URL encoding:

```text
/a/b -> -a-b
/a/b-1 -> -a-b-1
```

Original hyphens are not escaped, so the directory name cannot be decoded back to a unique path.
Claude finds a transcript by applying the same mapping to the cwd used for resume.

Claude session paths have two separate cwd meanings:

- First non-empty transcript `cwd`: resume launch cwd / storage namespace key. Use this as
`LaunchCWD` for `cd <LaunchCWD> && claude --resume <session-id>`.
- Last non-empty transcript `cwd`: latest/display cwd. Use this as `CWD` for display, filtering,
and preview context.

Local scan on 2026-06-26 found 260 top-level JSONL files with a first `cwd`; all 260 satisfied
`sanitizePath(first cwd) == dirname(jsonl parent)`. One top-level JSONL had no `cwd`.

Main transcript files are `<session-uuid>.jsonl`. No `<session-uuid>.todos` files were observed in
the 2026-06-01 local scan; current task/todo state is stored in transcript tool calls and
`~/.claude/tasks/<session-id>/*.json`.

The `source` package discovers sessions via glob on `~/.claude/projects/*/*.jsonl`. When the JSONL
lacks a `cwd` field, the encoded directory name is decoded with `url.PathUnescape` as fallback.
lacks a `cwd` field, the sanitized directory name is not reliably reversible; skip or handle the
session conservatively instead of treating the project directory name as URL-encoded cwd.

### Current sidecar formats

Expand Down Expand Up @@ -213,6 +234,8 @@ project directory; subagent transcripts are loaded separately through `getAgentT
aps compatibility notes:
- Main discovery via `~/.claude/projects/*/*.jsonl` still matches top-level resumable transcripts
and excludes nested subagent JSONL files, matching Claude Code's own main-session scan shape.
- Preserve the `CWD` vs `LaunchCWD` distinction: `CWD` follows the latest transcript cwd for
display/filtering, while `LaunchCWD` follows the first cwd/storage namespace for resume.
- Treat `*.jsonl.wakatime` and `subagents/*.jsonl` as sidecars for the main picker.
- Prefer `agent-name` over `custom-title` when matching Claude Code display priority.
- Count `user.message.content` arrays containing `{"type":"text"}` as real user turns; arrays
Expand Down
107 changes: 34 additions & 73 deletions docs/agent/plan-issue-62-claude-launch-dir.md
Original file line number Diff line number Diff line change
@@ -1,88 +1,49 @@
## Goal

Fix Claude resume launch directory selection so sessions stored under the original project namespace
can still resume after later transcript records move into a Claude worktree.

## Problem

Claude Code resolves `claude --resume <session-id>` against the launch directory's storage
namespace. `aps` currently parses the last non-empty transcript `cwd` and uses it both as the
display directory and the launch directory. For worktree sessions, the transcript can live at:

```text
~/.claude/projects/-Users-sd-projects-aps/<session-id>.jsonl
```
# Plan: issue #62 — Claude resume launch directory for worktree sessions

while later records carry:
## Goal

```text
/Users/sd/projects/aps/.claude/worktrees/fix+18-metacache-reload
```
Resume Claude sessions correctly when the transcript's tail `cwd` points to a
`.claude/worktrees/` path, while preserving existing behavior for non-worktree
sessions.

Launching from the worktree makes Claude search a non-existent worktree namespace and fail with
`No conversation found with session ID`.
## Root cause

## Target Files
`parseJSONL` uses last-wins semantics for `cwd`. When a session starts in the
project root and then work moves into a worktree, the last `cwd` is the worktree
path. `launcher.Claude` does `os.Chdir(cwd)` before `claude --resume <id>`.
Claude Code resolves transcript storage from the launch directory namespace, so
it searches a namespace that contains no transcript.

- `source/session.go` — add a Claude launch directory field or equivalent typed distinction.
- `source/claude.go` — parse and populate display/current cwd separately from launch cwd.
- `launcher/launch.go` or `main.go` — pass Claude launch cwd to `launcher.Claude`.
- `preview/claude.go` — keep preview data path and displayed directory semantics clear.
- `source/claude_test.go` — cover launch cwd extraction and worktree-state cases.
- `launcher/launch_test.go` or main-level tests if available — cover command directory selection.
## Solution

## Intended Change
Separate two concepts in `source.Session`:

1. Represent two concepts explicitly:
- `CWD`: latest/display working directory used for UI context and path filtering.
- `LaunchCWD`: directory used to invoke Claude resume.
2. Populate `LaunchCWD` for Claude sessions from the transcript storage namespace or earliest
project cwd, not from the tail worktree cwd.
3. Keep `CWD` as the last non-empty transcript cwd so the picker still shows where the session last
operated.
4. Make `main.go` call `launcher.Claude(session.ID, session.LaunchCWD, launchOpts)` for Claude and
preserve existing launch behavior for Opencode and Codex.
5. Keep old cache compatibility in mind. If `MetaCache` stores cwd-only metadata, either add a
schema-safe cached launch cwd field or force a cache miss/version bump for Claude metadata.
- `CWD` — last non-empty `cwd` from the transcript (display/filter, unchanged)
- `LaunchCWD` — first non-empty `cwd` from the transcript; this is the cwd used
by `cd <LaunchCWD> && claude --resume <session-id>`, not the
`~/.claude/projects/<sanitized-cwd>` storage directory itself

## Non-Goals
For sessions that never changed directory, `LaunchCWD == CWD`.

- Do not change Claude title extraction.
- Do not change path filtering semantics unless tests prove current behavior conflicts with the
launch fix.
- Do not alter Opencode or Codex launch behavior.
## Files changed

## Tests
| File | Change |
|------|--------|
| `source/session.go` | Add `LaunchCWD string` field |
| `source/metacache.go` | Add `LaunchCWD string` to `MetaEntry`; make `Lookup` reject incomplete entries so old cache records reparse |
| `source/claude.go` | `parseJSONL` returns `jsonlMeta` with separate `CWD` and `LaunchCWD`; `parseOne` and `ReloadSession` populate `Session.LaunchCWD`; cache misses and incomplete older entries refresh cache |
| `source/claude_test.go` | New tests: `TestParseJSONL_LaunchCWDFirstCWD`, `TestParseJSONL_LaunchCWDEqualsCWDForSingleProject`, `TestLoadClaude_SessionLaunchCWDFromFirstCWD`, `TestLoadClaude_OldCacheEntryWithoutLaunchCWDReparses` |
| `main.go` | Use `session.LaunchCWD` (fallback `session.CWD`) as the `dir` arg to launcher; report missing launch directories without silently falling back to the last cwd |

Write failing tests before implementation:
## Non-goals

1. `TestParseJSONL_LaunchCWDUsesOriginalProjectWhenWorktreeTailCWD`:
- transcript begins in `/Users/sd/projects/aps`
- later records and `worktree-state` point at `/Users/sd/projects/aps/.claude/worktrees/...`
- parsed session has `CWD` equal to the worktree path and `LaunchCWD` equal to the original path
2. `TestParseJSONL_LaunchCWDFallsBackToCWDForNormalSession`:
- non-worktree transcript keeps `LaunchCWD == CWD`
3. cache round-trip test:
- cached Claude metadata preserves `LaunchCWD` or invalidates old cache safely
4. launcher/main selection test:
- Claude uses `LaunchCWD`
- Opencode/Codex still use their existing `CWD`
- Changing path-filter logic (still uses `CWD`)
- Changing `CWDDisplay` (still uses `CWD`)
- Handling Opencode or Codex worktree patterns (not observed)
- Treating Claude's sanitized project directory name as a reversible path encoding
- Silently falling back from a missing Claude launch directory to the last cwd; that can resume from the wrong namespace

## Verification

```bash
go test ./source ./launcher ./cmd ./picker
go test ./...
go build .
go install .
```

Manual check with a known affected session:

```bash
aps -c
# select session a60e87e2-eb65-4b92-bd29-204dc165d47c
# expected: Claude resumes instead of reporting "No conversation found with session ID"
```

Use `aps -c -n -v` before the real launch if command output needs inspection.
`go test ./...` passes. The new tests exercise first-cwd parsing, session population,
old cache migration, and launch-directory diagnostics.
30 changes: 23 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ func runInteractiveStreaming(cfg cmd.Config, from, until *time.Time) {
os.Exit(0)
}

if !dirExists(session.CWD) {
fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", session.CWD)
launchCWD := sessionLaunchCWD(session)
if !dirExists(launchCWD) {
fmt.Fprint(os.Stderr, missingLaunchCWDMessage(session, launchCWD))
os.Exit(1)
}

Expand All @@ -194,11 +195,11 @@ func runInteractiveStreaming(cfg cmd.Config, from, until *time.Time) {

switch session.Client {
case source.ClientClaude:
mustLaunch(launcher.Claude(session.ID, session.CWD, launchOpts))
mustLaunch(launcher.Claude(session.ID, launchCWD, launchOpts))
case source.ClientCodex:
mustLaunch(launcher.Codex(session.ID, session.CWD, launchOpts))
mustLaunch(launcher.Codex(session.ID, launchCWD, launchOpts))
default:
mustLaunch(launcher.Opencode(session.ID, session.CWD, launchOpts))
mustLaunch(launcher.Opencode(session.ID, launchCWD, launchOpts))
}
}

Expand Down Expand Up @@ -337,7 +338,8 @@ func runList(sessions []source.Session, cfg cmd.Config) {
os.Setenv("COLORTERM", "truecolor")
case "never":
os.Setenv("NO_COLOR", "1")
// "auto": lipgloss detects TTY automatically; nothing to do
case "auto":
// lipgloss detects TTY automatically.
}

combined := cfg.MultiAgent()
Expand All @@ -352,14 +354,28 @@ func runList(sessions []source.Session, cfg cmd.Config) {
}
}


func mustLaunch(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "launch error: %v\n", err)
os.Exit(1)
}
}

func sessionLaunchCWD(session *source.Session) string {
if session.LaunchCWD != "" {
return session.LaunchCWD
}
return session.CWD
}

func missingLaunchCWDMessage(session *source.Session, launchCWD string) string {
msg := fmt.Sprintf("Error: launch directory not found: %s\n", launchCWD)
if session.CWD != "" && launchCWD != session.CWD && dirExists(session.CWD) {
msg += fmt.Sprintf("Last session directory exists but is not used as the resume launch directory: %s\n", session.CWD)
}
return msg
}

func dirExists(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
Expand Down
39 changes: 38 additions & 1 deletion main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package main

import "testing"
import (
"strings"
"testing"

"github.com/gadflysu/aps/source"
)

func TestJoinNames_Empty(t *testing.T) {
if got := joinNames(nil); got != "" {
Expand All @@ -25,3 +30,35 @@ func TestJoinNames_Three(t *testing.T) {
t.Errorf("joinNames = %q, want \"Claude, Opencode and Codex\"", got)
}
}

func TestSessionLaunchCWDPrefersLaunchCWD(t *testing.T) {
session := &source.Session{CWD: "/cwd", LaunchCWD: "/launch"}
if got := sessionLaunchCWD(session); got != "/launch" {
t.Errorf("sessionLaunchCWD = %q, want /launch", got)
}
}

func TestSessionLaunchCWDFallsBackToCWD(t *testing.T) {
session := &source.Session{CWD: "/cwd"}
if got := sessionLaunchCWD(session); got != "/cwd" {
t.Errorf("sessionLaunchCWD = %q, want /cwd", got)
}
}

func TestMissingLaunchCWDMessageMentionsExistingLastCWD(t *testing.T) {
lastCWD := t.TempDir()
session := &source.Session{
CWD: lastCWD,
LaunchCWD: "/missing/launch-dir",
}
msg := missingLaunchCWDMessage(session, sessionLaunchCWD(session))
if !strings.Contains(msg, "launch directory not found: /missing/launch-dir") {
t.Errorf("missing message should name launch dir, got %q", msg)
}
if !strings.Contains(msg, "Last session directory exists") {
t.Errorf("missing message should mention existing last cwd, got %q", msg)
}
if !strings.Contains(msg, lastCWD) {
t.Errorf("missing message should include last cwd %q, got %q", lastCWD, msg)
}
}
22 changes: 19 additions & 3 deletions source/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,12 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose
meta = jsonlMeta{
Title: entry.Title,
CWD: entry.CWD,
LaunchCWD: entry.LaunchCWD,
MsgCount: entry.MsgCount,
SessionTime: entry.SessionTime,
}
} else {
// Cache misses and incomplete older entries reparse to recover first-cwd semantics.
meta = parseJSONL(jsonlFile, verbose)
if meta.CWD == "" {
decoded, err := url.PathUnescape(dirName)
Expand All @@ -170,17 +172,21 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose
}
meta.CWD = decoded
}
if meta.LaunchCWD == "" {
meta.LaunchCWD = meta.CWD
}
cache.Store(jsonlFile, MetaEntry{
Mtime: mtime,
Size: size,
Title: meta.Title,
CWD: meta.CWD,
LaunchCWD: meta.LaunchCWD,
MsgCount: meta.MsgCount,
SessionTime: meta.SessionTime,
})
}

if meta.CWD == "" {
if meta.CWD == "" || meta.LaunchCWD == "" {
return Session{}, false
}

Expand All @@ -199,6 +205,7 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose
Title: meta.Title,
CWD: meta.CWD,
CWDDisplay: abbreviateHome(meta.CWD, home),
LaunchCWD: meta.LaunchCWD,
ProjectPath: projectPath,
Time: effectiveTime,
MsgCount: meta.MsgCount,
Expand Down Expand Up @@ -233,13 +240,17 @@ func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, e
}
meta.CWD = decoded
}
if meta.LaunchCWD == "" {
meta.LaunchCWD = meta.CWD
}

if cache != nil {
cache.Store(jsonlFile, MetaEntry{
Mtime: info.ModTime(),
Size: info.Size(),
Title: meta.Title,
CWD: meta.CWD,
LaunchCWD: meta.LaunchCWD,
MsgCount: meta.MsgCount,
SessionTime: meta.SessionTime,
})
Expand All @@ -256,6 +267,7 @@ func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, e
Title: meta.Title,
CWD: meta.CWD,
CWDDisplay: abbreviateHome(meta.CWD, home),
LaunchCWD: meta.LaunchCWD,
ProjectPath: projectPath,
Time: effectiveTime,
MsgCount: meta.MsgCount,
Expand All @@ -268,6 +280,7 @@ func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, e
type jsonlMeta struct {
Title string
CWD string
LaunchCWD string
MsgCount int
SessionTime time.Time // zero when no valid timestamp found; callers fall back to mtime
}
Expand Down Expand Up @@ -303,10 +316,14 @@ func parseJSONL(path string, verbose bool) jsonlMeta {
continue
}

// Extract cwd — last value wins (early records may carry launcher dir)
// Extract cwd. The first non-empty cwd is the launch directory;
// the last non-empty cwd is the display/filter cwd.
if raw, ok := rec["cwd"]; ok {
var s string
if json.Unmarshal(raw, &s) == nil && s != "" {
if m.LaunchCWD == "" {
m.LaunchCWD = s
}
m.CWD = s
}
}
Expand Down Expand Up @@ -638,4 +655,3 @@ func truncateStr(s string, max int) string {
runes := []rune(s)
return string(runes[:max])
}

Loading