fix: stop codex transcript replay after hooks migration (#2365)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { execFileSync, spawnSync } from 'child_process';
|
import { execFileSync, spawnSync } from 'child_process';
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { paths } from '../../shared/paths.js';
|
import { paths } from '../../shared/paths.js';
|
||||||
@@ -9,7 +9,10 @@ import { paths } from '../../shared/paths.js';
|
|||||||
const CODEX_DIR = path.join(homedir(), '.codex');
|
const CODEX_DIR = path.join(homedir(), '.codex');
|
||||||
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
|
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
|
||||||
const CODEX_TRANSCRIPT_WATCH_CONFIG_PATH = paths.transcriptsConfig();
|
const CODEX_TRANSCRIPT_WATCH_CONFIG_PATH = paths.transcriptsConfig();
|
||||||
|
const CODEX_CONFIG_PATH = path.join(CODEX_DIR, 'config.toml');
|
||||||
const MARKETPLACE_NAME = 'claude-mem-local';
|
const MARKETPLACE_NAME = 'claude-mem-local';
|
||||||
|
const CODEX_PLUGIN_ID = `claude-mem@${MARKETPLACE_NAME}`;
|
||||||
|
const LEGACY_CODEX_PLUGIN_IDS = ['claude-mem@thedotmack'];
|
||||||
const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0';
|
const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0';
|
||||||
const REQUIRED_MARKETPLACE_FILES = [
|
const REQUIRED_MARKETPLACE_FILES = [
|
||||||
path.join('.agents', 'plugins', 'marketplace.json'),
|
path.join('.agents', 'plugins', 'marketplace.json'),
|
||||||
@@ -131,6 +134,74 @@ function registerCodexMarketplace(marketplaceRoot: string): void {
|
|||||||
runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]);
|
runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setTomlBooleanInTable(content: string, header: string, key: string, enabled: boolean): string {
|
||||||
|
const booleanLine = `${key} = ${enabled ? 'true' : 'false'}`;
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const headerIndex = lines.findIndex((line) => line.trim() === header);
|
||||||
|
|
||||||
|
if (headerIndex === -1) {
|
||||||
|
const trimmed = content.trimEnd();
|
||||||
|
return `${trimmed}${trimmed ? '\n\n' : ''}${header}\n${booleanLine}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sectionEnd = headerIndex + 1;
|
||||||
|
while (sectionEnd < lines.length && !/^\s*\[/.test(lines[sectionEnd])) {
|
||||||
|
sectionEnd += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const keyPattern = new RegExp(`^\\s*${escapedKey}\\s*=`);
|
||||||
|
const keyIndex = lines.findIndex(
|
||||||
|
(line, index) => index > headerIndex && index < sectionEnd && keyPattern.test(line),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keyIndex === -1) {
|
||||||
|
lines.splice(headerIndex + 1, 0, booleanLine);
|
||||||
|
} else {
|
||||||
|
lines[keyIndex] = booleanLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTomlPluginEnabled(content: string, pluginId: string, enabled: boolean): string {
|
||||||
|
const escapedPluginId = pluginId.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
return setTomlBooleanInTable(content, `[plugins."${escapedPluginId}"]`, 'enabled', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTomlFeatureEnabled(content: string, featureName: string, enabled: boolean): string {
|
||||||
|
return setTomlBooleanInTable(content, '[features]', featureName, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexPluginConfig(enabled: boolean): boolean {
|
||||||
|
if (!enabled && !existsSync(CODEX_CONFIG_PATH)) return false;
|
||||||
|
mkdirSync(CODEX_DIR, { recursive: true });
|
||||||
|
const current = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, 'utf-8') : '';
|
||||||
|
let next = current;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
next = setTomlFeatureEnabled(next, 'hooks', true);
|
||||||
|
}
|
||||||
|
for (const legacyPluginId of LEGACY_CODEX_PLUGIN_IDS) {
|
||||||
|
next = setTomlPluginEnabled(next, legacyPluginId, false);
|
||||||
|
}
|
||||||
|
next = setTomlPluginEnabled(next, CODEX_PLUGIN_ID, enabled);
|
||||||
|
|
||||||
|
if (next === current) return false;
|
||||||
|
writeFileSync(CODEX_CONFIG_PATH, next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableCodexPluginConfig(): void {
|
||||||
|
const changed = writeCodexPluginConfig(true);
|
||||||
|
console.log(` Enabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already enabled)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableCodexPluginConfig(): void {
|
||||||
|
const changed = writeCodexPluginConfig(false);
|
||||||
|
console.log(` Disabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already disabled)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
function parseSemver(value: string): [number, number, number] | null {
|
function parseSemver(value: string): [number, number, number] | null {
|
||||||
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
|
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
@@ -284,16 +355,12 @@ export async function installCodexCli(marketplaceRootOverride?: string): Promise
|
|||||||
|
|
||||||
console.log(` Registering Codex plugin marketplace: ${marketplaceRoot}`);
|
console.log(` Registering Codex plugin marketplace: ${marketplaceRoot}`);
|
||||||
registerCodexMarketplace(marketplaceRoot);
|
registerCodexMarketplace(marketplaceRoot);
|
||||||
|
enableCodexPluginConfig();
|
||||||
runCodexBestEffort(
|
runCodexBestEffort(
|
||||||
['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME],
|
['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME],
|
||||||
'Refreshed Codex marketplace and installed plugin cache.',
|
'Refreshed Codex marketplace and installed plugin cache.',
|
||||||
'Could not refresh Codex marketplace cache; reinstall or upgrade claude-mem from /plugins if Codex still uses old MCP config',
|
'Could not refresh Codex marketplace cache; reinstall or upgrade claude-mem from /plugins if Codex still uses old MCP config',
|
||||||
);
|
);
|
||||||
runCodexBestEffort(
|
|
||||||
['features', 'enable', 'plugin_hooks'],
|
|
||||||
'Enabled Codex plugin_hooks so claude-mem hooks can run.',
|
|
||||||
'Could not enable Codex plugin_hooks; run `codex features enable plugin_hooks` if context hooks do not appear',
|
|
||||||
);
|
|
||||||
if (!cleanupLegacyCodexAgentsMdContext()) {
|
if (!cleanupLegacyCodexAgentsMdContext()) {
|
||||||
console.warn(` Native Codex hooks registered, but failed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`);
|
console.warn(` Native Codex hooks registered, but failed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`);
|
||||||
}
|
}
|
||||||
@@ -309,9 +376,7 @@ Plugin source: ${marketplaceRoot}
|
|||||||
|
|
||||||
Next steps:
|
Next steps:
|
||||||
1. Open Codex CLI in your project
|
1. Open Codex CLI in your project
|
||||||
2. Run /plugins
|
2. Restart any running Codex sessions so native hooks are loaded
|
||||||
3. Install claude-mem from the claude-mem (local) marketplace
|
|
||||||
4. Restart Codex CLI after install so MCP tools and plugin hooks reload
|
|
||||||
|
|
||||||
For a fresh setup, the supported entry point is:
|
For a fresh setup, the supported entry point is:
|
||||||
npx claude-mem@latest install
|
npx claude-mem@latest install
|
||||||
@@ -329,6 +394,14 @@ export function uninstallCodexCli(): number {
|
|||||||
|
|
||||||
let failed = false;
|
let failed = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
disableCodexPluginConfig();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`\nCodex plugin config update failed: ${message}`);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (commandExists('codex')) {
|
if (commandExists('codex')) {
|
||||||
runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
|
runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import type { TranscriptSchema, TranscriptWatchConfig } from './types.js';
|
|||||||
export const DEFAULT_CONFIG_PATH = paths.transcriptsConfig();
|
export const DEFAULT_CONFIG_PATH = paths.transcriptsConfig();
|
||||||
export const DEFAULT_STATE_PATH = paths.transcriptsState();
|
export const DEFAULT_STATE_PATH = paths.transcriptsState();
|
||||||
|
|
||||||
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
export const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
||||||
name: 'codex',
|
name: 'codex',
|
||||||
version: '0.3',
|
version: '0.3',
|
||||||
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
description: 'Legacy schema for Codex session JSONL files. Codex native hooks are preferred.',
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
name: 'session-meta',
|
name: 'session-meta',
|
||||||
@@ -109,20 +109,39 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
|
|
||||||
export const SAMPLE_CONFIG: TranscriptWatchConfig = {
|
export const SAMPLE_CONFIG: TranscriptWatchConfig = {
|
||||||
version: 1,
|
version: 1,
|
||||||
schemas: {
|
schemas: {},
|
||||||
codex: CODEX_SAMPLE_SCHEMA
|
watches: [],
|
||||||
},
|
|
||||||
watches: [
|
|
||||||
{
|
|
||||||
name: 'codex',
|
|
||||||
path: '~/.codex/sessions/**/*.jsonl',
|
|
||||||
schema: 'codex',
|
|
||||||
startAtEnd: true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
stateFile: DEFAULT_STATE_PATH
|
stateFile: DEFAULT_STATE_PATH
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isNativeHookBackedCodexWatch(watch: { name?: string; path?: string; schema?: string | TranscriptSchema }): boolean {
|
||||||
|
const schemaName = typeof watch.schema === 'string' ? watch.schema : watch.schema?.name;
|
||||||
|
const nameOrSchemaIsCodex = watch.name === 'codex' || schemaName === 'codex';
|
||||||
|
if (!nameOrSchemaIsCodex || !watch.path) return false;
|
||||||
|
|
||||||
|
const normalizedPath = expandHomePath(watch.path).replace(/\\/g, '/');
|
||||||
|
const codexSessionsRoot = join(homedir(), '.codex', 'sessions').replace(/\\/g, '/');
|
||||||
|
return normalizedPath === `${codexSessionsRoot}/**/*.jsonl`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterNativeHookBackedCodexWatches(
|
||||||
|
config: TranscriptWatchConfig,
|
||||||
|
allowCodexTranscriptIngestion: boolean
|
||||||
|
): { config: TranscriptWatchConfig; removed: number } {
|
||||||
|
if (allowCodexTranscriptIngestion) {
|
||||||
|
return { config, removed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const watches = config.watches.filter(watch => !isNativeHookBackedCodexWatch(watch));
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
watches,
|
||||||
|
},
|
||||||
|
removed: config.watches.length - watches.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function expandHomePath(inputPath: string): string {
|
export function expandHomePath(inputPath: string): string {
|
||||||
if (!inputPath) return inputPath;
|
if (!inputPath) return inputPath;
|
||||||
if (inputPath.startsWith('~')) {
|
if (inputPath.startsWith('~')) {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class TranscriptWatcher {
|
|||||||
const files = this.resolveWatchFiles(resolvedPath);
|
const files = this.resolveWatchFiles(resolvedPath);
|
||||||
|
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
await this.addTailer(filePath, watch, schema, true);
|
await this.addTailer(filePath, watch, schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
const watchRoot = this.deepestNonGlobAncestor(resolvedPath);
|
const watchRoot = this.deepestNonGlobAncestor(resolvedPath);
|
||||||
@@ -143,7 +143,7 @@ export class TranscriptWatcher {
|
|||||||
const matches = this.resolveWatchFiles(resolvedPath);
|
const matches = this.resolveWatchFiles(resolvedPath);
|
||||||
for (const filePath of matches) {
|
for (const filePath of matches) {
|
||||||
if (!this.tailers.has(filePath)) {
|
if (!this.tailers.has(filePath)) {
|
||||||
void this.addTailer(filePath, watch, schema, false);
|
void this.addTailer(filePath, watch, schema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -223,15 +223,14 @@ export class TranscriptWatcher {
|
|||||||
private async addTailer(
|
private async addTailer(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
watch: WatchTarget,
|
watch: WatchTarget,
|
||||||
schema: TranscriptSchema,
|
schema: TranscriptSchema
|
||||||
initialDiscovery: boolean
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.tailers.has(filePath)) return;
|
if (this.tailers.has(filePath)) return;
|
||||||
|
|
||||||
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
|
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
|
||||||
|
|
||||||
let offset = this.state.offsets[filePath] ?? 0;
|
let offset = this.state.offsets[filePath] ?? 0;
|
||||||
if (offset === 0 && watch.startAtEnd && initialDiscovery) {
|
if (offset === 0 && watch.startAtEnd) {
|
||||||
try {
|
try {
|
||||||
offset = statSync(filePath).size;
|
offset = statSync(filePath).size;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import { TimelineService } from './worker/TimelineService.js';
|
|||||||
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
|
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
|
||||||
import { SessionCompletionHandler } from './worker/session/SessionCompletionHandler.js';
|
import { SessionCompletionHandler } from './worker/session/SessionCompletionHandler.js';
|
||||||
import { setIngestContext, attachIngestGeneratorStarter } from './worker/http/shared.js';
|
import { setIngestContext, attachIngestGeneratorStarter } from './worker/http/shared.js';
|
||||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig } from './transcripts/config.js';
|
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, filterNativeHookBackedCodexWatches, loadTranscriptWatchConfig } from './transcripts/config.js';
|
||||||
import { TranscriptWatcher } from './transcripts/watcher.js';
|
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||||
|
|
||||||
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
|
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
|
||||||
@@ -471,9 +471,27 @@ export class WorkerService implements WorkerRef {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transcriptConfig = loadTranscriptWatchConfig(configPath);
|
const allowCodexTranscriptIngestion = settings.CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION === 'true';
|
||||||
|
const { config: transcriptConfig, removed } = filterNativeHookBackedCodexWatches(
|
||||||
|
loadTranscriptWatchConfig(configPath),
|
||||||
|
allowCodexTranscriptIngestion
|
||||||
|
);
|
||||||
const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH);
|
const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH);
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
logger.warn('TRANSCRIPT', 'Skipped Codex transcript watch because native Codex hooks are authoritative', {
|
||||||
|
removed,
|
||||||
|
optInSetting: 'CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION=true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transcriptConfig.watches.length === 0) {
|
||||||
|
logger.info('TRANSCRIPT', 'Transcript watcher config has no active watches; skipping automatic transcript capture', {
|
||||||
|
configPath: resolvedConfigPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath);
|
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath);
|
||||||
await this.transcriptWatcher.start();
|
await this.transcriptWatcher.start();
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface SettingsDefaults {
|
|||||||
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string;
|
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string;
|
||||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: string;
|
CLAUDE_MEM_TRANSCRIPTS_ENABLED: string;
|
||||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string;
|
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string;
|
||||||
|
CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION: string;
|
||||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string;
|
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string;
|
||||||
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: string;
|
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: string;
|
||||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string;
|
CLAUDE_MEM_EXCLUDED_PROJECTS: string;
|
||||||
@@ -117,6 +118,7 @@ export class SettingsDefaultsManager {
|
|||||||
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false', // When true, writes to CLAUDE.local.md instead of CLAUDE.md
|
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false', // When true, writes to CLAUDE.local.md instead of CLAUDE.md
|
||||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: 'true',
|
CLAUDE_MEM_TRANSCRIPTS_ENABLED: 'true',
|
||||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'),
|
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'),
|
||||||
|
CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION: 'false',
|
||||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
|
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
|
||||||
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: '3', // Plan 05 Phase 8 — escalate to exit code 2 after N consecutive worker-unreachable hook invocations
|
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: '3', // Plan 05 Phase 8 — escalate to exit code 2 after N consecutive worker-unreachable hook invocations
|
||||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
||||||
|
|||||||
@@ -151,13 +151,15 @@ describe('Install Non-TTY Support', () => {
|
|||||||
expect(registerRegion).toContain("['plugin', 'marketplace', 'add', marketplaceRoot]");
|
expect(registerRegion).toContain("['plugin', 'marketplace', 'add', marketplaceRoot]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables Codex plugin hooks during install', () => {
|
it('enables Codex hooks and claude-mem plugin config during install', () => {
|
||||||
const installRegion = codexInstallerSource.slice(
|
const installRegion = codexInstallerSource.slice(
|
||||||
codexInstallerSource.indexOf('export async function installCodexCli'),
|
codexInstallerSource.indexOf('export async function installCodexCli'),
|
||||||
codexInstallerSource.indexOf('export function uninstallCodexCli'),
|
codexInstallerSource.indexOf('export function uninstallCodexCli'),
|
||||||
);
|
);
|
||||||
expect(installRegion).toContain("['features', 'enable', 'plugin_hooks']");
|
expect(codexInstallerSource).toContain("setTomlFeatureEnabled(next, 'hooks', true)");
|
||||||
expect(installRegion).toContain('codex features enable plugin_hooks');
|
expect(codexInstallerSource).toContain("const CODEX_PLUGIN_ID = `claude-mem@${MARKETPLACE_NAME}`");
|
||||||
|
expect(installRegion).toContain('enableCodexPluginConfig()');
|
||||||
|
expect(installRegion).not.toContain('plugin_hooks');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('captures Codex CLI output for install failure reporting', () => {
|
it('captures Codex CLI output for install failure reporting', () => {
|
||||||
@@ -211,13 +213,14 @@ describe('Install Non-TTY Support', () => {
|
|||||||
|
|
||||||
it('does not seed new Codex transcript watcher configs with AGENTS context injection', () => {
|
it('does not seed new Codex transcript watcher configs with AGENTS context injection', () => {
|
||||||
expect(transcriptConfigSource).toContain("name: 'codex'");
|
expect(transcriptConfigSource).toContain("name: 'codex'");
|
||||||
const codexWatchRegion = transcriptConfigSource.slice(
|
const sampleConfigRegion = transcriptConfigSource.slice(
|
||||||
transcriptConfigSource.indexOf("name: 'codex'"),
|
transcriptConfigSource.indexOf('export const SAMPLE_CONFIG'),
|
||||||
transcriptConfigSource.indexOf('stateFile: DEFAULT_STATE_PATH'),
|
transcriptConfigSource.indexOf('stateFile: DEFAULT_STATE_PATH'),
|
||||||
);
|
);
|
||||||
expect(codexWatchRegion).toContain("path: '~/.codex/sessions/**/*.jsonl'");
|
expect(sampleConfigRegion).toContain('watches: []');
|
||||||
expect(codexWatchRegion).not.toContain("mode: 'agents'");
|
expect(sampleConfigRegion).not.toContain("path: '~/.codex/sessions/**/*.jsonl'");
|
||||||
expect(codexWatchRegion).not.toContain('updateOn');
|
expect(sampleConfigRegion).not.toContain("mode: 'agents'");
|
||||||
|
expect(sampleConfigRegion).not.toContain('updateOn');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
setTomlFeatureEnabled,
|
||||||
|
setTomlPluginEnabled,
|
||||||
|
} from '../../src/services/integrations/CodexCliInstaller.js';
|
||||||
|
|
||||||
|
describe('Codex CLI installer config repair', () => {
|
||||||
|
it('adds claude-mem plugin enablement when missing', () => {
|
||||||
|
const result = setTomlPluginEnabled('model = "gpt-5.5"\n', 'claude-mem@claude-mem-local', true);
|
||||||
|
|
||||||
|
expect(result).toContain('[plugins."claude-mem@claude-mem-local"]');
|
||||||
|
expect(result).toContain('enabled = true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing plugin enablement in place', () => {
|
||||||
|
const input = [
|
||||||
|
'[plugins."claude-mem@thedotmack"]',
|
||||||
|
'enabled = true',
|
||||||
|
'',
|
||||||
|
'[marketplaces.claude-mem-local]',
|
||||||
|
'source_type = "git"',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = setTomlPluginEnabled(input, 'claude-mem@thedotmack', false);
|
||||||
|
|
||||||
|
expect(result).toContain('[plugins."claude-mem@thedotmack"]\nenabled = false');
|
||||||
|
expect(result).toContain('[marketplaces.claude-mem-local]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts enabled into an existing plugin section without touching the next section', () => {
|
||||||
|
const input = [
|
||||||
|
'[plugins."claude-mem@claude-mem-local"]',
|
||||||
|
'',
|
||||||
|
'[hooks.state]',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = setTomlPluginEnabled(input, 'claude-mem@claude-mem-local', true);
|
||||||
|
|
||||||
|
expect(result).toContain('[plugins."claude-mem@claude-mem-local"]\nenabled = true\n');
|
||||||
|
expect(result).toContain('[hooks.state]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables the current Codex hooks feature flag', () => {
|
||||||
|
const input = [
|
||||||
|
'[features]',
|
||||||
|
'shell_snapshot = true',
|
||||||
|
'',
|
||||||
|
'[plugins."claude-mem@claude-mem-local"]',
|
||||||
|
'enabled = true',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = setTomlFeatureEnabled(input, 'hooks', true);
|
||||||
|
|
||||||
|
expect(result).toContain('[features]\nhooks = true\nshell_snapshot = true');
|
||||||
|
expect(result).toContain('[plugins."claude-mem@claude-mem-local"]');
|
||||||
|
expect(result).not.toContain('codex_hooks');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import {
|
||||||
|
CODEX_SAMPLE_SCHEMA,
|
||||||
|
SAMPLE_CONFIG,
|
||||||
|
filterNativeHookBackedCodexWatches,
|
||||||
|
isNativeHookBackedCodexWatch,
|
||||||
|
} from '../../src/services/transcripts/config.js';
|
||||||
|
import type { TranscriptWatchConfig } from '../../src/services/transcripts/types.js';
|
||||||
|
|
||||||
|
describe('transcript watcher config', () => {
|
||||||
|
it('does not auto-watch Codex transcripts in the sample config', () => {
|
||||||
|
expect(SAMPLE_CONFIG.watches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes the legacy Codex session transcript watch', () => {
|
||||||
|
expect(isNativeHookBackedCodexWatch({
|
||||||
|
name: 'codex',
|
||||||
|
path: '~/.codex/sessions/**/*.jsonl',
|
||||||
|
schema: 'codex',
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
expect(isNativeHookBackedCodexWatch({
|
||||||
|
name: 'codex',
|
||||||
|
path: join(homedir(), '.codex', 'sessions', '**', '*.jsonl'),
|
||||||
|
schema: CODEX_SAMPLE_SCHEMA,
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat custom transcript watches as native Codex hooks', () => {
|
||||||
|
expect(isNativeHookBackedCodexWatch({
|
||||||
|
name: 'codex-archive',
|
||||||
|
path: '~/custom-codex-export/**/*.jsonl',
|
||||||
|
schema: 'codex',
|
||||||
|
})).toBe(false);
|
||||||
|
|
||||||
|
expect(isNativeHookBackedCodexWatch({
|
||||||
|
name: 'other',
|
||||||
|
path: '~/.codex/sessions/**/*.jsonl',
|
||||||
|
schema: 'other',
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips legacy Codex watches unless explicitly opted in', () => {
|
||||||
|
const config: TranscriptWatchConfig = {
|
||||||
|
version: 1,
|
||||||
|
schemas: {
|
||||||
|
codex: CODEX_SAMPLE_SCHEMA,
|
||||||
|
},
|
||||||
|
watches: [
|
||||||
|
{
|
||||||
|
name: 'codex',
|
||||||
|
path: '~/.codex/sessions/**/*.jsonl',
|
||||||
|
schema: 'codex',
|
||||||
|
startAtEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'custom',
|
||||||
|
path: '~/custom/**/*.jsonl',
|
||||||
|
schema: 'codex',
|
||||||
|
startAtEnd: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = filterNativeHookBackedCodexWatches(config, false);
|
||||||
|
expect(filtered.removed).toBe(1);
|
||||||
|
expect(filtered.config.watches.map(watch => watch.name)).toEqual(['custom']);
|
||||||
|
|
||||||
|
const allowed = filterNativeHookBackedCodexWatches(config, true);
|
||||||
|
expect(allowed.removed).toBe(0);
|
||||||
|
expect(allowed.config.watches).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
|
import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { NormalizedHookInput } from '../../src/cli/types.js';
|
||||||
|
import type { TranscriptSchema, WatchTarget } from '../../src/services/transcripts/types.js';
|
||||||
|
|
||||||
|
const sessionInitCalls: NormalizedHookInput[] = [];
|
||||||
|
|
||||||
|
mock.module('../../src/cli/handlers/session-init.js', () => ({
|
||||||
|
sessionInitHandler: {
|
||||||
|
execute: async (input: NormalizedHookInput) => {
|
||||||
|
sessionInitCalls.push(input);
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { logger } from '../../src/utils/logger.js';
|
||||||
|
import { TranscriptWatcher } from '../../src/services/transcripts/watcher.js';
|
||||||
|
|
||||||
|
const waitForAsyncTail = () => new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
describe('TranscriptWatcher startAtEnd', () => {
|
||||||
|
let tmpRoot: string;
|
||||||
|
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionInitCalls.length = 0;
|
||||||
|
tmpRoot = join(tmpdir(), `claude-mem-transcript-watch-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||||
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
|
loggerSpies = [
|
||||||
|
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||||
|
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||||
|
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||||
|
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
loggerSpies.forEach(spy => spy.mockRestore());
|
||||||
|
rmSync(tmpRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not replay history from transcript files discovered after startup', async () => {
|
||||||
|
const sessionId = '019e050e-7ae0-71b2-b19f-6cc428e5763a';
|
||||||
|
const filePath = join(tmpRoot, `${sessionId}.jsonl`);
|
||||||
|
const statePath = join(tmpRoot, 'state.json');
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
filePath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
type: 'event',
|
||||||
|
payload: {
|
||||||
|
type: 'user_message',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: 'historical prompt that must not be replayed',
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema: TranscriptSchema = {
|
||||||
|
name: 'codex-test',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
name: 'user-message',
|
||||||
|
match: { path: 'payload.type', equals: 'user_message' },
|
||||||
|
action: 'session_init',
|
||||||
|
fields: {
|
||||||
|
sessionId: 'payload.session_id',
|
||||||
|
prompt: 'payload.message',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const watch: WatchTarget = {
|
||||||
|
name: 'codex',
|
||||||
|
path: join(tmpRoot, '*.jsonl'),
|
||||||
|
schema,
|
||||||
|
startAtEnd: true,
|
||||||
|
};
|
||||||
|
const watcher = new TranscriptWatcher({ version: 1, watches: [watch] }, statePath);
|
||||||
|
|
||||||
|
await (watcher as any).addTailer(filePath, watch, schema);
|
||||||
|
await waitForAsyncTail();
|
||||||
|
|
||||||
|
expect(sessionInitCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
appendFileSync(
|
||||||
|
filePath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
type: 'event',
|
||||||
|
payload: {
|
||||||
|
type: 'user_message',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: 'live prompt',
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
(watcher as any).tailers.get(filePath)?.poke();
|
||||||
|
await waitForAsyncTail();
|
||||||
|
watcher.stop();
|
||||||
|
|
||||||
|
const prompts = sessionInitCalls.map(call => call.prompt);
|
||||||
|
expect(prompts).toContain('live prompt');
|
||||||
|
expect(prompts).not.toContain('historical prompt that must not be replayed');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user