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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ seats auto-switch on # off by default: move to the next seat when one hits i
seats use api:openrouter
seats health # ask every seat to reply and see which ones work
seats usage # every seat's 5-hour and weekly usage, without sending a prompt
seats sessions # conversations in this folder, from every Claude seat
seats sessions # conversations in this folder, from every Claude and Codex seat
seats move 3f2a claude:work # copy one to another seat and pick it up there
seats add claude work # sign a new account in, without touching the active one
seats desktop open work # Claude Desktop on another account, beside the ones already open
Expand Down Expand Up @@ -228,7 +228,7 @@ Things to know:

## Continue a conversation on another seat

Each seat keeps its own history, so `claude --resume` on one account doesn't show what you were doing on another. `seats sessions` lists conversations from every Claude seat, newest first:
Each seat keeps its own history, so `claude --resume` on one account doesn't show what you were doing on another. `seats sessions` lists conversations from every Claude and Codex seat, newest first:

```text
┌─────────────────┬────────┬────────────┬───────────────────────────────┬──────────┐
Expand All @@ -239,8 +239,9 @@ Each seat keeps its own history, so `claude --resume` on one account doesn't sho
└─────────────────┴────────┴────────────┴───────────────────────────────┴──────────┘
```

- It shows the conversations of the folder you're in, the same ones `claude --resume` would list there. Add `--all` for every project. Pass a seat id to see one seat: `seats sessions claude:work`.
- It shows the conversations of the folder you're in, the same ones `claude --resume` would list there. Add `--all` for every project. Pass a tool or seat id to see fewer: `seats sessions codex`, `seats sessions claude:work`.
- The title is the name you gave the conversation, or Claude's own title, or else your first message. Only the start and end of each transcript are read, so long conversations stay fast to list.
- Codex conversations are the ones `codex resume` lists: archived ones, `codex exec` runs and subagents stay out. Their Id column shows the whole id, which is what `codex resume` takes.
- The 20 newest are shown, with a note when there are more.

`seats move <id> <seat>` copies a conversation to another seat. The first characters of the id are enough; if they fit more than one conversation, seats lists them so you can type more.
Expand All @@ -254,7 +255,7 @@ seats move 3f2a claude:work --force # replace a copy the seat already has
- The transcript and its folder land at the same place inside the other seat, so `claude --resume` finds it there.
- A seat that already has that conversation keeps its copy unless you add `--force`. Moving a conversation to the seat it's on is an error.
- Afterwards seats prints the command that continues it, and in a terminal it asks "Open it now on claude:work?". Yes opens it on that seat with `claude --resume`, from the folder the conversation ran in. Your active seat doesn't change, and auto-switch stays out of this launch, so it opens on the seat you named.
- Codex isn't supported yet: it isn't confirmed that copying its session file is enough for `codex resume` on another account.
- Codex conversations are listed but not moved. Codex would find the copy (`codex resume <id>` looks up the file by name), but a conversation keeps the reasoning Codex encrypted for the account that ran it, and the API can refuse it from another account. Reopen one on its own seat with `seats run codex:work resume <id>`.

## One folder for every seat

Expand Down Expand Up @@ -477,7 +478,7 @@ seatEnv("claude", "work"); // { CLAUDE_CONFIG_DIR: "/home/you/.config/seats/clau

**Paseo.** [`plugins/paseo`](plugins/paseo) is a Paseo plugin that sets `CLAUDE_CONFIG_DIR` from seats whenever a `claude-<seat>` provider opens a session. Keep the provider's `env` in Paseo's `config.json` too, because provider diagnostics and model listing don't go through the plugin. Paseo only loads modules from inside the plugin folder, so the plugin installs seats as a copy (`install-links`). After changing the library, run `bun run build` here and `npm install` in `plugins/paseo`, then reload the plugin. `seats install plugin paseo` prints the config to add. It used to live in `integrations/paseo`: if you linked Paseo to that path, point it at `plugins/paseo` instead (`seats doctor` notices).

