Skip to content
Draft
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
1 change: 1 addition & 0 deletions cli/episodic-memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const args = process.argv.slice(3);
function runScript(scriptPath, args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [scriptPath, ...args], {
windowsHide: true,
stdio: 'inherit'
});

Expand Down
1 change: 1 addition & 0 deletions cli/install-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export function runNpmInstall(pluginRoot, { spawn = defaultSpawn, lockHandle } =
const child = spawn(npmCommand, ['install', '--no-audit', '--no-fund'], {
cwd: pluginRoot,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
shell: isWindows, // On Windows, we need shell: true to find npm.cmd
detached: !isWindows,
});
Expand Down
1 change: 1 addition & 0 deletions cli/mcp-server-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async function main() {

// Use spawn with shell: false for better cross-platform compatibility
const child = spawn(process.execPath, [mcpServerPath], {
windowsHide: true,
stdio: 'inherit',
shell: false
});
Expand Down
9 changes: 9 additions & 0 deletions dist/codex-executable.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface CodexExecutable {
command: string;
identity: string;
}
/** Resolve native executables without a shell. Opaque scripts/wrappers fall
* back to uncached validation: their interpreter or delegated binary can
* change independently. The identity is only a cache key: subprocesses retain
* the caller's original command and argv[0], including native alias behavior. */
export declare function resolveCodexExecutable(command: string): CodexExecutable | undefined;
74 changes: 74 additions & 0 deletions dist/codex-executable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import fs from 'fs';
import path from 'path';
/** Resolve native executables without a shell. Opaque scripts/wrappers fall
* back to uncached validation: their interpreter or delegated binary can
* change independently. The identity is only a cache key: subprocesses retain
* the caller's original command and argv[0], including native alias behavior. */
export function resolveCodexExecutable(command) {
const windows = process.platform === 'win32';
const hasPath = path.isAbsolute(command) || command.includes('/') || (windows && command.includes('\\'));
if (!command || command === '.')
return undefined;
// Drive-relative paths, quoted PATH syntax and the Windows cwd-search
// policy are deliberately left uncached rather than approximating lookup.
if (windows && !path.isAbsolute(command) && command.includes(':'))
return undefined;
const pathKey = Object.keys(process.env).sort().find(key => windows ? key.toLowerCase() === 'path' : key === 'PATH');
const searchPath = pathKey ? process.env[pathKey] : undefined;
// A missing PATH has platform-specific defaults. Leave that case to spawn.
if (!hasPath && searchPath === undefined)
return undefined;
if (windows && !hasPath && (/["']/.test(searchPath) || Object.keys(process.env).some(key => key.toLowerCase() === 'nodefaultcurrentdirectoryinexepath')))
return undefined;
const name = path.basename(command);
const dot = name.indexOf('.');
const nameHasExtension = dot >= 0 && dot < name.length - 1;
// libuv's exact-name flag for path-qualified extensionless executables has
// varied across runtimes; do not cache that ambiguous case.
if (windows && hasPath && !nameHasExtension)
return undefined;
const directories = hasPath ? [''] : [
...(windows ? [process.cwd()] : []),
...searchPath.split(path.delimiter).filter(dir => !windows || dir.length > 0),
];
for (const directory of directories) {
const candidate = path.resolve(directory, command);
// libuv's shell-free Windows lookup considers .com/.exe, not PATHEXT.
const suffixBase = candidate.endsWith('.') ? candidate : `${candidate}.`;
const candidates = windows
? [...(nameHasExtension ? [candidate] : []), `${suffixBase}com`, `${suffixBase}exe`]
: [candidate];
for (const file of candidates) {
try {
const resolved = fs.realpathSync(file);
const stat = fs.statSync(resolved, { bigint: true });
if (!stat.isFile())
continue;
fs.accessSync(resolved, windows ? fs.constants.F_OK : fs.constants.X_OK);
// Only native executables have an identity bounded by this file.
const fd = fs.openSync(resolved, 'r');
const magic = Buffer.alloc(4);
try {
fs.readSync(fd, magic, 0, 4, 0);
}
finally {
fs.closeSync(fd);
}
const native = windows ? magic.subarray(0, 2).toString() === 'MZ'
: ['7f454c46', 'feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca'].includes(magic.toString('hex'));
if (!native)
return undefined;
return {
command: resolved,
identity: [resolved, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join('|'),
};
}
catch (error) {
// Unknown resolution/permissions must never reuse a successful check.
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')
return undefined;
}
}
}
return undefined;
}
8 changes: 2 additions & 6 deletions dist/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1037,12 +1037,8 @@ async function parseCursorConversation(filePath, projectName, archivePath) {
*/
export async function parseConversationFile(filePath) {
// Extract project name from path (directory name before the .jsonl file)
const pathParts = filePath.split('/');
let project = 'unknown';
// Find the parent directory name (second to last part)
if (pathParts.length >= 2) {
project = pathParts[pathParts.length - 2];
}
const parent = path.parse(filePath).dir;
const project = parent ? path.basename(parent) || 'unknown' : 'unknown';
const exchanges = await parseConversation(filePath, project, filePath);
return {
project,
Expand Down
56 changes: 51 additions & 5 deletions dist/summarizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SUMMARIZER_CONTEXT_MARKER } from './constants.js';
import { VERSION } from './version.js';
import { spawn } from 'child_process';
import { createInterface } from 'readline';
import { resolveCodexExecutable } from './codex-executable.js';
import { codexVersionRequirementMessage, parseCodexCliVersion, versionMeetsMinimum, } from './codex-support.js';
/** Max chars of SDK `result` text kept on SummarizerSdkError (see #138). */
const SDK_ERROR_DETAIL_MAX = 300;
Expand Down Expand Up @@ -394,6 +395,7 @@ function appServerTimeoutMs() {
function readCommandOutput(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
windowsHide: true,
env: getApiEnv(),
stdio: ['ignore', 'pipe', 'pipe']
});
Expand All @@ -415,14 +417,57 @@ function readCommandOutput(command, args) {
});
});
}
async function assertSupportedCodexVersion(command) {
// Worker-local only: share in-flight checks, retain successful checks, and retry
// every failure. Bound entries so callers with custom version arguments cannot
// grow a long-lived worker indefinitely.
const supportedCodexVersions = new Map();
const MAX_CODEX_VERSION_ENTRIES = 16;
class CodexExecutableChangedError extends Error {
}
async function assertSupportedCodexVersion(command, retryIdentityChange = true) {
if (command.skipVersionCheck) {
return;
}
const output = await readCommandOutput(command.command, command.versionArgs || ['--version']);
const version = parseCodexCliVersion(output);
if (!version || !versionMeetsMinimum(version)) {
throw new Error(codexVersionRequirementMessage(output));
const executable = resolveCodexExecutable(command.command);
const versionArgs = command.versionArgs || ['--version'];
const validate = async () => {
const output = await readCommandOutput(command.command, versionArgs);
const version = parseCodexCliVersion(output);
if (!version || !versionMeetsMinimum(version)) {
throw new Error(codexVersionRequirementMessage(output));
}
if (executable && resolveCodexExecutable(command.command)?.identity !== executable.identity) {
throw new CodexExecutableChangedError('Codex executable changed during compatibility validation; retry.');
}
};
if (!executable) {
await validate();
return;
}
const key = JSON.stringify([command.command, executable.command, versionArgs]);
let entry = supportedCodexVersions.get(key);
if (!entry || entry.identity !== executable.identity) {
entry = { identity: executable.identity, check: validate() };
supportedCodexVersions.delete(key);
supportedCodexVersions.set(key, entry);
if (supportedCodexVersions.size > MAX_CODEX_VERSION_ENTRIES) {
supportedCodexVersions.delete(supportedCodexVersions.keys().next().value);
}
}
try {
await entry.check;
}
catch (error) {
// An older failed check must not evict a replacement executable's entry.
if (supportedCodexVersions.get(key) === entry)
supportedCodexVersions.delete(key);
// Native installers/Windows can update metadata on first execution. Repeat
// the full check once; concurrent waiters join the same replacement entry.
// Repeated instability and ordinary version/launch failures still reject.
if (retryIdentityChange && error instanceof CodexExecutableChangedError) {
return assertSupportedCodexVersion(command, false);
}
throw error;
}
}
function requireThreadId(result, method) {
Expand All @@ -443,6 +488,7 @@ export async function runCodexCommand(command) {
await assertSupportedCodexVersion(command);
return new Promise((resolve, reject) => {
const child = spawn(command.command, command.args, {
windowsHide: true,
env: getApiEnv(),
stdio: ['pipe', 'pipe', 'pipe']
});
Expand Down
1 change: 1 addition & 0 deletions dist/sync-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ if (isBackground) {
process.argv[1], // This script
...filteredArgs
], {
windowsHide: true,
detached: true,
stdio: ['ignore', logFd, logFd]
});
Expand Down
66 changes: 66 additions & 0 deletions src/codex-executable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import fs from 'fs';
import path from 'path';

export interface CodexExecutable {
command: string;
identity: string;
}

/** Resolve native executables without a shell. Opaque scripts/wrappers fall
* back to uncached validation: their interpreter or delegated binary can
* change independently. The identity is only a cache key: subprocesses retain
* the caller's original command and argv[0], including native alias behavior. */
export function resolveCodexExecutable(command: string): CodexExecutable | undefined {
const windows = process.platform === 'win32';
const hasPath = path.isAbsolute(command) || command.includes('/') || (windows && command.includes('\\'));
if (!command || command === '.') return undefined;
// Drive-relative paths, quoted PATH syntax and the Windows cwd-search
// policy are deliberately left uncached rather than approximating lookup.
if (windows && !path.isAbsolute(command) && command.includes(':')) return undefined;
const pathKey = Object.keys(process.env).sort().find(key => windows ? key.toLowerCase() === 'path' : key === 'PATH');
const searchPath = pathKey ? process.env[pathKey] : undefined;
// A missing PATH has platform-specific defaults. Leave that case to spawn.
if (!hasPath && searchPath === undefined) return undefined;
if (windows && !hasPath && (/["']/.test(searchPath!) || Object.keys(process.env).some(key => key.toLowerCase() === 'nodefaultcurrentdirectoryinexepath'))) return undefined;
const name = path.basename(command);
const dot = name.indexOf('.');
const nameHasExtension = dot >= 0 && dot < name.length - 1;
// libuv's exact-name flag for path-qualified extensionless executables has
// varied across runtimes; do not cache that ambiguous case.
if (windows && hasPath && !nameHasExtension) return undefined;
const directories = hasPath ? [''] : [
...(windows ? [process.cwd()] : []),
...searchPath!.split(path.delimiter).filter(dir => !windows || dir.length > 0),
];
for (const directory of directories) {
const candidate = path.resolve(directory, command);
// libuv's shell-free Windows lookup considers .com/.exe, not PATHEXT.
const suffixBase = candidate.endsWith('.') ? candidate : `${candidate}.`;
const candidates = windows
? [...(nameHasExtension ? [candidate] : []), `${suffixBase}com`, `${suffixBase}exe`]
: [candidate];
for (const file of candidates) {
try {
const resolved = fs.realpathSync(file);
const stat = fs.statSync(resolved, { bigint: true });
if (!stat.isFile()) continue;
fs.accessSync(resolved, windows ? fs.constants.F_OK : fs.constants.X_OK);
// Only native executables have an identity bounded by this file.
const fd = fs.openSync(resolved, 'r');
const magic = Buffer.alloc(4);
try { fs.readSync(fd, magic, 0, 4, 0); } finally { fs.closeSync(fd); }
const native = windows ? magic.subarray(0, 2).toString() === 'MZ'
: ['7f454c46', 'feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca'].includes(magic.toString('hex'));
if (!native) return undefined;
return {
command: resolved,
identity: [resolved, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join('|'),
};
} catch (error: any) {
// Unknown resolution/permissions must never reuse a successful check.
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return undefined;
}
}
}
return undefined;
}
9 changes: 2 additions & 7 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1262,13 +1262,8 @@ export async function parseConversationFile(filePath: string): Promise<{
exchanges: ConversationExchange[];
}> {
// Extract project name from path (directory name before the .jsonl file)
const pathParts = filePath.split('/');
let project = 'unknown';

// Find the parent directory name (second to last part)
if (pathParts.length >= 2) {
project = pathParts[pathParts.length - 2];
}
const parent = path.parse(filePath).dir;
const project = parent ? path.basename(parent) || 'unknown' : 'unknown';

const exchanges = await parseConversation(filePath, project, filePath);

Expand Down
55 changes: 50 additions & 5 deletions src/summarizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SUMMARIZER_CONTEXT_MARKER } from './constants.js';
import { VERSION } from './version.js';
import { spawn } from 'child_process';
import { createInterface } from 'readline';
import { resolveCodexExecutable } from './codex-executable.js';
import {
codexVersionRequirementMessage,
parseCodexCliVersion,
Expand Down Expand Up @@ -455,6 +456,7 @@ function appServerTimeoutMs(): number {
function readCommandOutput(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
windowsHide: true,
env: getApiEnv(),
stdio: ['ignore', 'pipe', 'pipe']
});
Expand All @@ -477,15 +479,57 @@ function readCommandOutput(command: string, args: string[]): Promise<string> {
});
}

async function assertSupportedCodexVersion(command: CodexSummarizerCommand): Promise<void> {
// Worker-local only: share in-flight checks, retain successful checks, and retry
// every failure. Bound entries so callers with custom version arguments cannot
// grow a long-lived worker indefinitely.
const supportedCodexVersions = new Map<string, { identity: string; check: Promise<void> }>();
const MAX_CODEX_VERSION_ENTRIES = 16;
class CodexExecutableChangedError extends Error {}

async function assertSupportedCodexVersion(command: CodexSummarizerCommand, retryIdentityChange = true): Promise<void> {
if (command.skipVersionCheck) {
return;
}

const output = await readCommandOutput(command.command, command.versionArgs || ['--version']);
const version = parseCodexCliVersion(output);
if (!version || !versionMeetsMinimum(version)) {
throw new Error(codexVersionRequirementMessage(output));
const executable = resolveCodexExecutable(command.command);
const versionArgs = command.versionArgs || ['--version'];
const validate = async () => {
const output = await readCommandOutput(command.command, versionArgs);
const version = parseCodexCliVersion(output);
if (!version || !versionMeetsMinimum(version)) {
throw new Error(codexVersionRequirementMessage(output));
}
if (executable && resolveCodexExecutable(command.command)?.identity !== executable.identity) {
throw new CodexExecutableChangedError('Codex executable changed during compatibility validation; retry.');
}
};
if (!executable) {
await validate();
return;
}

const key = JSON.stringify([command.command, executable.command, versionArgs]);
let entry = supportedCodexVersions.get(key);
if (!entry || entry.identity !== executable.identity) {
entry = { identity: executable.identity, check: validate() };
supportedCodexVersions.delete(key);
supportedCodexVersions.set(key, entry);
if (supportedCodexVersions.size > MAX_CODEX_VERSION_ENTRIES) {
supportedCodexVersions.delete(supportedCodexVersions.keys().next().value!);
}
}
try {
await entry.check;
} catch (error) {
// An older failed check must not evict a replacement executable's entry.
if (supportedCodexVersions.get(key) === entry) supportedCodexVersions.delete(key);
// Native installers/Windows can update metadata on first execution. Repeat
// the full check once; concurrent waiters join the same replacement entry.
// Repeated instability and ordinary version/launch failures still reject.
if (retryIdentityChange && error instanceof CodexExecutableChangedError) {
return assertSupportedCodexVersion(command, false);
}
throw error;
}
}

Expand All @@ -510,6 +554,7 @@ export async function runCodexCommand(command: CodexSummarizerCommand): Promise<

return new Promise((resolve, reject) => {
const child = spawn(command.command, command.args, {
windowsHide: true,
env: getApiEnv(),
stdio: ['pipe', 'pipe', 'pipe']
});
Expand Down
1 change: 1 addition & 0 deletions src/sync-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ if (isBackground) {
process.argv[1], // This script
...filteredArgs
], {
windowsHide: true,
detached: true,
stdio: ['ignore', logFd, logFd]
});
Expand Down
Loading