Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to Codex Extras are documented in this file.

## Unreleased

- Hide archived chats even when they remain in the session index.
- Refresh after archiving or restoring chats without requiring an index change;
use scoped VS Code file system watchers, coalesce bursts of events, and
support nested archives and compressed `.jsonl.zst` files.

## 1.1.0

- Add a Codex Chats Activity Bar panel with local chat history, automatic
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ conversations ordered by their most recent activity.
- Select a chat to open it as a pinned Codex editor tab.
- Start a new chat or refresh the list from the panel toolbar.
- New and renamed chats appear automatically while VS Code is running.
- Archived chats are hidden, including compressed archives. File system events
refresh the list after archiving or restoring a chat, without polling the archive.

The panel reads Codex's local session index from `$CODEX_HOME/session_index.jsonl`
when `CODEX_HOME` is set, or from `~/.codex/session_index.jsonl` otherwise.
Expand Down
8 changes: 7 additions & 1 deletion extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const fs = require("node:fs");
const { readFile } = require("node:fs/promises");
const path = require("node:path");
const vscode = require("vscode");
const { loadArchivedSessionIds, watchArchivedSessions } = require("./session-archive");
const {
getSessionIndexPath,
getSessionsDirectory,
Expand Down Expand Up @@ -80,6 +81,7 @@ class CodexChatHistoryProvider {
constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders) {
this.sessionIndexPath = sessionIndexPath;
this.sessionsDirectory = sessionsDirectory;
this.archiveDirectory = path.join(path.dirname(sessionIndexPath), "archived_sessions");
this.getWorkspaceFolders = getWorkspaceFolders;
this.workspaceBySessionId = new Map();
this.changeEmitter = new vscode.EventEmitter();
Expand Down Expand Up @@ -107,7 +109,10 @@ class CodexChatHistoryProvider {

try {
const contents = await readFile(this.sessionIndexPath, "utf8");
const sessions = parseSessionIndex(contents);
const archivedIds = await loadArchivedSessionIds(this.archiveDirectory);
const sessions = parseSessionIndex(contents).filter(
(session) => !archivedIds.has(session.id.toLowerCase()),
);
const missingWorkspaceIds = sessions
.filter((session) => !this.workspaceBySessionId.has(session.id))
.map((session) => session.id);
Expand Down Expand Up @@ -391,6 +396,7 @@ function activate(context) {
visibilitySubscription,
workspaceSubscription,
watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()),
watchArchivedSessions(historyProvider.archiveDirectory, () => historyProvider.refresh(), vscode),
);
}

Expand Down
66 changes: 66 additions & 0 deletions session-archive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use strict";

const { readdir } = require("node:fs/promises");
const path = require("node:path");
const SESSION_ID_IN_FILENAME =
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i;

// Read names only; archived transcripts may be compressed.
async function loadArchivedSessionIds(directory) {
const ids = new Set();
async function visit(current) {
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch (error) {
if (error.code === "ENOENT") return;
throw error;
}
for (const entry of entries) {
if (entry.isDirectory()) {
await visit(path.join(current, entry.name));
} else if (entry.isFile()) {
const id = entry.name.match(SESSION_ID_IN_FILENAME)?.[1];
if (id) ids.add(id.toLowerCase());
}
}
}
await visit(directory);
return ids;
}

// Watch the archive recursively, plus its parent non-recursively so creating
// or replacing the entire archive also refreshes the list. VS Code handles
// attaching the recursive watcher when a missing directory is created.
function watchArchivedSessions(directory, onChange, vscode) {
let timer;
const scheduleRefresh = () => {
clearTimeout(timer);
timer = setTimeout(onChange, 100);
};
const archiveWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(directory, "**"), false, true, false,
);
const parentWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(path.dirname(directory), "*"), false, true, false,
);
const onParentChange = (uri) => {
if (path.relative(directory, uri.fsPath) === "") scheduleRefresh();
};
const subscriptions = [
archiveWatcher.onDidCreate(scheduleRefresh),
archiveWatcher.onDidDelete(scheduleRefresh),
parentWatcher.onDidCreate(onParentChange),
parentWatcher.onDidDelete(onParentChange),
];
return {
dispose() {
clearTimeout(timer);
for (const subscription of subscriptions) subscription.dispose();
archiveWatcher.dispose();
parentWatcher.dispose();
},
};
}