**herdr.** [`plugins/herdr`](plugins/herdr) makes [herdr](https://herdr.dev) reopen each Claude Code and Codex pane on the seat its conversation ran on. On its own, herdr restores those panes after a restart with `claude --resume <id>` or `codex resume <id>`, which open the active seat. The plugin runs `seats hook herdr` when herdr detects Claude Code or Codex in a pane or the pane's status changes, finds the seat that has the conversation (the most recently written copy, when several have it) and reports `seats run claude:<seat> --resume <id>` or `seats run codex:<seat> resume <id>` to herdr, which then uses it over its own. Codex conversations are only found by file name, never listed or copied, so `seats sessions` and `seats move` stay Claude-only. Needs herdr 0.9.2 or later, its integration for each tool (`herdr integration install claude`, `herdr integration install codex`) and `seats` on your `PATH`:
**herdr.** [`plugins/herdr`](plugins/herdr) makes [herdr](https://herdr.dev) reopen each Claude Code and Codex pane on the seat its conversation ran on. On its own, herdr restores those panes after a restart with `claude --resume <id>` or `codex resume <id>`, which open the active seat. The plugin runs `seats hook herdr` when herdr detects Claude Code or Codex in a pane or the pane's status changes, finds the seat that has the conversation (the most recently written copy, when several have it) and reports `seats run claude:<seat> --resume <id>` or `seats run codex:<seat> resume <id>` to herdr, which then uses it over its own. Codex conversations are found by file name. Needs herdr 0.9.2 or later, its integration for each tool (`herdr integration install claude`, `herdr integration install codex`) and `seats` on your `PATH`:

```bash
seats install plugin herdr # --dry-run prints the herdr command
Expand Down
9 changes: 5 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ Usage:
Show each seat's 5-hour and weekly usage, without
sending a prompt (--json for scripts)
seats sessions [tool|id] [--all]
Conversations from every Claude seat, newest first:
this folder's, or every project's with --all
Conversations from every Claude and Codex seat, newest
first: this folder's, or every project's with --all
seats move <session> <seat>
Copy a conversation to another seat (the original
stays) and offer to reopen it there (--force to replace)
Copy a Claude conversation to another seat (the
original stays) and offer to reopen it there (--force
to replace)
seats add [tool] [name]
Sign a new account in (claude | codex) without touching
the active one. Args after -- go to the tool's login.
Expand Down
38 changes: 26 additions & 12 deletions src/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export function moveSession(sessions: SeatSession[], prefix: string, target: Sea
const copies = findSession(sessions, prefix);
const { id, seat } = copies[0]!;
if (seat.source !== target.source) throw new Error(`${id} is a ${seat.source} conversation; ${target.id} isn't.`);
const noMove = seatSourceById(seat.source).sessions?.noMove;
if (noMove) throw new Error(noMove);
const others = copies.filter((session) => session.seat.id !== target.id);
if (others.length === 0) throw new Error(`${shortId(id)} is already on ${target.id}. Name another seat.`);
if (others.length < copies.length && !force)
Expand All @@ -100,7 +102,8 @@ export async function sessionsCommand(argv: string[]): Promise<void> {
const cwd = process.cwd();
intro();
const seats = (await sessionSeats(home)).filter((seat) => matchesFilter(seat, filter));
if (seats.length === 0) fail(filter ? `No Claude seat matches ${filter}.` : "No Claude seats found.");
if (seats.length === 0)
fail(filter ? `No seat with conversations matches ${filter}.` : "No seats with conversations found.");

const sessions = listSessions(seats).filter((session) => all || inFolder(session, cwd));
if (sessions.length === 0)
Expand All @@ -109,21 +112,32 @@ export async function sessionsCommand(argv: string[]): Promise<void> {
);

const now = new Date();
const titleWidth = Math.max(20, (process.stdout.columns || 120) - 80);
const rows = sessions
.slice(0, LIMIT)
.map((session) => [
session.seat.id,
pc.dim(ago(session.updatedAt, now)),
session.cwd ? tilde(session.cwd) : pc.dim("—"),
truncate(session.title, titleWidth),
pc.cyan(shortId(session.id)),
]);
const shown = sessions.slice(0, LIMIT);
const sources = [...new Set(shown.map((session) => session.seat.source))].map(seatSourceById);
// Only seats move takes a prefix; a conversation that can't move is reopened by its whole id.
const fullId = (session: SeatSession) => seatSourceById(session.seat.source).sessions?.noMove !== undefined;
const idWidth = Math.max(...shown.map((session) => (fullId(session) ? session.id.length : 8)));
const titleWidth = Math.max(20, (process.stdout.columns || 120) - 72 - idWidth);
const rows = shown.map((session) => [
session.seat.id,
pc.dim(ago(session.updatedAt, now)),
session.cwd ? tilde(session.cwd) : pc.dim("—"),
truncate(session.title, titleWidth),
pc.cyan(fullId(session) ? session.id : shortId(session.id)),
]);
console.log(table(["Seat", "When", "Project", "Title", "Id"], rows));
const more = sessions.length - LIMIT;
if (more > 0)
p.log.message(pc.dim(`${more} older ${more === 1 ? "conversation isn't" : "conversations aren't"} shown.`));
p.outro(pc.dim("Continue one on another seat with seats move <id> <seat>."));
const hints = sources
.filter((source) => source.sessions?.noMove)
.map(
(source) =>
`${source.label} conversations stay on their seat. Reopen one with seats run <seat> ${source.sessions!.resumeArgs("<id>").join(" ")}.`,
);
if (sources.some((source) => !source.sessions?.noMove))
hints.push("Continue one on another seat with seats move <id> <seat>.");
p.outro(pc.dim(hints.join("\n")));
}

/**
Expand Down
148 changes: 132 additions & 16 deletions src/providers/codex/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { readdirSync } from "node:fs";
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
import { join } from "node:path";
import type { SeatSource } from "../../types.ts";
import type { SeatSource, Session } from "../../types.ts";

/** `rollout-2026-10-15T19-25-01-<id>.jsonl`: the id is everything after the timestamp. */
const ROLLOUT = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
/**
* `rollout-2026-10-15T19-25-01-<id>.jsonl`. A reverted thread's file is
* `…-<id>_<rollout id>.jsonl`: the session id is the part before `_`.
* Compressed `.jsonl.zst` rollouts don't match.
*/
const ROLLOUT = /^rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-([^_]+)(?:_.+)?\.jsonl$/;

/**
* Enough for the `session_meta` line, which carries the whole base
* instructions, and the turn context that comes before the first prompt.
*/
const HEAD = 512 * 1024;

function entries(dir: string) {
try {
Expand All @@ -13,30 +23,136 @@ function entries(dir: string) {
}
}

/** The rollout of `sessionId` under `dir`, at any depth. */
function findRollout(dir: string, sessionId: string): string | undefined {
for (const entry of entries(dir)) {
/** Every rollout under `dir`, at any depth. */
function rollouts(dir: string): { file: string; id: string; stamp: string }[] {
return entries(dir).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
const found = findRollout(path, sessionId);
if (found) return found;
} else if (ROLLOUT.exec(entry.name)?.[1] === sessionId) return path;
}
return undefined;
if (entry.isDirectory()) return rollouts(path);
const match = ROLLOUT.exec(entry.name);
return match ? [{ file: path, id: match[2]!, stamp: match[1]! }] : [];
});
}

/** The newest rollout of `sessionId` under `dir`: a reverted thread has several. */
function findRollout(dir: string, sessionId: string): string | undefined {
return rollouts(dir)
.filter((rollout) => rollout.id === sessionId)
.sort((a, b) => b.stamp.localeCompare(a.stamp) || b.file.localeCompare(a.file))[0]?.file;
}

/** `<seat>/sessions/YYYY/MM/DD/rollout-…-<id>.jsonl`, else `<seat>/archived_sessions/`. Matched by name alone. */
export function findCodexSession(dir: string, sessionId: string): string | undefined {
return findRollout(join(dir, "sessions"), sessionId) ?? findRollout(join(dir, "archived_sessions"), sessionId);
}

/** The complete lines in the first `bytes` of `file`. A line cut at the end is dropped. */
function head(file: string, bytes = HEAD): string[] {
const fd = openSync(file, "r");
try {
const buffer = Buffer.alloc(Math.min(bytes, statSync(file).size));
const text = buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
const lines = text.split("\n");
return buffer.length < bytes ? lines : lines.slice(0, -1);
} finally {
closeSync(fd);
}
}

type Line = {
type?: unknown;
payload?: { type?: unknown; cwd?: unknown; source?: unknown; message?: unknown };
};

/** Sources `codex resume` lists: exec runs, subagents and the like aren't conversations to continue. */
const INTERACTIVE = new Set(["cli", "vscode"]);

const text = (value: unknown) => (typeof value === "string" && value.trim() ? value : undefined);
const oneLine = (value: string) => value.replace(/\s+/g, " ").trim();

/**
* Folder and first prompt of one rollout, from its `session_meta` and first
* `user_message` event. The `user` messages beside them also carry AGENTS.md
* and the environment, so they aren't read. Undefined for a rollout `codex
* resume` wouldn't list, or one with no prompt yet.
*/
export function readCodexSession(file: string): Pick<Session, "cwd" | "title"> | undefined {
let cwd: string | undefined;
let interactive = false;
for (const raw of head(file)) {
if (!raw.trim()) continue;
let line: Line;
try {
line = JSON.parse(raw) as Line;
} catch {
continue;
}
if (line.type === "session_meta") {
cwd = text(line.payload?.cwd);
interactive = INTERACTIVE.has(line.payload?.source as string);
}
if (!interactive) continue;
const prompt = line.type === "event_msg" && line.payload?.type === "user_message" && text(line.payload.message);
if (prompt) return { cwd, title: oneLine(prompt) };
}
return undefined;
}

/** Names given with `/rename`, from `<seat>/session_index.jsonl`: the last one for an id wins. */
function threadNames(dir: string): Map<string, string> {
const names = new Map<string, string>();
let content = "";
try {
content = readFileSync(join(dir, "session_index.jsonl"), "utf8");
} catch {
return names;
}
for (const raw of content.split("\n")) {
try {
const entry = JSON.parse(raw) as { id?: unknown; thread_name?: unknown };
const name = text(entry.thread_name);
if (typeof entry.id === "string" && name) names.set(entry.id, oneLine(name));
} catch {
// cut or blank line
}
}
return names;
}

/**
* `<seat>/sessions/…/rollout-…-<id>.jsonl`, the conversations `codex resume`
* lists. Archived ones stay out, as they do there.
*/
export function codexSessionList(dir: string): Session[] {
const names = threadNames(dir);
const newest = new Map<string, { file: string; stamp: string }>();
for (const rollout of rollouts(join(dir, "sessions"))) {
const kept = newest.get(rollout.id);
if (!kept || rollout.stamp > kept.stamp) newest.set(rollout.id, rollout);
}
const sessions: Session[] = [];
for (const [id, { file }] of newest) {
try {
const found = readCodexSession(file);
if (found)
sessions.push({ id, file, ...found, title: names.get(id) ?? found.title, updatedAt: statSync(file).mtime });
} catch {
// unreadable rollout: leave it out
}
}
return sessions;
}

/**
* Finding only, for `seats hook herdr`: no `list`, so `seats sessions` and
* `seats move` leave Codex out. It isn't confirmed that a copied rollout
* resumes on another account.
* Listed, never copied. `codex resume <id>` does find a rollout copied into
* another seat, but the conversation keeps the reasoning Codex encrypted for
* the account that ran it, and replaying that under another account can be
* refused.
*/
export const codexSessions: NonNullable<SeatSource["sessions"]> = {
list: codexSessionList,
resumeArgs: (sessionId) => ["resume", sessionId],
find: findCodexSession,
resumedId: ([command, id]) => (command === "resume" && id && !id.startsWith("-") ? id : undefined),
noMove:
"Codex conversations stay on their seat: they carry reasoning encrypted for the account that ran them, which another account can refuse.",
};
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export type SeatSource = {
* The conversations a seat keeps. With `list`, for `seats sessions` and
* `seats move`: a session is one file that resumes from the same path in
* any seat. With only `find`, seats can name the seat that has a session
* (`seats hook herdr`) but never lists or copies them.
* (`seats hook herdr`) but never lists or copies them. With `noMove`, it
* lists them but `seats move` refuses.
*/
sessions?: {
/** Conversations kept in the seat folder `dir`, in no particular order. */
Expand All @@ -120,6 +121,8 @@ export type SeatSource = {
find?: (dir: string, sessionId: string) => string | undefined;
/** The session that CLI args reopen, read back from what `resumeArgs` writes; undefined for a new session. */
resumedId?: (args: string[]) => string | undefined;
/** Why a copy wouldn't resume on another seat, for `seats move` to say instead of copying. */
noMove?: string;
};
};

Expand Down
Loading
Loading