diff --git a/CHANGELOG.md b/CHANGELOG.md index 319dd7400..5351d0c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **Fix: `sessions --active` showed the SAME preview + topic for every co-located session.** Multiple Claude sessions in one cwd (e.g. several editor tabs, or two worktree siblings) all rendered identical activity — they looked like duplicate cards. `findClaudeSessionFile` fell back to the newest `.jsonl` in the cwd whenever a session's `.jsonl` wasn't found, so every distinct session collapsed onto ONE file's preview/topic. The stale-id trigger: an editor publishes a session id into `live-terminals.json`, but Claude rotates its transcript uuid on resume/compact, so the cached id no longer matches any file. Now the terminal path resolves each tab's EXACT id from the pid registry (mirroring the headless path), and the newest-file fallback is gated to the no-id case only — a supplied-but-unresolved id yields no preview instead of a sibling's. Source: `src/lib/session/active.ts` (`pickSessionFile`, `listTerminalsActive`). + - **NEW: `agents sessions focus [id]`** — one command to get back to a session, however it's reachable. It **attaches** a live session in place (tmux `switch-client`/`attach-session`, a remote tmux over `ssh -tt`, or a Ghostty tab — joining the live process without forking); where there's **no live terminal to attach**, it **opens a new tab and resumes** the session — locally, or on the remote peer over SSH (`runOnPeer`, so the peer resolves the version-pinned binary). No id opens the rich live-session picker (this-machine first). Reuses the live-session detection and the terminal launch engine (`openSurfaces`), and folds `go`'s attach paths in. Source: `src/commands/focus.ts`, `src/commands/go.ts`. - **`--device` is now a first-class alias of `--host`** on every host-routable command (`sessions`, `run`, …), registered centrally on `addHostOption` so a local fall-through no longer errors. Source: `src/lib/hosts/`. - **`agents computer` steers Electron/webview targets over CDP** instead of reporting a fake success when the native-automation path can't reach them (#716). diff --git a/src/lib/session/active.pickfile.test.ts b/src/lib/session/active.pickfile.test.ts new file mode 100644 index 000000000..6ee207843 --- /dev/null +++ b/src/lib/session/active.pickfile.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pickSessionFile } from './active'; + +// Regression for the "every co-located session shows the same preview" bug: when a +// concrete session id was requested but its transcript file was absent, +// findClaudeSessionFile fell through to the NEWEST .jsonl in the cwd, so N distinct +// sessions collapsed onto one file's preview + topic (they looked like duplicate +// cards). A supplied-but-missing id must resolve to undefined, never a sibling. + +let dir: string; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pickfile-')); + // Two real transcripts; make `b` strictly newer so it is the mtime winner. + fs.writeFileSync(path.join(dir, 'a.jsonl'), '{"a":1}\n'); + fs.writeFileSync(path.join(dir, 'b.jsonl'), '{"b":1}\n'); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(path.join(dir, 'a.jsonl'), old, old); +}); + +afterAll(() => { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } +}); + +describe('pickSessionFile', () => { + test('a concrete id returns its own file', () => { + expect(pickSessionFile(dir, 'a')).toBe(path.join(dir, 'a.jsonl')); + expect(pickSessionFile(dir, 'b')).toBe(path.join(dir, 'b.jsonl')); + }); + + test('a supplied-but-missing id returns undefined — NOT the newest sibling', () => { + // This is the fix: pre-fix this returned b.jsonl (the newest), so every + // co-located session with an unresolved id shared b.jsonl's preview + topic. + expect(pickSessionFile(dir, 'does-not-exist')).toBeUndefined(); + }); + + test('two distinct missing ids do NOT collapse onto the same file', () => { + const one = pickSessionFile(dir, 'ghost-1'); + const two = pickSessionFile(dir, 'ghost-2'); + expect(one).toBeUndefined(); + expect(two).toBeUndefined(); + // Neither borrowed the newest file, so they can't render an identical preview. + }); + + test('no id falls back to the newest file (legitimate single-session heuristic)', () => { + expect(pickSessionFile(dir, undefined)).toBe(path.join(dir, 'b.jsonl')); + }); + + test('an unreadable project dir returns undefined', () => { + expect(pickSessionFile(path.join(dir, 'nope'), undefined)).toBeUndefined(); + }); +}); diff --git a/src/lib/session/active.ts b/src/lib/session/active.ts index bfcf38e9d..6cc8b32e2 100644 --- a/src/lib/session/active.ts +++ b/src/lib/session/active.ts @@ -221,11 +221,25 @@ function claudeProjectDirName(cwd: string): string { * fall back to the most-recent-mtime .jsonl in the project's folder. */ function findClaudeSessionFile(cwd: string, sessionId?: string): string | undefined { - const projectDir = path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd)); + return pickSessionFile(path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd)), sessionId); +} +/** + * Pick a Claude transcript file within a project dir. + * + * With a CONCRETE session id: return that id's `.jsonl` or undefined — NEVER a + * sibling's. Falling back to the newest file here is the bug that made N distinct + * co-located sessions (e.g. several editor tabs in one cwd, or two worktree siblings) + * all collapse onto ONE file and render identical preview + topic (they look like + * duplicate cards). The mtime fallback is only sound when NO id is known. + * + * With NO id: return the newest `.jsonl` by mtime (the legitimate single-session + * heuristic for a directly-launched agent with no registry entry). + */ +export function pickSessionFile(projectDir: string, sessionId?: string): string | undefined { if (sessionId) { const specific = path.join(projectDir, `${sessionId}.jsonl`); - if (fs.existsSync(specific)) return specific; + return fs.existsSync(specific) ? specific : undefined; } let files: string[]; @@ -247,7 +261,11 @@ function findClaudeSessionFile(cwd: string, sessionId?: string): string | undefi } function classifyActivity(sessionFile: string | undefined): 'running' | 'idle' { - if (!sessionFile) return 'running'; + // No resolvable transcript is NOT evidence of activity — default to idle. (Before + // the no-borrow fix in findClaudeSessionFile this rarely fired because every + // terminal borrowed the newest file; now a session with an unresolved id lands + // here, and "running" would wrongly light it up.) + if (!sessionFile) return 'idle'; try { const mtimeMs = fs.statSync(sessionFile).mtimeMs; return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle'; @@ -423,7 +441,14 @@ export async function listTerminalsActive(): Promise { const labelMap = buildClaudeLabelMap(); return entries.map((t): ActiveSession => { - const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, t.sessionId); + // The id cached in live-terminals.json goes stale when Claude rotates its + // transcript uuid on resume/compact, so it often no longer matches any + // .jsonl. Prefer the EXACT id this pid was launched with from the pid + // registry (written by the shim delegate) — the same source the headless path + // uses — so each tab resolves to its OWN current transcript instead of every + // co-located tab collapsing onto the newest file. Falls back to the cached id. + const resolvedId = readPidSessionEntry(t.pid)?.sessionId ?? t.sessionId; + const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId); // Prefer label from live terminal, fall back to Claude's session label const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined; // Extract topic from session file (first meaningful user message)