From 7fbbee63a3ba270a32fc396e8f485baaff7f5c6c Mon Sep 17 00:00:00 2001 From: gelzin Date: Wed, 28 Oct 2026 11:31:04 -0300 Subject: [PATCH 1/2] feat(sessions): list Codex conversations, and say why they don't move seats sessions now lists Codex seats' conversations next to Claude's, the same ones codex resume lists there: archived ones, codex exec runs and subagents stay out. The title is the name given with /rename, else the first prompt, and the Id column shows the whole id, which codex resume needs. seats move refuses a Codex conversation and says why: Codex finds a copied rollout by its file name, but the conversation keeps reasoning encrypted for the account that ran it, which another account can refuse. The list points at seats run codex: resume instead. --- README.md | 11 +- src/cli.ts | 4 +- src/commands/sessions.ts | 38 ++++--- src/providers/codex/sessions.ts | 148 +++++++++++++++++++++++--- src/types.ts | 5 +- test/commands/sessions.test.ts | 40 ++++++- test/providers/codex/sessions.test.ts | 105 ++++++++++++++++-- 7 files changed, 305 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 45ca1d1..92b8283 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ┌─────────────────┬────────┬────────────┬───────────────────────────────┬──────────┐ @@ -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 ` 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. @@ -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 ` 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 `. ## One folder for every seat @@ -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-` 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 ` or `codex resume `, 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: --resume ` or `seats run codex: resume ` 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 ` or `codex resume `, 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: --resume ` or `seats run codex: resume ` 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 diff --git a/src/cli.ts b/src/cli.ts index f6b016b..30fa614 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,8 +43,8 @@ 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 Copy a conversation to another seat (the original stays) and offer to reopen it there (--force to replace) diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index cd07fae..a8deed4 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -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) @@ -100,7 +102,8 @@ export async function sessionsCommand(argv: string[]): Promise { 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) @@ -109,21 +112,32 @@ export async function sessionsCommand(argv: string[]): Promise { ); 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 .")); + const hints = sources + .filter((source) => source.sessions?.noMove) + .map( + (source) => + `${source.label} conversations stay on their seat. Reopen one with seats run ${source.id}: ${source.sessions!.resumeArgs("").join(" ")}.`, + ); + if (sources.some((source) => !source.sessions?.noMove)) + hints.push("Continue one on another seat with seats move ."); + p.outro(pc.dim(hints.join("\n"))); } /** diff --git a/src/providers/codex/sessions.ts b/src/providers/codex/sessions.ts index e5a9d6c..cf6b943 100644 --- a/src/providers/codex/sessions.ts +++ b/src/providers/codex/sessions.ts @@ -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-.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-.jsonl`. A reverted thread's file is + * `…-_.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 { @@ -13,16 +23,21 @@ 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; } /** `/sessions/YYYY/MM/DD/rollout-…-.jsonl`, else `/archived_sessions/`. Matched by name alone. */ @@ -30,13 +45,114 @@ export function findCodexSession(dir: string, sessionId: string): string | undef 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 | 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 `/session_index.jsonl`: the last one for an id wins. */ +function threadNames(dir: string): Map { + const names = new Map(); + 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; +} + +/** + * `/sessions/…/rollout-…-.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(); + 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 ` 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 = { + 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.", }; diff --git a/src/types.ts b/src/types.ts index 1867792..b9ed924 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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. */ @@ -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; }; }; diff --git a/test/commands/sessions.test.ts b/test/commands/sessions.test.ts index 6a083a6..70b712c 100644 --- a/test/commands/sessions.test.ts +++ b/test/commands/sessions.test.ts @@ -31,6 +31,28 @@ function transcript(slug: string, id: string, cwd: string, title: string, minute return file; } +const codexSeat = (slug: string): Seat => ({ + id: `codex:${slug}`, + source: "codex", + provider: "codex", + kind: "cli", + label: slug, + dir: join(env.home, ".config/seats/codex", slug), + signedIn: true, +}); + +/** A Codex rollout in `slug`'s seat, as `codex` writes it from the terminal. */ +function codexRollout(slug: string, id: string, prompt: string): string { + const lines = [ + { type: "session_meta", payload: { id, cwd: "/p/one", source: "cli" } }, + { type: "event_msg", payload: { type: "user_message", message: prompt } }, + ]; + return env.write( + `.config/seats/codex/${slug}/sessions/2026/10/15/rollout-2026-10-15T19-25-01-${id}.jsonl`, + lines.map((line) => JSON.stringify(line)).join("\n"), + ); +} + describe("listSessions", () => { test("lists every seat's conversations, newest first", () => { transcript("work", "aaaa1111", "/p/one", "oldest", 30); @@ -61,13 +83,13 @@ describe("sessionSeats", () => { expect((await sessionSeats(env.home)).map((item) => [item.id, item.signedIn])).toEqual([["claude:old", false]]); }); - test("leaves Codex out, even with a conversation: seats only finds those for herdr", async () => { + test("includes Codex seats, whose conversations are listed too", async () => { env.write(".config/seats/claude/old/.claude.json", "{}"); env.write(".config/seats/codex/work/auth.json", "{}"); - env.write(".config/seats/codex/work/sessions/2026/10/15/rollout-2026-10-15T19-25-01-c1.jsonl", "{}\n"); + codexRollout("work", "019a0000-0000-7000-8000-0000000000c1", "codex prompt"); const seats = await sessionSeats(env.home); - expect(seats.map((item) => item.id)).toEqual(["claude:old"]); - expect(listSessions(seats)).toEqual([]); + expect(seats.map((item) => item.id).sort()).toEqual(["claude:old", "codex:work"]); + expect(listSessions(seats).map((item) => [item.seat.id, item.title])).toEqual([["codex:work", "codex prompt"]]); }); }); @@ -103,6 +125,16 @@ describe("moveSession", () => { expect(readFileSync(theirs, "utf8")).toContain("newer"); }); + test("refuses to copy a Codex conversation, saying why, and refuses another tool's seat first", () => { + const file = codexRollout("work", "019a0000-0000-7000-8000-0000000000c1", "codex prompt"); + const seats = [codexSeat("work"), codexSeat("personal"), seat("work")]; + expect(() => moveSession(listSessions(seats), "019a", codexSeat("personal"))).toThrow(/encrypted for the account/); + expect(existsSync(file.replace("/work/", "/personal/"))).toBe(false); + expect(() => moveSession(listSessions(seats), "019a", seat("work"))).toThrow( + /is a codex conversation; claude:work isn't/, + ); + }); + test("refuses to move a conversation to the seat it's on", () => { transcript("work", "aaaa1111", "/p", "here", 1); expect(() => moveSession(listSessions([seat("work")]), "aaaa", seat("work"))).toThrow(/already on claude:work/); diff --git a/test/providers/codex/sessions.test.ts b/test/providers/codex/sessions.test.ts index 8a5548b..41026cf 100644 --- a/test/providers/codex/sessions.test.ts +++ b/test/providers/codex/sessions.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { join } from "node:path"; -import { codexSessions, findCodexSession } from "../../../src/providers/codex/sessions.ts"; +import { + codexSessionList, + codexSessions, + findCodexSession, + readCodexSession, +} from "../../../src/providers/codex/sessions.ts"; import { tempHome } from "../../helpers/temp-home.ts"; let env: ReturnType; @@ -8,12 +13,32 @@ beforeEach(() => (env = tempHome())); afterEach(() => env.cleanup()); const ID = "01a141ab-35b6-7ac2-8bf3-62eae17dc84b"; +const OTHER = "01a141ab-0000-7000-8000-000000000002"; + +const meta = (id: string, extra: Record = {}) => ({ + type: "session_meta", + payload: { id, cwd: "/p/one", source: "cli", ...extra }, +}); +const said = (message: string) => ({ type: "event_msg", payload: { type: "user_message", message } }); +/** What Codex records for AGENTS.md and the environment: a `user` message nobody typed. */ +const injected = { + type: "response_item", + payload: { type: "message", role: "user", content: [{ type: "input_text", text: "# AGENTS.md instructions" }] }, +}; + +/** A rollout at `path` inside the seat, one JSON line per item (strings go in as they are). */ +function rollout(path: string, items: unknown[]): string { + const lines = items.map((item) => (typeof item === "string" ? item : JSON.stringify(item))); + return env.write(`seat/${path}`, `${lines.join("\n")}\n`); +} +const live = (id: string, stamp = "2026-10-15T19-25-01") => + `sessions/${stamp.slice(0, 4)}/${stamp.slice(5, 7)}/${stamp.slice(8, 10)}/rollout-${stamp}-${id}.jsonl`; describe("findCodexSession", () => { test("finds a rollout under sessions/YYYY/MM/DD, else in archived_sessions", () => { const dir = join(env.home, "seat"); - const live = env.write(`seat/sessions/2026/10/15/rollout-2026-10-15T19-25-01-${ID}.jsonl`, "{}\n"); - expect(findCodexSession(dir, ID)).toBe(live); + const found = env.write(`seat/sessions/2026/10/15/rollout-2026-10-15T19-25-01-${ID}.jsonl`, "{}\n"); + expect(findCodexSession(dir, ID)).toBe(found); const archived = env.write("seat/archived_sessions/rollout-2026-10-01T08-00-00-old-id.jsonl", "{}\n"); expect(findCodexSession(dir, "old-id")).toBe(archived); @@ -28,8 +53,76 @@ describe("findCodexSession", () => { expect(findCodexSession(join(env.home, "no-seat"), ID)).toBeUndefined(); }); - test("resumes with codex resume , and lists nothing", () => { - expect(codexSessions.resumeArgs(ID)).toEqual(["resume", ID]); - expect(codexSessions.list).toBeUndefined(); + test("finds a reverted thread by its id, in its newest rollout", () => { + env.write(`seat/sessions/2026/10/15/rollout-2026-10-15T19-25-01-${ID}.jsonl`, "{}\n"); + const reverted = env.write(`seat/sessions/2026/10/16/rollout-2026-10-16T08-00-00-${ID}_${OTHER}.jsonl`, "{}\n"); + expect(findCodexSession(join(env.home, "seat"), ID)).toBe(reverted); + }); +}); + +describe("readCodexSession", () => { + test("takes the folder from session_meta and the title from the first typed prompt", () => { + const file = rollout(live(ID), [meta(ID), injected, said("fix the\nlogin"), said("and then this")]); + expect(readCodexSession(file)).toEqual({ cwd: "/p/one", title: "fix the login" }); + }); + + test("reads a session_meta line longer than any transcript chunk", () => { + const file = rollout(live(ID), [meta(ID, { base_instructions: { text: "x".repeat(200_000) } }), said("hi")]); + expect(readCodexSession(file)).toEqual({ cwd: "/p/one", title: "hi" }); + }); + + test("skips malformed and blank lines", () => { + const file = rollout(live(ID), ["{not json", "", meta(ID), "[1,", said("still found")]); + expect(readCodexSession(file)?.title).toBe("still found"); + }); + + test("leaves out what codex resume doesn't list: exec runs, subagents, no prompt yet", () => { + expect(readCodexSession(rollout(live("a"), [meta("a", { source: "exec" }), said("x")]))).toBeUndefined(); + const subagent = { subagent: { thread_spawn: { parent_thread_id: ID } } }; + expect(readCodexSession(rollout(live("b"), [meta("b", { source: subagent }), said("x")]))).toBeUndefined(); + expect(readCodexSession(rollout(live("c"), [meta("c"), injected]))).toBeUndefined(); + expect(readCodexSession(rollout(live("d"), [meta("d", { source: "vscode" }), said("x")]))?.title).toBe("x"); + }); +}); + +describe("codexSessionList", () => { + test("lists live rollouts with id, file and folder, leaving archived ones out", () => { + const file = rollout(live(ID), [meta(ID), said("live one")]); + rollout(`archived_sessions/rollout-2026-10-01T08-00-00-${OTHER}.jsonl`, [meta(OTHER), said("archived")]); + const sessions = codexSessionList(join(env.home, "seat")); + expect(sessions.map(({ id, file, cwd, title }) => ({ id, file, cwd, title }))).toEqual([ + { id: ID, file, cwd: "/p/one", title: "live one" }, + ]); + expect(sessions[0]!.updatedAt).toBeInstanceOf(Date); + }); + + test("prefers the latest name given with /rename, from session_index.jsonl", () => { + rollout(live(ID), [meta(ID), said("first prompt")]); + rollout(live(OTHER), [meta(OTHER), said("other prompt")]); + const names = [ + { id: ID, thread_name: "old name", updated_at: "2026-10-15T19:30:00Z" }, + { id: ID, thread_name: "new name", updated_at: "2026-10-15T20:00:00Z" }, + ]; + env.write("seat/session_index.jsonl", `${names.map((entry) => JSON.stringify(entry)).join("\n")}\n{cut`); + const titles = codexSessionList(join(env.home, "seat")).map((item) => [item.id, item.title]); + expect(titles.sort()).toEqual([ + [OTHER, "other prompt"], + [ID, "new name"], + ]); }); + + test("lists a reverted thread once, from its newest rollout", () => { + rollout(live(ID), [meta(ID), said("before revert")]); + const reverted = rollout(live(`${ID}_${OTHER}`, "2026-10-16T08-00-00"), [meta(ID), said("after revert")]); + expect(codexSessionList(join(env.home, "seat")).map((item) => [item.id, item.file])).toEqual([[ID, reverted]]); + }); + + test("is empty for a seat without sessions", () => { + expect(codexSessionList(join(env.home, "no-seat"))).toEqual([]); + }); +}); + +test("resumes with codex resume , and won't be moved", () => { + expect(codexSessions.resumeArgs(ID)).toEqual(["resume", ID]); + expect(codexSessions.noMove).toMatch(/stay on their seat/); }); From 05b5ca1c1591038d65dbd6ba3489e024d413ffe7 Mon Sep 17 00:00:00 2001 From: gelzin Date: Wed, 28 Oct 2026 11:33:43 -0300 Subject: [PATCH 2/2] fix(sessions): point the Codex reopen hint at the seat id the list shows The hint under seats sessions now reads seats run resume , so the Seat column's value goes in as it is, and the help for seats move says it copies Claude conversations. --- src/cli.ts | 5 +++-- src/commands/sessions.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 30fa614..fb91770 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,8 +46,9 @@ Usage: Conversations from every Claude and Codex seat, newest first: this folder's, or every project's with --all seats move - 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. diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index a8deed4..47f422e 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -133,7 +133,7 @@ export async function sessionsCommand(argv: string[]): Promise { .filter((source) => source.sessions?.noMove) .map( (source) => - `${source.label} conversations stay on their seat. Reopen one with seats run ${source.id}: ${source.sessions!.resumeArgs("").join(" ")}.`, + `${source.label} conversations stay on their seat. Reopen one with seats run ${source.sessions!.resumeArgs("").join(" ")}.`, ); if (sources.some((source) => !source.sessions?.noMove)) hints.push("Continue one on another seat with seats move .");