Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>.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).
Expand Down
55 changes: 55 additions & 0 deletions src/lib/session/active.pickfile.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
33 changes: 29 additions & 4 deletions src/lib/session/active.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>.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[];
Expand All @@ -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';
Expand Down Expand Up @@ -423,7 +441,14 @@ export async function listTerminalsActive(): Promise<ActiveSession[]> {
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
// <id>.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)
Expand Down