module.exports = { loadArchivedSessionIds, watchArchivedSessions };
79 changes: 79 additions & 0 deletions test/extension.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,53 @@ const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { setTimeout: delay } = require("node:timers/promises");

const extensionPath = path.resolve(__dirname, "..", "extension.js");
const originalLoad = Module._load;

test("hides an already archived session that remains in the index", async (t) => {
const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-archived-"));
t.after(() => rm(codexHome, { recursive: true, force: true }));
const id = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f";
await mkdir(path.join(codexHome, "archived_sessions"));
await writeFile(path.join(codexHome, "archived_sessions", `rollout-${id}.jsonl`), "");
await writeFile(path.join(codexHome, "session_index.jsonl"), JSON.stringify({ id }));
const harness = createHarness(t, [], { codexHome, workspaceFolders: [] });
assert.deepEqual(await harness.treeViews[0].options.treeDataProvider.getChildren(), []);
});

test("hides archived chats and refreshes on archive and restore without index changes", async (t) => {
const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-archive-"));
t.after(() => rm(codexHome, { recursive: true, force: true }));
const id = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f";
const index = JSON.stringify({ id, thread_name: "Archived regression" });
await writeFile(path.join(codexHome, "session_index.jsonl"), index);
const harness = createHarness(t, [], { codexHome, workspaceFolders: [] });
const provider = harness.treeViews[0].options.treeDataProvider;
let changes = 0;
provider.onDidChangeTreeData(() => changes++);
const waitForChange = async (previous) => {
const deadline = Date.now() + 5000;
while (changes === previous && Date.now() < deadline) await delay(25);
assert.ok(changes > previous, "archive changes must refresh the panel");
};
assert.equal((await provider.getChildren())[0].sessions.length, 1);
const archive = path.join(codexHome, "archived_sessions", "2026", "10");
await mkdir(archive, { recursive: true });
let previous = changes;
const archivedFile = path.join(archive, `rollout-${id}.jsonl.zst`);
await writeFile(archivedFile, "compressed contents need not be read");
await waitForChange(previous);
assert.deepEqual(await provider.getChildren(), []);
previous = changes;
await rm(archivedFile);
await waitForChange(previous);
assert.equal((await provider.getChildren())[0].sessions[0].id, id);
const { readFile } = require("node:fs/promises");
assert.equal(await readFile(path.join(codexHome, "session_index.jsonl"), "utf8"), index);
});

