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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ On Windows, v0.1.12 enables toast window focus automatically. The extension reco

### Remote SSH Workspaces

For a workspace outside a recognizable home directory (for example,
`/data/alice/project`), configure `codexNotifier.remoteSessionsPath` with the
absolute remote sessions directory. An unknown home or invalid remote path no
longer falls back to local sessions. `Show Diagnostics` reports bounded,
throttled metadata about discovery and read errors. Notification submission
status means the OS API accepted the request, not that a banner was displayed.

Codex Notifier remains a single extension running in VS Code's local UI
extension host, so sounds and notifications are produced on your computer. In a
Remote SSH window it reads `~/.codex/sessions/**/*.jsonl` through VS Code's
Expand Down
91 changes: 75 additions & 16 deletions extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ let codexInputRequestTurnIds = new Set();
let codexSessionInfo = new Map();
let statusItem = null;
let statusTimer = null;
const diagnosticEvents = new Map();
let lastNotificationAttemptAt = 0;
let lastNotificationResult = "none";

// Keep bounded metadata only. Never include notification text or transcript bodies.
function recordDiagnostic(code, source, detail) {
const key = `${code}:${source}`;
const previous = diagnosticEvents.get(key);
const now = Date.now();
const event = { code, source, detail, at: fmtTs(now), count: (previous?.count || 0) + 1 };
event.loggedAt = previous?.loggedAt || 0;
if (!previous || now - event.loggedAt >= 60000) {
getOutput().appendLine(`[${event.at}] ${code}: ${source} ${detail}`);
event.loggedAt = now;
}
diagnosticEvents.delete(key);
diagnosticEvents.set(key, event);
if (diagnosticEvents.size > 30) diagnosticEvents.delete(diagnosticEvents.keys().next().value);
}

function claimNotification(key) {
const digest = crypto.createHash("sha256").update(String(key)).digest("hex");
Expand Down Expand Up @@ -83,7 +102,9 @@ function getDiagnosticsSummary() {
ignoredSessionFiles: [...codexSessionInfo.values()].filter((info) => !info.eligible).length,
completedTurnIds: codexCompletedTurnIds.size,
inputRequestTurnIds: codexInputRequestTurnIds.size,
lastNotifyAt: fmtTs(lastCodexNotifyAt)
lastNotifyAt: fmtTs(lastCodexNotifyAt),
lastNotificationAttemptAt: fmtTs(lastNotificationAttemptAt),
lastNotificationResult
};
}

Expand Down Expand Up @@ -146,6 +167,7 @@ function showWindowsToast(title, message, launchUri = "") {
const xml = `<toast${launchUri ? ` activationType="protocol" launch="${escXml(launchUri)}"` : ""}><visual><binding template="ToastGeneric"><text>${escXml(title)}</text><text>${escXml(message)}</text></binding></visual>${actionXml}</toast>`;
const escapedXml = xml.replace(/'/g, "''");
const script = [
"$ErrorActionPreference = 'Stop';",
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;",
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null;",
`$xml = '${escapedXml}';`,
Expand All @@ -156,32 +178,53 @@ function showWindowsToast(title, message, launchUri = "") {
"$notifier.Show($toast);"
].join(" ");

execFile("powershell.exe", ["-NoProfile", "-Command", script], () => resolve());
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script],
{ timeout: 10000 }, (error) => {
resolve(error ? { status: "failed", code: String(error.code || error.name) } : { status: "submitted" });
});
});
}

// Cross-platform system notification helper for when VS Code is not focused.
function showSystemNotification(title, message, launchUri = "") {
lastNotificationAttemptAt = Date.now();
lastNotificationResult = "pending";
const complete = (result) => {
lastNotificationResult = result.status;
if (result.status === "failed") {
recordDiagnostic("system-notification-failed", process.platform, `code=${result.code}`);
}
};
return new Promise((resolve) => {
if (process.platform === "win32") {
void showWindowsToast(title, message, launchUri).finally(resolve);
void showWindowsToast(title, message, launchUri).then((result) => {
complete(result);
resolve();
});
return;
}

if (process.platform === "darwin") {
execFile(
"osascript",
["-e", `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}`],
() => resolve()
(error) => {
complete(error ? { status: "failed", code: String(error.code || error.name) } : { status: "submitted" });
resolve();
}
);
return;
}

if (process.platform === "linux") {
execFile("notify-send", [title, message], () => resolve());
execFile("notify-send", [title, message], (error) => {
complete(error ? { status: "failed", code: String(error.code || error.name) } : { status: "submitted" });
resolve();
});
return;
}

lastNotificationResult = "unsupported-platform";
resolve();
});
}
Expand Down Expand Up @@ -516,6 +559,7 @@ async function notify(kind, message, _options = {}) {
await new Promise((resolve) => setTimeout(resolve, Math.max(150, workspacePriorityDelay)));
}
if (!claimNotification(_options.dedupeKey)) {
recordDiagnostic("notification-deduplicated", _options.dedupeKey, "Already claimed by another window or turn.");
logDebug(`duplicate notification suppressed key=${_options.dedupeKey}`);
return;
}
Expand Down Expand Up @@ -646,10 +690,18 @@ function getRemoteSessionRoots() {
const roots = [];
const seen = new Set();
for (const uri of getRemoteSourceUris()) {
const homePath = inferRemoteHomePath(uri.path);
if (!configuredPath && !homePath) {
recordDiagnostic("remote-home-unknown", uri.toString(), "Set remoteSessionsPath to the absolute remote sessions directory.");
continue;
}
const sessionsPath = configuredPath
? configuredPath
: `${inferRemoteHomePath(uri.path) || ""}/.codex/sessions`;
if (!sessionsPath.startsWith("/")) continue;
: `${homePath}/.codex/sessions`;
if (!sessionsPath.startsWith("/")) {
recordDiagnostic("remote-path-invalid", uri.authority || "remote", "remoteSessionsPath must be an absolute POSIX path, not ~ or a Windows path.");
continue;
}
const root = uri.with({ path: sessionsPath, query: "", fragment: "" });
const key = root.toString();
if (!seen.has(key)) {
Expand Down Expand Up @@ -721,7 +773,8 @@ async function remoteDirectoriesAt(root) {
.filter(([, type]) => type === vscode.FileType.Directory)
.map(([name]) => name)
.sort();
} catch {
} catch (error) {
recordDiagnostic("remote-directory-read-failed", root.toString(), String(error.code || error.name || "unknown"));
return [];
}
}
Expand All @@ -738,7 +791,8 @@ async function findRemoteCodexSessionFiles() {
let entries = [];
try {
entries = await vscode.workspace.fs.readDirectory(dayDir);
} catch {
} catch (error) {
recordDiagnostic("remote-directory-read-failed", dayDir.toString(), String(error.code || error.name || "unknown"));
continue;
}
for (const [name, type] of entries) {
Expand All @@ -752,7 +806,8 @@ async function findRemoteCodexSessionFiles() {
uri,
mtimeMs: stat.mtime
});
} catch {
} catch (error) {
recordDiagnostic("remote-file-stat-failed", uri.toString(), String(error.code || error.name || "unknown"));
// File may be rotated between listing and stat.
}
}
Expand All @@ -768,7 +823,10 @@ async function findAllCodexSessionFiles() {
if (now - codexLogFilesCacheAt < 5000) return codexLogFilesCache;

const remoteRoots = getRemoteSessionRoots();
const candidates = remoteRoots.length > 0
// A remote workspace with an invalid/missing path must not silently monitor
// unrelated sessions on the local UI machine.
const isRemoteWorkspace = getRemoteSourceUris().length > 0 || Boolean(vscode.env.remoteName);
const candidates = remoteRoots.length > 0 || isRemoteWorkspace
? await findRemoteCodexSessionFiles()
: findLocalCodexSessionFiles();

Expand Down Expand Up @@ -801,7 +859,8 @@ async function getDetailedDiagnosticsSummary() {
.map((source) => ({
source: source.key,
reason: codexSessionInfo.get(source.key).reason
}))
})),
recentDiagnostics: [...diagnosticEvents.values()].map(({ loggedAt: _loggedAt, ...event }) => event)
};
} catch (error) {
return {
Expand Down Expand Up @@ -1063,7 +1122,7 @@ function startCodexLogWatcher() {
const workspaceMatch = isSessionInCurrentWorkspace(sessionInfo);
sessionSources.push({ source, stat, sessionInfo, workspaceMatch });
} catch (error) {
logDebug(`session read failed source=${source.key} error=${String(error)}`);
recordDiagnostic("session-read-failed", source.key, String(error.code || error.name || "unknown"));
}
}

Expand Down Expand Up @@ -1098,11 +1157,11 @@ function startCodexLogWatcher() {
codexLogOffsets.set(source.key, stat.size);
await processCodexSessionChunk(chunk, source, sessionInfo, stat.size, historyNotBeforeMs, workspaceMatch);
} catch (error) {
logDebug(`session read failed source=${source.key} error=${String(error)}`);
recordDiagnostic("session-read-failed", source.key, String(error.code || error.name || "unknown"));
}
}
} catch (error) {
logDebug(`session discovery failed error=${String(error)}`);
recordDiagnostic("session-discovery-failed", "sessions", String(error.code || error.name || "unknown"));
} finally {
codexLogPollBusy = false;
}
Expand Down Expand Up @@ -1424,7 +1483,7 @@ function activate(context) {
const summary = await getDetailedDiagnosticsSummary();
const out = getOutput();
out.appendLine("===== Diagnostics =====");
Object.entries(summary).forEach(([k, v]) => out.appendLine(`${k}: ${v}`));
Object.entries(summary).forEach(([k, v]) => out.appendLine(`${k}: ${JSON.stringify(v, null, 2)}`));
out.show(true);
vscode.window.showInformationMessage(
`Codex Notifier: sessions=${summary.trackedSessionFiles} completed=${summary.completedTurnIds} lastNotify=${summary.lastNotifyAt}`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@
"ci": "npm run check && npm run lint && npm test && npm run package:test",
"package:test": "node ./scripts/test-vsix.js",
"release:check": "npm run check && npm run lint && npm test && npm run package:test",
"test": "node ./scripts/test-session-events.js && node ./scripts/test-toast-focus.js"
"test": "node ./scripts/test-session-events.js && node ./scripts/test-toast-focus.js && node ./scripts/test-remote-diagnostics.js"
},
"devDependencies": {
"adm-zip": "^0.5.16",
Expand Down
96 changes: 96 additions & 0 deletions scripts/runtime-harness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"use strict";

const fs = require("fs");
const path = require("path");
const vm = require("vm");
const { createRequire } = require("module");
const root = path.resolve(__dirname, "..");

class Uri {
constructor(uriPath, authority = "ssh-remote+test", scheme = "vscode-remote") {
this.path = uriPath;
this.fsPath = uriPath;
this.authority = authority;
this.scheme = scheme;
}
with(values) { return Object.assign(new Uri(this.path, this.authority, this.scheme), values); }
toString() { return `${this.scheme}://${this.authority}${this.path}`; }
}

function createHarness({ platform = "win32", focused = false, settings = {}, locks = new Set(), remotePath } = {}) {
const config = { focusExistingWindowOnToast: false, enableSound: false, completionUseBanner: true, ...settings };
const lines = [];
const notifications = [];
const localReads = [];
const directories = new Map();
const files = new Map();
let notificationError;
const vscode = {
env: { remoteName: remotePath ? "ssh-remote" : undefined, sessionId: "test-window" },
FileType: { File: 1, Directory: 2 },
Uri: { joinPath: (uri, ...parts) => uri.with({ path: path.posix.join(uri.path, ...parts) }) },
workspace: {
workspaceFolders: remotePath ? [{ uri: new Uri(remotePath) }] : [],
textDocuments: [],
getConfiguration: () => ({ get: (key, fallback) => key in config ? config[key] : fallback }),
fs: {
readDirectory: async (uri) => {
const entries = directories.get(uri.path);
if (entries instanceof Error) throw entries;
return entries || [];
},
readFile: async (uri) => Buffer.from(files.get(uri.path) || ""),
stat: async (uri) => ({ size: Buffer.byteLength(files.get(uri.path) || ""), mtime: Date.now() })
}
},
window: {
state: { focused }, visibleTextEditors: [],
createOutputChannel: () => ({ appendLine: (line) => lines.push(line), show: () => {} }),
showInformationMessage: (message) => lines.push(`banner:${message}`)
}
};
const module = { exports: {} };
const localRequire = createRequire(path.join(root, "extension.js"));
const runtimeFs = {
openSync: (file, mode) => {
if (mode === "wx" && locks.has(file)) throw Object.assign(new Error("exists"), { code: "EEXIST" });
locks.add(file);
return 1;
},
writeFileSync: () => {}, closeSync: () => {},
statSync: () => ({ mtimeMs: Date.now() }), existsSync: () => false,
readdirSync: (dir) => { localReads.push(dir); return []; }
};
const source = fs.readFileSync(path.join(root, "extension.js"), "utf8");
const expose = `\nmodule.exports.testing = {
getRemoteSessionRoots, findAllCodexSessionFiles, getDetailedDiagnosticsSummary,
remoteDirectoriesAt, notify, showSystemNotification, recordDiagnostic,
classifySessionMetadata, processCodexSessionChunk, getDiagnosticsSummary
};`;
const context = {
require: (name) => {
if (name === "vscode") return vscode;
if (name === "fs") return runtimeFs;
if (name === "os") return { tmpdir: () => "/test-temp", homedir: () => "/test-home" };
if (name === "child_process") return {
execFile: (_command, args, options, callback) => {
const cb = typeof options === "function" ? options : callback;
if (args.join(" ").includes("ForegroundWindow")) cb(null, focused ? "321" : "999");
else { notifications.push(args); cb(notificationError); }
}
};
return localRequire(name);
},
module, exports: module.exports, Buffer, Date, console,
process: { platform, ppid: 321 }, __dirname: root,
setTimeout: (callback) => { callback(); return 1; }, clearTimeout: () => {},
setInterval: () => 1, clearInterval: () => {}
};
vm.runInNewContext(source + expose, context, { filename: "extension.js" });
return {
api: module.exports.testing, config, lines, notifications, localReads, directories, files,
setNotificationError: (error) => { notificationError = error; }
};
}

module.exports = { createHarness, Uri };
Loading