function loadExtension(vscode) {
delete require.cache[extensionPath];
Module._load = function load(request, parent, isMain) {
Expand Down Expand Up @@ -63,6 +106,12 @@ function createHarness(t, tabs = [], options = {}) {
}

const vscode = {
RelativePattern: class RelativePattern {
constructor(base, pattern) {
this.base = base;
this.pattern = pattern;
}
},
commands: {
registerCommand(command, handler) {
commandHandlers.set(command, handler);
Expand Down Expand Up @@ -93,6 +142,36 @@ function createHarness(t, tabs = [], options = {}) {
Expanded: 2,
},
workspace: {
createFileSystemWatcher(pattern) {
const created = new EventEmitter();
const deleted = new EventEmitter();
// Exercise real filesystem changes in the temporary Codex home. The
// production extension delegates watcher lifecycle to VS Code.
let nativeWatcher;
if (options.codexHome) {
nativeWatcher = require("node:fs").watch(
options.codexHome, { recursive: true }, (event, filename) => {
if (event !== "rename" || !filename) return;
const fsPath = path.join(options.codexHome, filename);
const relative = path.relative(pattern.base, fsPath);
const matches = pattern.pattern === "*"
? relative !== "" && !relative.includes(path.sep) && !relative.startsWith("..")
: relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
if (matches) created.fire({ fsPath });
},
);
nativeWatcher.on("error", () => {});
}
return {
onDidCreate: created.event,
onDidDelete: deleted.event,
dispose() {
nativeWatcher?.close();
created.dispose();
deleted.dispose();
},
};
},
workspaceFolders: options.workspaceFolders ?? [
{
name: "codex-extras",
Expand Down
90 changes: 90 additions & 0 deletions test/session-archive.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use strict";

const assert = require("node:assert/strict");
const { mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { loadArchivedSessionIds, watchArchivedSessions } = require("../session-archive");
const { setTimeout: delay } = require("node:timers/promises");

test("reads nested plain and compressed archive filenames, ignoring unrelated files", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "codex-extras-archive-"));
t.after(() => rm(root, { recursive: true, force: true }));
assert.deepEqual([...await loadArchivedSessionIds(path.join(root, "missing"))], []);
const nested = path.join(root, "2026", "10");
await mkdir(nested, { recursive: true });
const plain = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f";
const compressed = "019f9c28-a20f-70f3-9df9-518aaf67a20b";
await writeFile(path.join(root, `rollout-${plain}.jsonl`), "");
await writeFile(path.join(nested, `rollout-${compressed.toUpperCase()}.jsonl.zst`), "");
await writeFile(path.join(root, `rollout-${plain}.jsonl.bak`), "");
await writeFile(path.join(root, "unrelated.jsonl"), "");
assert.deepEqual([...await loadArchivedSessionIds(root)].sort(), [plain, compressed].sort());
});

function createWatcherHarness(directory, onChange) {
const watchers = [];
const vscode = {
RelativePattern: class RelativePattern {
constructor(base, pattern) { Object.assign(this, { base, pattern }); }
},
workspace: {
createFileSystemWatcher(pattern, ignoreCreate, ignoreChange, ignoreDelete) {
const listeners = { create: new Set(), delete: new Set() };
const subscribe = (type) => (listener) => {
listeners[type].add(listener);
return { dispose: () => listeners[type].delete(listener) };
};
const watcher = {
pattern, ignoreCreate, ignoreChange, ignoreDelete, disposed: false,
onDidCreate: subscribe("create"),
onDidDelete: subscribe("delete"),
emit(type, fsPath) { for (const listener of listeners[type]) listener({ fsPath }); },
dispose() { this.disposed = true; },
};
watchers.push(watcher);
return watcher;
},
},
};
return { watcher: watchArchivedSessions(directory, onChange, vscode), watchers };
}

test("uses scoped file events, coalesces bursts, and ignores unrelated parent changes", async (t) => {
const directory = path.resolve(os.tmpdir(), "codex-home", "archived_sessions");
let changes = 0;
const { watcher, watchers: [archive, parent] } = createWatcherHarness(directory, () => changes++);
t.after(() => watcher.dispose());
assert.deepEqual([archive.pattern.base, archive.pattern.pattern], [directory, "**"]);
assert.deepEqual([parent.pattern.base, parent.pattern.pattern], [path.dirname(directory), "*"]);
assert.deepEqual([archive.ignoreCreate, archive.ignoreChange, archive.ignoreDelete], [false, true, false]);
parent.emit("create", path.join(path.dirname(directory), "state.sqlite-wal"));
await delay(150);
assert.equal(changes, 0);
archive.emit("create", path.join(directory, "2026", "session.jsonl.zst"));
archive.emit("delete", path.join(directory, "2026", "session.jsonl"));
await delay(150);
assert.equal(changes, 1);
await delay(150);
assert.equal(changes, 1, "no periodic refresh when idle");
parent.emit("delete", directory);
await delay(150);
assert.equal(changes, 2, "whole archive deletion refreshes the list");
parent.emit("create", directory);
await delay(150);
assert.equal(changes, 3, "whole archive creation refreshes the list");
});

test("disposing watchers cancels a pending refresh and removes event listeners", async () => {
let changes = 0;
const directory = path.resolve(os.tmpdir(), "codex-home", "archived_sessions");
const { watcher, watchers } = createWatcherHarness(directory, () => changes++);
watchers[0].emit("create", directory);
watcher.dispose();
watchers[0].emit("delete", directory);
watchers[1].emit("create", directory);
await delay(150);
assert.equal(changes, 0);
assert.ok(watchers.every(({ disposed }) => disposed));
});