Add native Codex hooks integration (#2319)
* Add native Codex hooks integration * Address Codex review feedback * Use durable Codex marketplace root * Address Codex file context review feedback * Harden Codex installer review paths * Report Codex legacy cleanup failures * fix: keep MCP manifests in marketplace sync * fix: bundle zod in MCP server * fix: warn on Codex legacy cleanup failure * Fix hook observation readiness timeouts * Address Codex hook review notes * Tighten Codex MCP file context matching * Resolve final Codex review nits * Add Codex marketplace version guidance * Reset worker failure counter on API fallback * Fix Codex cat flag file extraction
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "claude-mem-local",
|
||||
"interface": {
|
||||
"displayName": "claude-mem (local)"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
"typescript",
|
||||
"nodejs"
|
||||
],
|
||||
"skills": "./plugin/skills/",
|
||||
"mcpServers": "./.mcp.json",
|
||||
"hooks": "./plugin/hooks/codex-hooks.json",
|
||||
"interface": {
|
||||
"displayName": "claude-mem",
|
||||
"shortDescription": "Persistent memory and context compression across coding sessions.",
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
"mcpServers": {
|
||||
"mcp-search": {
|
||||
"type": "stdio",
|
||||
"command": "sh",
|
||||
"args": [
|
||||
"-c",
|
||||
"_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; exec node \"$_P/scripts/mcp-server.cjs\""
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,12 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
".agents/plugins/marketplace.json",
|
||||
".codex-plugin",
|
||||
".mcp.json",
|
||||
"plugin/.claude-plugin",
|
||||
"plugin/.codex-plugin",
|
||||
"plugin/.mcp.json",
|
||||
"plugin/CLAUDE.md",
|
||||
"plugin/package.json",
|
||||
"plugin/hooks",
|
||||
@@ -127,6 +132,7 @@
|
||||
"picocolors": "^1.1.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.6.5",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
},
|
||||
"repository": "https://github.com/thedotmack/claude-mem",
|
||||
"license": "AGPL-3.0",
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"claude-agent-sdk",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"memory",
|
||||
"context",
|
||||
"persistence",
|
||||
"hooks",
|
||||
"mcp"
|
||||
]
|
||||
"compression",
|
||||
"knowledge-graph",
|
||||
"transcript",
|
||||
"typescript",
|
||||
"nodejs"
|
||||
],
|
||||
"homepage": "https://github.com/thedotmack/claude-mem#readme"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.6.5",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman",
|
||||
"url": "https://github.com/thedotmack"
|
||||
},
|
||||
"homepage": "https://github.com/thedotmack/claude-mem#readme",
|
||||
"repository": "https://github.com/thedotmack/claude-mem",
|
||||
"license": "AGPL-3.0",
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"claude-agent-sdk",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"memory",
|
||||
"compression",
|
||||
"knowledge-graph",
|
||||
"transcript",
|
||||
"typescript",
|
||||
"nodejs"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"mcpServers": "./.mcp.json",
|
||||
"hooks": "./hooks/codex-hooks.json",
|
||||
"interface": {
|
||||
"displayName": "claude-mem",
|
||||
"shortDescription": "Persistent memory and context compression across coding sessions.",
|
||||
"longDescription": "claude-mem captures coding-session activity, compresses it into reusable observations, and injects relevant context back into future Claude Code and Codex-compatible sessions.",
|
||||
"developerName": "Alex Newman",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
"Interactive",
|
||||
"Write"
|
||||
],
|
||||
"websiteURL": "https://github.com/thedotmack/claude-mem",
|
||||
"defaultPrompt": [
|
||||
"Find what I already learned about this codebase before I start a new task.",
|
||||
"Show recent observations related to the files I am editing right now.",
|
||||
"Summarize the last session and inject the most relevant context into this one."
|
||||
],
|
||||
"brandColor": "#1F6FEB"
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -2,8 +2,11 @@
|
||||
"mcpServers": {
|
||||
"mcp-search": {
|
||||
"type": "stdio",
|
||||
"command": "bun",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs"]
|
||||
"command": "sh",
|
||||
"args": [
|
||||
"-c",
|
||||
"_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; exec node \"$_P/scripts/mcp-server.cjs\""
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"description": "claude-mem Codex CLI hook integration",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; CLAUDE_MEM_CODEX_HOOK=1 node \"$_P/scripts/version-check.js\"",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" start",
|
||||
"timeout": 60
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex context",
|
||||
"timeout": 60,
|
||||
"statusMessage": "Loading claude-mem context"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex session-init",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "^Bash$|^mcp__.+__(read|view|cat)(_file|_files)?$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex file-context",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex observation",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex summarize",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"@tree-sitter-grammars/tree-sitter-toml": "^0.7.0",
|
||||
"@tree-sitter-grammars/tree-sitter-yaml": "^0.7.1",
|
||||
"@derekstride/tree-sitter-sql": "^0.3.11",
|
||||
"@tree-sitter-grammars/tree-sitter-markdown": "^0.3.2"
|
||||
"@tree-sitter-grammars/tree-sitter-markdown": "^0.3.2",
|
||||
"shell-quote": "^1.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"tree-sitter": "^0.25.0"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,18 +19,31 @@ function resolveRoot() {
|
||||
const ROOT = resolveRoot();
|
||||
if (!ROOT) process.exit(0);
|
||||
|
||||
function emitUpgradeHint(message) {
|
||||
if (process.env.CLAUDE_MEM_CODEX_HOOK === '1') {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext: message,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
const markerPath = join(ROOT, '.install-version');
|
||||
if (!existsSync(markerPath)) {
|
||||
console.error('claude-mem: runtime not yet set up — run: npx claude-mem repair');
|
||||
emitUpgradeHint('claude-mem: runtime not yet set up - run: npx claude-mem@latest install');
|
||||
process.exit(0);
|
||||
}
|
||||
const marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
|
||||
if (marker.version !== pkg.version) {
|
||||
console.error(`claude-mem: upgraded to v${pkg.version} — run: npx claude-mem repair`);
|
||||
emitUpgradeHint(`claude-mem: upgraded to v${pkg.version} - run: npx claude-mem@latest install`);
|
||||
}
|
||||
} catch {
|
||||
console.error('claude-mem: install marker unreadable — run: npx claude-mem repair');
|
||||
emitUpgradeHint('claude-mem: install marker unreadable - run: npx claude-mem@latest install');
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
+211
-212
File diff suppressed because one or more lines are too long
+30
-1
@@ -35,6 +35,7 @@ function stripHardcodedDirname(filePath) {
|
||||
}
|
||||
|
||||
content = content.replace(/\bvar\s*;/g, '');
|
||||
content = content.replace(/[ \t]+$/gm, '');
|
||||
|
||||
const removed = before - content.length;
|
||||
if (removed > 0) {
|
||||
@@ -97,6 +98,7 @@ async function buildHooks() {
|
||||
'@tree-sitter-grammars/tree-sitter-yaml': '^0.7.1',
|
||||
'@derekstride/tree-sitter-sql': '^0.3.11',
|
||||
'@tree-sitter-grammars/tree-sitter-markdown': '^0.3.2',
|
||||
'shell-quote': '^1.8.3',
|
||||
},
|
||||
overrides: {
|
||||
'tree-sitter': '^0.25.0'
|
||||
@@ -172,7 +174,6 @@ async function buildHooks() {
|
||||
logLevel: 'error',
|
||||
external: [
|
||||
'bun:sqlite',
|
||||
'zod',
|
||||
'tree-sitter-cli',
|
||||
'tree-sitter-javascript',
|
||||
'tree-sitter-typescript',
|
||||
@@ -221,6 +222,13 @@ async function buildHooks() {
|
||||
`mcp-server.cjs contains a Bun-only ${bunRequireMatch[0]} call. This means a transitive import in src/servers/mcp-server.ts pulled in code from worker-service.ts (or another module that touches DatabaseManager/ChromaSync). The MCP server runs under Node and cannot load bun:* modules. Audit recent imports in src/servers/mcp-server.ts and src/services/worker-spawner.ts — the spawner module is intentionally lightweight and MUST NOT import anything that touches SQLite or other Bun-only modules. See PR #1645 for context.`
|
||||
);
|
||||
}
|
||||
const zodRequireRegex = /require\(\s*["']zod(?:\/[^"']*)?["']\s*\)/;
|
||||
const zodRequireMatch = mcpBundleContent.match(zodRequireRegex);
|
||||
if (zodRequireMatch) {
|
||||
throw new Error(
|
||||
`mcp-server.cjs contains external ${zodRequireMatch[0]}. Claude Desktop can launch this bundle without plugin node_modules available, so Zod must be bundled into the MCP server.`
|
||||
);
|
||||
}
|
||||
|
||||
const MCP_SERVER_MAX_BYTES = 600 * 1024;
|
||||
if (mcpServerStats.size > MCP_SERVER_MAX_BYTES) {
|
||||
@@ -342,19 +350,40 @@ async function buildHooks() {
|
||||
console.log(`✓ Copied ${onboardingExplainerSrc} → ${onboardingExplainerDst}`);
|
||||
|
||||
console.log('\n📋 Verifying distribution files...');
|
||||
const validCodexHookEvents = new Set([
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'PreToolUse',
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'Stop',
|
||||
]);
|
||||
const requiredDistributionFiles = [
|
||||
'plugin/skills/mem-search/SKILL.md',
|
||||
'plugin/skills/smart-explore/SKILL.md',
|
||||
'plugin/skills/how-it-works/SKILL.md',
|
||||
'plugin/skills/how-it-works/onboarding-explainer.md',
|
||||
'plugin/hooks/hooks.json',
|
||||
'plugin/hooks/codex-hooks.json',
|
||||
'plugin/scripts/bun-runner.js',
|
||||
'plugin/.claude-plugin/plugin.json',
|
||||
'plugin/.codex-plugin/plugin.json',
|
||||
'plugin/.mcp.json',
|
||||
'.codex-plugin/plugin.json',
|
||||
'.mcp.json',
|
||||
'.agents/plugins/marketplace.json',
|
||||
];
|
||||
for (const filePath of requiredDistributionFiles) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Missing required distribution file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
const codexHooks = JSON.parse(fs.readFileSync('plugin/hooks/codex-hooks.json', 'utf-8'));
|
||||
for (const eventName of Object.keys(codexHooks.hooks ?? {})) {
|
||||
if (!validCodexHookEvents.has(eventName)) {
|
||||
throw new Error(`plugin/hooks/codex-hooks.json contains unknown Codex hook event: ${eventName}`);
|
||||
}
|
||||
}
|
||||
console.log('✓ All required distribution files present');
|
||||
|
||||
console.log('\n✅ All build targets compiled successfully!');
|
||||
|
||||
@@ -34,10 +34,19 @@ function getGitignoreExcludes(basePath) {
|
||||
const gitignorePath = path.join(basePath, '.gitignore');
|
||||
if (!existsSync(gitignorePath)) return '';
|
||||
|
||||
const syncManagedFiles = new Set([
|
||||
'.mcp.json',
|
||||
]);
|
||||
|
||||
const lines = readFileSync(gitignorePath, 'utf-8').split('\n');
|
||||
return lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#') && !line.startsWith('!'))
|
||||
.filter(line =>
|
||||
line &&
|
||||
!line.startsWith('#') &&
|
||||
!line.startsWith('!') &&
|
||||
!syncManagedFiles.has(line)
|
||||
)
|
||||
.map(pattern => `--exclude=${JSON.stringify(pattern)}`)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -211,4 +220,4 @@ try {
|
||||
} catch (error) {
|
||||
console.error('\x1b[31m%s\x1b[0m', 'Sync failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
const packageJsonPath = path.join(rootDir, 'package.json');
|
||||
const codexPluginPath = path.join(rootDir, '.codex-plugin', 'plugin.json');
|
||||
const bundledCodexPluginPath = path.join(rootDir, 'plugin', '.codex-plugin', 'plugin.json');
|
||||
const claudePluginPath = path.join(rootDir, '.claude-plugin', 'plugin.json');
|
||||
const bundledClaudePluginPath = path.join(rootDir, 'plugin', '.claude-plugin', 'plugin.json');
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
@@ -75,7 +77,7 @@ function normalizeRepositoryUrl(repository) {
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const filePath of [packageJsonPath, codexPluginPath, claudePluginPath]) {
|
||||
for (const filePath of [packageJsonPath, codexPluginPath, bundledCodexPluginPath, claudePluginPath, bundledClaudePluginPath]) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Missing required file: ${filePath}`);
|
||||
process.exit(1);
|
||||
@@ -84,10 +86,14 @@ function main() {
|
||||
|
||||
const pkg = readJson(packageJsonPath);
|
||||
const codexPlugin = readJson(codexPluginPath);
|
||||
const bundledCodexPlugin = readJson(bundledCodexPluginPath);
|
||||
const claudePlugin = readJson(claudePluginPath);
|
||||
const bundledClaudePlugin = readJson(bundledClaudePluginPath);
|
||||
|
||||
writeJson(codexPluginPath, syncCodexPlugin(codexPlugin, pkg));
|
||||
writeJson(bundledCodexPluginPath, syncCodexPlugin(bundledCodexPlugin, pkg));
|
||||
writeJson(claudePluginPath, syncClaudePlugin(claudePlugin, pkg));
|
||||
writeJson(bundledClaudePluginPath, syncClaudePlugin(bundledClaudePlugin, pkg));
|
||||
|
||||
console.log('✓ Synced plugin manifests from package.json');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { parse, type ParsedToken } from 'shell-quote';
|
||||
|
||||
const MAX_FILE_PATHS = 10;
|
||||
const READ_COMMANDS = new Set(['cat', 'head', 'tail', 'less', 'more', 'bat', 'view', 'nl', 'tac']);
|
||||
const FLAGS_WITH_VALUES_BY_COMMAND: Record<string, Set<string>> = {
|
||||
head: new Set(['-n', '-c', '--lines', '--bytes']),
|
||||
tail: new Set(['-n', '-c', '--lines', '--bytes']),
|
||||
};
|
||||
const NO_FLAGS_WITH_VALUES = new Set<string>();
|
||||
|
||||
function isOperatorToken(token: ParsedToken): boolean {
|
||||
return typeof token === 'object' && token !== null && 'op' in token;
|
||||
}
|
||||
|
||||
function splitSegments(tokens: ParsedToken[]): string[][] {
|
||||
const segments: string[][] = [];
|
||||
let current: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
if (isOperatorToken(token)) {
|
||||
if (current.length > 0) segments.push(current);
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
if (typeof token === 'string') {
|
||||
current.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length > 0) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
function normalizeCommand(command: unknown): string | null {
|
||||
if (typeof command === 'string') return command;
|
||||
if (Array.isArray(command)) {
|
||||
const parts = command.filter((part): part is string => typeof part === 'string');
|
||||
return parts.length > 0 ? parts.join(' ') : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFlagLike(value: string): boolean {
|
||||
return value.startsWith('-') || value.startsWith('+');
|
||||
}
|
||||
|
||||
function flagsWithValues(command: string): Set<string> {
|
||||
return FLAGS_WITH_VALUES_BY_COMMAND[command] ?? NO_FLAGS_WITH_VALUES;
|
||||
}
|
||||
|
||||
function dropFlagValue(flag: string, command: string): boolean {
|
||||
const valueFlags = flagsWithValues(command);
|
||||
if (valueFlags.has(flag)) return true;
|
||||
const eqIndex = flag.indexOf('=');
|
||||
return eqIndex > 0 && valueFlags.has(flag.slice(0, eqIndex));
|
||||
}
|
||||
|
||||
function isExistingFile(candidate: string, cwd: string): boolean {
|
||||
const absolutePath = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
||||
try {
|
||||
if (!existsSync(absolutePath)) return false;
|
||||
return statSync(absolutePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeAndCap(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: string[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (seen.has(filePath)) continue;
|
||||
seen.add(filePath);
|
||||
deduped.push(filePath);
|
||||
if (deduped.length >= MAX_FILE_PATHS) break;
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function extractFromBash(toolInput: unknown, cwd: string): string[] {
|
||||
const command = normalizeCommand((toolInput as { command?: unknown } | undefined)?.command);
|
||||
if (!command) return [];
|
||||
|
||||
const tokens = parse(command);
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const segment of splitSegments(tokens)) {
|
||||
const argv0Index = segment.findIndex(token => token && !isFlagLike(token));
|
||||
if (argv0Index === -1) continue;
|
||||
|
||||
const argv0 = path.basename(segment[argv0Index]);
|
||||
if (!READ_COMMANDS.has(argv0)) continue;
|
||||
|
||||
let skipNext = false;
|
||||
for (const token of segment.slice(argv0Index + 1)) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
if (isFlagLike(token)) {
|
||||
skipNext = dropFlagValue(token, argv0) && !token.includes('=');
|
||||
continue;
|
||||
}
|
||||
if (isExistingFile(token, cwd)) {
|
||||
paths.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAndCap(paths);
|
||||
}
|
||||
|
||||
function extractFromMcp(toolName: string, toolInput: unknown, cwd: string): string[] {
|
||||
if (!/^mcp__.+__(read|view|cat)(?:_file|_files)?$/.test(toolName)) return [];
|
||||
|
||||
const input = (toolInput ?? {}) as { path?: unknown; paths?: unknown };
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (typeof input.path === 'string') candidates.push(input.path);
|
||||
if (Array.isArray(input.paths)) {
|
||||
for (const item of input.paths) {
|
||||
if (typeof item === 'string') candidates.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAndCap(candidates.filter(candidate => isExistingFile(candidate, cwd)));
|
||||
}
|
||||
|
||||
export function extractFilePaths(toolName: string, toolInput: unknown, cwd: string): string[] {
|
||||
if (toolName === 'Bash') return extractFromBash(toolInput, cwd);
|
||||
if (toolName.startsWith('mcp__')) return extractFromMcp(toolName, toolInput, cwd);
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { HookResult, NormalizedHookInput, PlatformAdapter } from '../types.js';
|
||||
import { AdapterRejectedInput, isValidCwd } from './errors.js';
|
||||
import { extractFilePaths } from './codex-file-context.js';
|
||||
|
||||
type CodexEventName =
|
||||
| 'PreToolUse'
|
||||
| 'PermissionRequest'
|
||||
| 'PostToolUse'
|
||||
| 'SessionStart'
|
||||
| 'UserPromptSubmit'
|
||||
| 'Stop';
|
||||
|
||||
const EVENT_NAMES = new Set<CodexEventName>([
|
||||
'PreToolUse',
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
]);
|
||||
|
||||
function eventName(value: unknown): CodexEventName | undefined {
|
||||
return typeof value === 'string' && EVENT_NAMES.has(value as CodexEventName)
|
||||
? value as CodexEventName
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function stringOrUndefined(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function booleanOrUndefined(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cloneToolInput(toolInput: unknown): unknown {
|
||||
if (toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
|
||||
return { ...(toolInput as Record<string, unknown>) };
|
||||
}
|
||||
return toolInput;
|
||||
}
|
||||
|
||||
function buildBaseOutput(result: HookResult): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = {};
|
||||
if (result.continue !== undefined) output.continue = result.continue;
|
||||
if (result.suppressOutput !== undefined) output.suppressOutput = result.suppressOutput;
|
||||
if (result.systemMessage) output.systemMessage = result.systemMessage;
|
||||
if (result.decision === 'block') output.decision = 'block';
|
||||
if (result.reason) output.reason = result.reason;
|
||||
return output;
|
||||
}
|
||||
|
||||
function inferOutputEvent(result: HookResult): CodexEventName | undefined {
|
||||
return eventName(result.hookSpecificOutput?.hookEventName);
|
||||
}
|
||||
|
||||
export const codexAdapter: PlatformAdapter = {
|
||||
normalizeInput(raw): NormalizedHookInput {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const cwd = typeof r.cwd === 'string' ? r.cwd : process.cwd();
|
||||
if (!isValidCwd(cwd)) {
|
||||
throw new AdapterRejectedInput('invalid_cwd');
|
||||
}
|
||||
|
||||
const hookEventName = eventName(r.hook_event_name);
|
||||
const toolName = stringOrUndefined(r.tool_name);
|
||||
let toolInput = cloneToolInput(r.tool_input);
|
||||
|
||||
if (hookEventName === 'PreToolUse' && toolName) {
|
||||
const filePaths = extractFilePaths(toolName, toolInput, cwd);
|
||||
if (filePaths.length > 0 && toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
|
||||
toolInput = { ...(toolInput as Record<string, unknown>), filePaths };
|
||||
}
|
||||
}
|
||||
|
||||
const source = r.source;
|
||||
const sessionSource =
|
||||
source === 'startup' || source === 'resume' || source === 'clear'
|
||||
? source
|
||||
: undefined;
|
||||
const sessionId = stringOrUndefined(r.session_id);
|
||||
if (!sessionId) {
|
||||
throw new AdapterRejectedInput('missing_session_id');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
cwd,
|
||||
prompt: stringOrUndefined(r.prompt),
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResponse: r.tool_response,
|
||||
transcriptPath: stringOrUndefined(r.transcript_path),
|
||||
lastAssistantMessage: stringOrUndefined(r.last_assistant_message),
|
||||
turnId: stringOrUndefined(r.turn_id),
|
||||
stopHookActive: booleanOrUndefined(r.stop_hook_active),
|
||||
permissionMode: stringOrUndefined(r.permission_mode),
|
||||
model: stringOrUndefined(r.model),
|
||||
sessionSource,
|
||||
};
|
||||
},
|
||||
|
||||
formatOutput(result): unknown {
|
||||
const r = result ?? {};
|
||||
const output = buildBaseOutput(r);
|
||||
const hookSpecific = r.hookSpecificOutput;
|
||||
const outputEvent = inferOutputEvent(r);
|
||||
|
||||
if (!hookSpecific || !outputEvent || outputEvent === 'Stop') {
|
||||
return output;
|
||||
}
|
||||
|
||||
const specific: Record<string, unknown> = {
|
||||
hookEventName: outputEvent,
|
||||
};
|
||||
|
||||
if (hookSpecific.additionalContext) {
|
||||
specific.additionalContext = hookSpecific.additionalContext;
|
||||
}
|
||||
|
||||
if (outputEvent === 'PreToolUse') {
|
||||
if (hookSpecific.permissionDecision === 'deny') {
|
||||
specific.permissionDecision = 'deny';
|
||||
if (hookSpecific.permissionDecisionReason) {
|
||||
specific.permissionDecisionReason = hookSpecific.permissionDecisionReason;
|
||||
}
|
||||
}
|
||||
if (hookSpecific.updatedInput) {
|
||||
specific.updatedInput = hookSpecific.updatedInput;
|
||||
}
|
||||
}
|
||||
|
||||
output.hookSpecificOutput = specific;
|
||||
return output;
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlatformAdapter } from '../types.js';
|
||||
import { claudeCodeAdapter } from './claude-code.js';
|
||||
import { codexAdapter } from './codex.js';
|
||||
import { cursorAdapter } from './cursor.js';
|
||||
import { geminiCliAdapter } from './gemini-cli.js';
|
||||
import { rawAdapter } from './raw.js';
|
||||
@@ -8,6 +9,7 @@ import { windsurfAdapter } from './windsurf.js';
|
||||
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
switch (platform) {
|
||||
case 'claude-code': return claudeCodeAdapter;
|
||||
case 'codex': return codexAdapter;
|
||||
case 'cursor': return cursorAdapter;
|
||||
case 'gemini':
|
||||
case 'gemini-cli': return geminiCliAdapter;
|
||||
@@ -17,4 +19,4 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||
export { claudeCodeAdapter, codexAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||
|
||||
@@ -13,6 +13,7 @@ const FILE_READ_GATE_MIN_BYTES = 1_500;
|
||||
const FETCH_LOOKAHEAD_LIMIT = 40;
|
||||
|
||||
const DISPLAY_LIMIT = 15;
|
||||
const MAX_FILE_CONTEXT_PATHS = 10;
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
decision: '\u2696\uFE0F',
|
||||
@@ -135,86 +136,112 @@ function formatFileTimeline(
|
||||
export const fileContextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
const toolInput = input.toolInput as Record<string, unknown> | undefined;
|
||||
const filePaths = Array.isArray(toolInput?.filePaths)
|
||||
? (toolInput.filePaths as unknown[]).filter((p): p is string => typeof p === 'string').slice(0, MAX_FILE_CONTEXT_PATHS)
|
||||
: [];
|
||||
const filePath = toolInput?.file_path as string | undefined;
|
||||
const candidatePaths = filePaths.length > 0 ? filePaths : (filePath ? [filePath] : []);
|
||||
|
||||
if (!filePath) {
|
||||
if (candidatePaths.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
let fileMtimeMs = 0;
|
||||
try {
|
||||
const statPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(input.cwd || process.cwd(), filePath);
|
||||
const stat = statSync(statPath);
|
||||
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
fileMtimeMs = stat.mtimeMs;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
if (input.cwd && !shouldTrackProject(input.cwd)) {
|
||||
logger.debug('HOOK', 'Project excluded from tracking, skipping file context', { cwd: input.cwd });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
const context = getProjectContext(input.cwd);
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
||||
const queryParams = new URLSearchParams({ path: relativePath });
|
||||
if (context.allProjects.length > 0) {
|
||||
queryParams.set('projects', context.allProjects.join(','));
|
||||
}
|
||||
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
|
||||
|
||||
const result = await executeWithWorkerFallback<{ observations: ObservationRow[]; count: number }>(
|
||||
`/api/observations/by-file?${queryParams.toString()}`,
|
||||
'GET',
|
||||
const timelineResults = await Promise.allSettled(
|
||||
candidatePaths.map(candidatePath => buildFileContextTimeline(input, candidatePath))
|
||||
);
|
||||
if (isWorkerFallback(result)) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
if (!result || !Array.isArray((result as any).observations)) {
|
||||
logger.warn('HOOK', 'File context query returned malformed body, skipping', { filePath });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
const data = result;
|
||||
const timelines: string[] = [];
|
||||
|
||||
if (!data.observations || data.observations.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
if (fileMtimeMs > 0) {
|
||||
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
|
||||
if (fileMtimeMs >= newestObservationMs) {
|
||||
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
|
||||
filePath: relativePath,
|
||||
fileMtimeMs,
|
||||
newestObservationMs,
|
||||
});
|
||||
return { continue: true, suppressOutput: true };
|
||||
timelineResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
if (result.value) timelines.push(result.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger.debug('HOOK', 'File context timeline lookup failed, skipping path', {
|
||||
filePath: candidatePaths[index],
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
});
|
||||
});
|
||||
|
||||
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
|
||||
if (dedupedObservations.length === 0) {
|
||||
if (timelines.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
const timeline = formatFileTimeline(dedupedObservations, filePath);
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: timeline,
|
||||
additionalContext: timelines.join('\n\n---\n\n'),
|
||||
permissionDecision: 'allow',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function buildFileContextTimeline(input: NormalizedHookInput, filePath: string): Promise<string | null> {
|
||||
let fileMtimeMs = 0;
|
||||
try {
|
||||
const statPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(input.cwd || process.cwd(), filePath);
|
||||
const stat = statSync(statPath);
|
||||
if (!stat.isFile() || stat.size < FILE_READ_GATE_MIN_BYTES) {
|
||||
return null;
|
||||
}
|
||||
fileMtimeMs = stat.mtimeMs;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
const context = getProjectContext(input.cwd);
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
||||
const queryParams = new URLSearchParams({ path: relativePath });
|
||||
if (context.allProjects.length > 0) {
|
||||
queryParams.set('projects', context.allProjects.join(','));
|
||||
}
|
||||
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
|
||||
|
||||
const result = await executeWithWorkerFallback<{ observations: ObservationRow[]; count: number }>(
|
||||
`/api/observations/by-file?${queryParams.toString()}`,
|
||||
'GET',
|
||||
);
|
||||
if (isWorkerFallback(result)) {
|
||||
return null;
|
||||
}
|
||||
if (!result || !Array.isArray((result as any).observations)) {
|
||||
logger.warn('HOOK', 'File context query returned malformed body, skipping', { filePath });
|
||||
return null;
|
||||
}
|
||||
const data = result;
|
||||
|
||||
if (!data.observations || data.observations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileMtimeMs > 0) {
|
||||
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
|
||||
if (fileMtimeMs >= newestObservationMs) {
|
||||
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
|
||||
filePath: relativePath,
|
||||
fileMtimeMs,
|
||||
newestObservationMs,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
|
||||
if (dedupedObservations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatFileTimeline(dedupedObservations, filePath);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ export const summarizeHandler: EventHandler = {
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
if (input.stopHookActive === true) {
|
||||
logger.debug('HOOK', 'Skipping summary: Codex Stop hook re-entry detected', {
|
||||
sessionId: input.sessionId,
|
||||
});
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
if (input.agentId) {
|
||||
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
|
||||
sessionId: input.sessionId,
|
||||
@@ -29,22 +36,28 @@ export const summarizeHandler: EventHandler = {
|
||||
logger.warn('HOOK', 'summarize: No sessionId provided, skipping');
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
if (!transcriptPath) {
|
||||
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
let lastAssistantMessage = '';
|
||||
try {
|
||||
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
lastAssistantMessage = stripMemoryTagsFromPrompt(lastAssistantMessage);
|
||||
} catch (err) {
|
||||
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
|
||||
if (input.lastAssistantMessage !== undefined) {
|
||||
lastAssistantMessage = stripMemoryTagsFromPrompt(input.lastAssistantMessage);
|
||||
} else {
|
||||
if (!transcriptPath) {
|
||||
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
try {
|
||||
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
lastAssistantMessage = stripMemoryTagsFromPrompt(lastAssistantMessage);
|
||||
} catch (err) {
|
||||
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
|
||||
logger.debug('HOOK', 'No assistant message in transcript - skipping summary', {
|
||||
logger.debug('HOOK', 'No assistant message available - skipping summary', {
|
||||
sessionId,
|
||||
transcriptPath
|
||||
});
|
||||
@@ -71,6 +84,6 @@ export const summarizeHandler: EventHandler = {
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'Summary request queued, exiting hook');
|
||||
return { continue: true, suppressOutput: true };
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface NormalizedHookInput {
|
||||
toolInput?: unknown;
|
||||
toolResponse?: unknown;
|
||||
transcriptPath?: string;
|
||||
lastAssistantMessage?: string;
|
||||
turnId?: string;
|
||||
stopHookActive?: boolean;
|
||||
permissionMode?: string;
|
||||
model?: string;
|
||||
sessionSource?: 'startup' | 'resume' | 'clear';
|
||||
filePath?: string;
|
||||
edits?: unknown[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -21,9 +27,12 @@ export interface HookResult {
|
||||
hookEventName: string;
|
||||
additionalContext: string;
|
||||
permissionDecision?: 'allow' | 'deny';
|
||||
permissionDecisionReason?: string;
|
||||
updatedInput?: Record<string, unknown>;
|
||||
};
|
||||
systemMessage?: string;
|
||||
decision?: 'block' | 'approve';
|
||||
reason?: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export function detectInstalledIDEs(): IDEInfo[] {
|
||||
label: 'Codex CLI',
|
||||
detected: existsSync(join(home, '.codex')),
|
||||
supported: true,
|
||||
hint: 'transcript-based integration',
|
||||
hint: 'native hooks integration',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
@@ -127,4 +127,3 @@ export function detectInstalledIDEs(): IDEInfo[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -243,17 +243,17 @@ function makeIDETask(ideId: string, failedIDEs: string[], pendingErrors: string[
|
||||
|
||||
case 'codex-cli': {
|
||||
return {
|
||||
title: 'Codex CLI: configuring transcript watching',
|
||||
title: 'Codex CLI: registering hooks marketplace',
|
||||
task: async (message) => {
|
||||
message('Loading Codex CLI installer…');
|
||||
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
||||
message('Configuring transcript watching…');
|
||||
const { result, output } = await bufferConsole(() => installCodexCli());
|
||||
message('Registering native Codex hooks…');
|
||||
const { result, output } = await bufferConsole(() => installCodexCli(marketplaceDirectory()));
|
||||
if (result !== 0) {
|
||||
recordFailure('Codex CLI: integration setup failed', output);
|
||||
return `Codex CLI: integration setup failed ${pc.red('FAIL')}`;
|
||||
}
|
||||
return `Codex CLI: transcript watching configured ${pc.green('OK')}`;
|
||||
return `Codex CLI: hooks marketplace registered ${pc.green('OK')}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -500,6 +500,9 @@ function copyPluginToMarketplace(): void {
|
||||
ensureDirectoryExists(marketplaceDir);
|
||||
|
||||
const allowedTopLevelEntries = [
|
||||
'.agents',
|
||||
'.codex-plugin',
|
||||
'.mcp.json',
|
||||
'plugin',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
|
||||
@@ -1,99 +1,151 @@
|
||||
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { replaceTaggedContent } from '../../utils/claude-md-utils.js';
|
||||
import {
|
||||
DEFAULT_CONFIG_PATH,
|
||||
DEFAULT_STATE_PATH,
|
||||
SAMPLE_CONFIG,
|
||||
} from '../transcripts/config.js';
|
||||
import { paths } from '../../shared/paths.js';
|
||||
import type { TranscriptWatchConfig, WatchTarget } from '../transcripts/types.js';
|
||||
|
||||
const CODEX_DIR = path.join(homedir(), '.codex');
|
||||
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
|
||||
const CLAUDE_MEM_DIR = paths.dataDir();
|
||||
|
||||
const CODEX_WATCH_NAME = 'codex';
|
||||
|
||||
function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
|
||||
const configPath = DEFAULT_CONFIG_PATH;
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
|
||||
}
|
||||
const MARKETPLACE_NAME = 'claude-mem-local';
|
||||
const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0';
|
||||
const REQUIRED_MARKETPLACE_FILES = [
|
||||
path.join('.agents', 'plugins', 'marketplace.json'),
|
||||
path.join('.codex-plugin', 'plugin.json'),
|
||||
'.mcp.json',
|
||||
];
|
||||
|
||||
function commandExists(command: string): boolean {
|
||||
try {
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
|
||||
|
||||
if (!parsed.version) parsed.version = 1;
|
||||
if (!parsed.watches) parsed.watches = [];
|
||||
if (!parsed.schemas) parsed.schemas = {};
|
||||
if (!parsed.stateFile) parsed.stateFile = DEFAULT_STATE_PATH;
|
||||
|
||||
return parsed;
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof Error) {
|
||||
logger.error('WORKER', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError);
|
||||
if (process.platform === 'win32') {
|
||||
execFileSync('where', [command], { stdio: 'ignore' });
|
||||
} else {
|
||||
logger.error('WORKER', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, new Error(String(parseError)));
|
||||
execFileSync('which', [command], { stdio: 'ignore' });
|
||||
}
|
||||
|
||||
const backupPath = `${configPath}.backup.${Date.now()}`;
|
||||
writeFileSync(backupPath, readFileSync(configPath));
|
||||
console.warn(` Backed up corrupt transcript-watch.json to ${backupPath}`);
|
||||
|
||||
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeCodexWatchConfig(existingConfig: TranscriptWatchConfig): TranscriptWatchConfig {
|
||||
const merged = { ...existingConfig };
|
||||
|
||||
merged.schemas = { ...merged.schemas };
|
||||
const codexSchema = SAMPLE_CONFIG.schemas?.[CODEX_WATCH_NAME];
|
||||
if (codexSchema) {
|
||||
merged.schemas[CODEX_WATCH_NAME] = codexSchema;
|
||||
}
|
||||
|
||||
const codexWatchFromSample = SAMPLE_CONFIG.watches.find(
|
||||
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||
);
|
||||
|
||||
if (codexWatchFromSample) {
|
||||
const existingWatchIndex = merged.watches.findIndex(
|
||||
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||
);
|
||||
|
||||
if (existingWatchIndex !== -1) {
|
||||
merged.watches[existingWatchIndex] = codexWatchFromSample;
|
||||
} else {
|
||||
merged.watches.push(codexWatchFromSample);
|
||||
function findAncestorWithCodexPlugin(start: string): string | null {
|
||||
let current = path.resolve(start);
|
||||
while (true) {
|
||||
if (existsSync(path.join(current, '.codex-plugin', 'plugin.json'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function missingMarketplaceFiles(root: string): string[] {
|
||||
return REQUIRED_MARKETPLACE_FILES.filter((entry) => !existsSync(path.join(root, entry)));
|
||||
}
|
||||
|
||||
function assertCodexMarketplaceRoot(root: string): string {
|
||||
const resolved = path.resolve(root);
|
||||
const missing = missingMarketplaceFiles(resolved);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Codex marketplace root ${resolved} is missing required files: ${missing.join(', ')}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolvePluginMarketplaceRoot(preferredRoot?: string): string {
|
||||
if (preferredRoot) {
|
||||
return assertCodexMarketplaceRoot(preferredRoot);
|
||||
}
|
||||
|
||||
return merged;
|
||||
const candidates = [
|
||||
process.env.CLAUDE_PLUGIN_ROOT,
|
||||
process.env.PLUGIN_ROOT,
|
||||
process.cwd(),
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const resolved = findAncestorWithCodexPlugin(candidate);
|
||||
if (resolved && missingMarketplaceFiles(resolved).length === 0) return resolved;
|
||||
}
|
||||
|
||||
throw new Error('Could not locate a Codex marketplace root with .agents/plugins/marketplace.json, .codex-plugin/plugin.json, and .mcp.json. Run npx claude-mem@latest install from the package or repo root.');
|
||||
}
|
||||
|
||||
function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
|
||||
mkdirSync(CLAUDE_MEM_DIR, { recursive: true });
|
||||
writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||
function runCodex(args: string[]): void {
|
||||
const result = spawnSync('codex', args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const output = console;
|
||||
const stdout = result.stdout?.trimEnd();
|
||||
const stderr = result.stderr?.trimEnd();
|
||||
|
||||
if (stdout) output.log(stdout);
|
||||
if (stderr) output.error(stderr);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const exitCode = result.status ?? 'unknown';
|
||||
throw new Error(`codex ${args.join(' ')} failed with exit code ${exitCode}${stderr ? `: ${stderr}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCodexAgentsMdContext(): void {
|
||||
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
|
||||
function parseSemver(value: string): [number, number, number] | null {
|
||||
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return null;
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareSemver(left: [number, number, number], right: [number, number, number]): number {
|
||||
if (left[0] !== right[0]) return left[0] - right[0];
|
||||
if (left[1] !== right[1]) return left[1] - right[1];
|
||||
return left[2] - right[2];
|
||||
}
|
||||
|
||||
function assertCodexMarketplaceSupported(): void {
|
||||
const result = spawnSync('codex', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.warn(` Could not determine Codex CLI version. Continuing; plugin marketplace support requires ${MIN_CODEX_MARKETPLACE_VERSION} or newer.${output ? `\n${output}` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const version = parseSemver(output);
|
||||
if (!version) {
|
||||
console.warn(` Could not parse Codex CLI version from "${output || '<empty>'}". Continuing; plugin marketplace support requires ${MIN_CODEX_MARKETPLACE_VERSION} or newer.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const minimumVersion = parseSemver(MIN_CODEX_MARKETPLACE_VERSION);
|
||||
if (minimumVersion && compareSemver(version, minimumVersion) < 0) {
|
||||
throw new Error(`Codex CLI ${version.join('.')} is too old for plugin marketplace support. Update Codex CLI to ${MIN_CODEX_MARKETPLACE_VERSION} or newer, then run: npx claude-mem@latest install`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCodexAgentsMdContext(): boolean {
|
||||
if (!existsSync(CODEX_AGENTS_MD_PATH)) return true;
|
||||
|
||||
const startTag = '<claude-mem-context>';
|
||||
const endTag = '</claude-mem-context>';
|
||||
|
||||
try {
|
||||
readAndStripContextTags(startTag, endTag);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn('WORKER', 'Failed to clean AGENTS.md context', { error: message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +172,39 @@ function readAndStripContextTags(startTag: string, endTag: string): void {
|
||||
|
||||
const cleanupLegacyCodexAgentsMdContext = removeCodexAgentsMdContext;
|
||||
|
||||
export async function installCodexCli(): Promise<number> {
|
||||
console.log('\nInstalling Claude-Mem for Codex CLI (transcript watching)...\n');
|
||||
export async function installCodexCli(marketplaceRootOverride?: string): Promise<number> {
|
||||
console.log('\nInstalling Claude-Mem for Codex CLI (native hooks)...\n');
|
||||
|
||||
const existingConfig = loadExistingTranscriptWatchConfig();
|
||||
const mergedConfig = mergeCodexWatchConfig(existingConfig);
|
||||
if (!commandExists('codex')) {
|
||||
console.error('Codex CLI was not found on PATH.');
|
||||
console.error('Install Codex, then run: npx claude-mem@latest install');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
writeConfigAndShowCodexInstructions(mergedConfig);
|
||||
assertCodexMarketplaceSupported();
|
||||
const marketplaceRoot = resolvePluginMarketplaceRoot(marketplaceRootOverride);
|
||||
|
||||
console.log(` Registering Codex plugin marketplace: ${marketplaceRoot}`);
|
||||
runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]);
|
||||
if (!cleanupLegacyCodexAgentsMdContext()) {
|
||||
console.warn(` Native Codex hooks registered, but failed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Installation complete!
|
||||
|
||||
Codex marketplace: ${MARKETPLACE_NAME}
|
||||
Plugin source: ${marketplaceRoot}
|
||||
|
||||
Next steps:
|
||||
1. Open Codex CLI in your project
|
||||
2. Run /plugins
|
||||
3. Install claude-mem from the claude-mem (local) marketplace
|
||||
|
||||
For a fresh setup, the supported entry point is:
|
||||
npx claude-mem@latest install
|
||||
`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -136,62 +213,41 @@ export async function installCodexCli(): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfigAndShowCodexInstructions(mergedConfig: TranscriptWatchConfig): void {
|
||||
writeTranscriptWatchConfig(mergedConfig);
|
||||
console.log(` Updated ${DEFAULT_CONFIG_PATH}`);
|
||||
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
|
||||
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
|
||||
|
||||
cleanupLegacyCodexAgentsMdContext();
|
||||
|
||||
console.log(`
|
||||
Installation complete!
|
||||
|
||||
Transcript watch config: ${DEFAULT_CONFIG_PATH}
|
||||
Context files: <workspace>/AGENTS.md
|
||||
|
||||
How it works:
|
||||
- claude-mem watches Codex session JSONL files for new activity
|
||||
- No hooks needed -- transcript watching is fully automatic
|
||||
- Context from past sessions is injected via AGENTS.md in the active Codex workspace
|
||||
|
||||
Next steps:
|
||||
1. Start claude-mem worker: npx claude-mem start
|
||||
2. Use Codex CLI as usual -- memory capture is automatic!
|
||||
`);
|
||||
}
|
||||
|
||||
export function uninstallCodexCli(): number {
|
||||
console.log('\nUninstalling Claude-Mem Codex CLI integration...\n');
|
||||
|
||||
if (existsSync(DEFAULT_CONFIG_PATH)) {
|
||||
const config = loadExistingTranscriptWatchConfig();
|
||||
let failed = false;
|
||||
|
||||
config.watches = config.watches.filter(
|
||||
(w: WatchTarget) => w.name !== CODEX_WATCH_NAME,
|
||||
);
|
||||
|
||||
if (config.schemas) {
|
||||
delete config.schemas[CODEX_WATCH_NAME];
|
||||
try {
|
||||
if (commandExists('codex')) {
|
||||
runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
|
||||
} else {
|
||||
console.log(' Codex CLI not found; skipping marketplace removal.');
|
||||
}
|
||||
|
||||
try {
|
||||
writeTranscriptWatchConfig(config);
|
||||
console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`\nUninstallation failed: ${message}`);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
console.log(' No transcript-watch.json found -- nothing to remove.');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`\nCodex marketplace removal failed: ${message}`);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
cleanupLegacyCodexAgentsMdContext();
|
||||
try {
|
||||
if (!cleanupLegacyCodexAgentsMdContext()) {
|
||||
console.error(`\nFailed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`);
|
||||
failed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`\nLegacy AGENTS.md cleanup failed: ${message}`);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error('\nUninstallation completed with errors.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log('\nUninstallation complete!');
|
||||
console.log('Restart claude-mem worker to apply changes.\n');
|
||||
console.log('Restart Codex CLI to apply changes.\n');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ import { TimelineService } from './worker/TimelineService.js';
|
||||
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
|
||||
import { SessionCompletionHandler } from './worker/session/SessionCompletionHandler.js';
|
||||
import { setIngestContext, attachIngestGeneratorStarter } from './worker/http/shared.js';
|
||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './transcripts/config.js';
|
||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig } from './transcripts/config.js';
|
||||
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||
|
||||
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
|
||||
@@ -128,9 +128,7 @@ export class WorkerService implements WorkerRef {
|
||||
private searchRoutes: SearchRoutes | null = null;
|
||||
|
||||
private chromaMcpManager: ChromaMcpManager | null = null;
|
||||
|
||||
private transcriptWatcher: TranscriptWatcher | null = null;
|
||||
|
||||
private initializationComplete: Promise<void>;
|
||||
private resolveInitialization!: () => void;
|
||||
|
||||
@@ -237,26 +235,12 @@ export class WorkerService implements WorkerRef {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = 120000;
|
||||
const timeoutPromise = new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Database initialization timeout')), timeoutMs)
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.race([this.initializationComplete, timeoutPromise]);
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized`, {}, error);
|
||||
} else {
|
||||
logger.error('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized with non-Error`, {}, new Error(String(error)));
|
||||
}
|
||||
res.status(503).json({
|
||||
error: 'Service initializing',
|
||||
message: 'Database is still initializing, please retry'
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.debug('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized`);
|
||||
res.status(503).json({
|
||||
error: 'Service initializing',
|
||||
message: 'Database is still initializing, please retry'
|
||||
});
|
||||
return;
|
||||
});
|
||||
|
||||
this.server.registerRoutes(new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager));
|
||||
@@ -461,10 +445,10 @@ export class WorkerService implements WorkerRef {
|
||||
const resolvedConfigPath = expandHomePath(configPath);
|
||||
|
||||
if (!existsSync(resolvedConfigPath)) {
|
||||
writeSampleConfig(configPath);
|
||||
logger.info('TRANSCRIPT', 'Created default transcript watch config', {
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher config not found; skipping automatic transcript capture', {
|
||||
configPath: resolvedConfigPath
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const transcriptConfig = loadTranscriptWatchConfig(configPath);
|
||||
@@ -477,11 +461,11 @@ export class WorkerService implements WorkerRef {
|
||||
this.transcriptWatcher?.stop();
|
||||
this.transcriptWatcher = null;
|
||||
if (error instanceof Error) {
|
||||
logger.error('WORKER', 'Failed to start transcript watcher (continuing without Codex ingestion)', {
|
||||
logger.error('WORKER', 'Failed to start transcript watcher (continuing without transcript ingestion)', {
|
||||
configPath: resolvedConfigPath
|
||||
}, error);
|
||||
} else {
|
||||
logger.error('WORKER', 'Failed to start transcript watcher with non-Error (continuing without Codex ingestion)', {
|
||||
logger.error('WORKER', 'Failed to start transcript watcher with non-Error (continuing without transcript ingestion)', {
|
||||
configPath: resolvedConfigPath
|
||||
}, new Error(String(error)));
|
||||
}
|
||||
@@ -864,7 +848,7 @@ async function main() {
|
||||
const event = process.argv[4];
|
||||
if (!platform || !event) {
|
||||
console.error('Usage: claude-mem hook <platform> <event>');
|
||||
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
|
||||
console.error('Platforms: claude-code, codex, cursor, gemini-cli, raw');
|
||||
console.error('Events: context, session-init, observation, summarize, user-message');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const HOOK_TIMEOUTS = {
|
||||
DEFAULT: 300000, // Standard HTTP timeout (5 min for slow systems)
|
||||
HEALTH_CHECK: 3000, // Worker health check (3s — healthy worker responds in <100ms)
|
||||
API_REQUEST: 30000, // Hook API calls should outlive health probes but stay below hook caps
|
||||
HOOK_READINESS_WAIT: 10000, // Per-hook wait for an already-starting worker to finish DB/search init
|
||||
POST_SPAWN_WAIT: 15000, // Wait for daemon to start after spawn (starts in <1s on Linux, 6-8s on macOS with Chroma)
|
||||
READINESS_WAIT: 30000, // Wait for DB + search init after spawn (typically <5s)
|
||||
PORT_IN_USE_WAIT: 3000, // Wait when port occupied but health failing
|
||||
|
||||
@@ -9,19 +9,41 @@ import { MARKETPLACE_ROOT, DATA_DIR } from "./paths.js";
|
||||
import { loadFromFileOnce } from "./hook-settings.js";
|
||||
import { validateWorkerPidFile } from "../supervisor/index.js";
|
||||
|
||||
const HEALTH_CHECK_TIMEOUT_MS = (() => {
|
||||
const envVal = process.env.CLAUDE_MEM_HEALTH_TIMEOUT_MS;
|
||||
function readTimeoutEnv(
|
||||
envName: string,
|
||||
defaultValue: number,
|
||||
bounds: { min: number; max: number }
|
||||
): number {
|
||||
const envVal = process.env[envName];
|
||||
if (envVal) {
|
||||
const parsed = parseInt(envVal, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 500 && parsed <= 300000) {
|
||||
if (Number.isFinite(parsed) && parsed >= bounds.min && parsed <= bounds.max) {
|
||||
return parsed;
|
||||
}
|
||||
logger.warn('SYSTEM', 'Invalid CLAUDE_MEM_HEALTH_TIMEOUT_MS, using default', {
|
||||
value: envVal, min: 500, max: 300000
|
||||
logger.warn('SYSTEM', `Invalid ${envName}, using default`, {
|
||||
value: envVal, min: bounds.min, max: bounds.max
|
||||
});
|
||||
}
|
||||
return getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
})();
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const HEALTH_CHECK_TIMEOUT_MS = readTimeoutEnv(
|
||||
'CLAUDE_MEM_HEALTH_TIMEOUT_MS',
|
||||
getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK),
|
||||
{ min: 500, max: 300000 }
|
||||
);
|
||||
|
||||
const API_REQUEST_TIMEOUT_MS = readTimeoutEnv(
|
||||
'CLAUDE_MEM_API_TIMEOUT_MS',
|
||||
getTimeout(HOOK_TIMEOUTS.API_REQUEST),
|
||||
{ min: 500, max: 300000 }
|
||||
);
|
||||
|
||||
const HOOK_READINESS_TIMEOUT_MS = readTimeoutEnv(
|
||||
'CLAUDE_MEM_HOOK_READINESS_TIMEOUT_MS',
|
||||
getTimeout(HOOK_TIMEOUTS.HOOK_READINESS_WAIT),
|
||||
{ min: 0, max: 300000 }
|
||||
);
|
||||
|
||||
export function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs: number): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -80,7 +102,7 @@ export function workerHttpRequest(
|
||||
} = {}
|
||||
): Promise<Response> {
|
||||
const method = options.method ?? 'GET';
|
||||
const timeoutMs = options.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
|
||||
const timeoutMs = options.timeoutMs ?? API_REQUEST_TIMEOUT_MS;
|
||||
|
||||
const url = buildWorkerUrl(apiPath);
|
||||
const init: RequestInit = { method };
|
||||
@@ -102,6 +124,11 @@ async function isWorkerHealthy(): Promise<boolean> {
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
async function isWorkerReady(): Promise<boolean> {
|
||||
const response = await workerHttpRequest('/api/readiness', { timeoutMs: HEALTH_CHECK_TIMEOUT_MS });
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
function getPluginVersion(): string {
|
||||
try {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
@@ -203,6 +230,32 @@ async function waitForWorkerPort(options: { attempts: number; backoffMs: number
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForWorkerReadiness(timeoutMs: number = HOOK_READINESS_TIMEOUT_MS): Promise<boolean> {
|
||||
if (timeoutMs <= 0) {
|
||||
try {
|
||||
return await isWorkerReady();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
if (await isWorkerReady()) return true;
|
||||
} catch (error: unknown) {
|
||||
logger.debug('SYSTEM', 'Worker readiness check threw', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
const remainingMs = timeoutMs - (Date.now() - start);
|
||||
if (remainingMs <= 0) break;
|
||||
await new Promise<void>(resolve => setTimeout(resolve, Math.min(250, remainingMs)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isWorkerPortAlive(): Promise<boolean> {
|
||||
let healthy: boolean;
|
||||
try {
|
||||
@@ -224,6 +277,11 @@ async function isWorkerPortAlive(): Promise<boolean> {
|
||||
export async function ensureWorkerRunning(): Promise<boolean> {
|
||||
if (await isWorkerPortAlive()) {
|
||||
await checkWorkerVersion();
|
||||
const ready = await waitForWorkerReadiness();
|
||||
if (!ready) {
|
||||
logger.warn('SYSTEM', 'Worker is healthy but not ready; skipping hook API call');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -263,6 +321,11 @@ export async function ensureWorkerRunning(): Promise<boolean> {
|
||||
logger.warn('SYSTEM', 'Worker port did not open after lazy-spawn within 3 attempts');
|
||||
return false;
|
||||
}
|
||||
const ready = await waitForWorkerReadiness();
|
||||
if (!ready) {
|
||||
logger.warn('SYSTEM', 'Worker lazy-spawned but did not become ready before hook readiness timeout');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -400,8 +463,19 @@ export async function executeWithWorkerFallback<T = unknown>(
|
||||
|
||||
const response = await workerHttpRequest(url, init);
|
||||
if (!response.ok) {
|
||||
resetWorkerFailureCounter();
|
||||
const text = await response.text().catch(() => '');
|
||||
resetWorkerFailureCounter();
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
logger.warn('SYSTEM', `Worker API ${method} ${url} returned ${response.status}; skipping hook API call`, {
|
||||
body: text.substring(0, 200),
|
||||
});
|
||||
return {
|
||||
continue: true,
|
||||
reason: `worker_api_${response.status}`,
|
||||
[WORKER_FALLBACK_BRAND]: true,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: unknown = text;
|
||||
try { parsed = JSON.parse(text); } catch { /* keep raw text */ }
|
||||
return parsed as T;
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module 'shell-quote' {
|
||||
export type ParsedToken = string | { op: string } | Record<string, unknown>;
|
||||
export function parse(command: string): ParsedToken[];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { extractFilePaths } from '../../../src/cli/adapters/codex-file-context.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'codex-file-context-'));
|
||||
writeFileSync(join(tmpDir, 'README.md'), 'readme');
|
||||
writeFileSync(join(tmpDir, 'src.ts'), 'source');
|
||||
writeFileSync(join(tmpDir, 'notes.txt'), 'notes');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('extractFilePaths', () => {
|
||||
it('extracts existing files from Codex Bash read commands', () => {
|
||||
const paths = extractFilePaths('Bash', {
|
||||
command: 'cat README.md && head -n 20 src.ts && cat missing.md',
|
||||
}, tmpDir);
|
||||
|
||||
expect(paths).toEqual(['README.md', 'src.ts']);
|
||||
});
|
||||
|
||||
it('does not consume cat boolean flags as file arguments', () => {
|
||||
const paths = extractFilePaths('Bash', {
|
||||
command: 'cat -n README.md',
|
||||
}, tmpDir);
|
||||
|
||||
expect(paths).toEqual(['README.md']);
|
||||
});
|
||||
|
||||
it('ignores non-read Bash commands', () => {
|
||||
const paths = extractFilePaths('Bash', {
|
||||
command: 'rm README.md; echo src.ts',
|
||||
}, tmpDir);
|
||||
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts MCP read tool path arrays', () => {
|
||||
const paths = extractFilePaths('mcp__local_filesystem__read_file', {
|
||||
paths: ['README.md', 'notes.txt', 'missing.txt'],
|
||||
}, tmpDir);
|
||||
|
||||
expect(paths).toEqual(['README.md', 'notes.txt']);
|
||||
});
|
||||
|
||||
it('extracts MCP exact read/view/cat tool names', () => {
|
||||
expect(extractFilePaths('mcp__fs__read', { path: 'README.md' }, tmpDir)).toEqual(['README.md']);
|
||||
expect(extractFilePaths('mcp__fs__view_files', { paths: ['README.md'] }, tmpDir)).toEqual(['README.md']);
|
||||
});
|
||||
|
||||
it('ignores MCP tool names that only contain read verbs as a prefix', () => {
|
||||
expect(extractFilePaths('mcp__fs__read_write', { path: 'README.md' }, tmpDir)).toEqual([]);
|
||||
expect(extractFilePaths('mcp__server__readonly', { path: 'README.md' }, tmpDir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,12 @@ mock.module('../../../src/shared/hook-settings.js', () => ({
|
||||
}));
|
||||
|
||||
let mockExtractedMessage: string = '';
|
||||
let extractCallCount = 0;
|
||||
mock.module('../../../src/shared/transcript-parser.js', () => ({
|
||||
extractLastMessage: () => mockExtractedMessage,
|
||||
extractLastMessage: () => {
|
||||
extractCallCount += 1;
|
||||
return mockExtractedMessage;
|
||||
},
|
||||
}));
|
||||
|
||||
const workerCallLog: Array<{ path: string; method: string; body: any }> = [];
|
||||
@@ -44,6 +48,7 @@ let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
beforeEach(() => {
|
||||
workerCallLog.length = 0;
|
||||
mockExtractedMessage = '';
|
||||
extractCallCount = 0;
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
@@ -72,6 +77,39 @@ function postedBody(): any {
|
||||
}
|
||||
|
||||
describe('summarizeHandler — privacy tag stripping', () => {
|
||||
it('uses Codex lastAssistantMessage directly without reading a transcript', async () => {
|
||||
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
|
||||
const result = await summarizeHandler.execute({
|
||||
sessionId: 'sess-codex',
|
||||
cwd: '/tmp',
|
||||
platform: 'codex',
|
||||
lastAssistantMessage: 'Codex answer <private>SECRET</private>',
|
||||
});
|
||||
|
||||
expect(result.continue).toBe(true);
|
||||
expect(extractCallCount).toBe(0);
|
||||
const body = postedBody();
|
||||
expect(body.last_assistant_message).toBe('Codex answer');
|
||||
expect(body.platformSource).toBe('codex');
|
||||
});
|
||||
|
||||
it('short-circuits Codex stop hook re-entry', async () => {
|
||||
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
|
||||
const result = await summarizeHandler.execute({
|
||||
sessionId: 'sess-codex',
|
||||
cwd: '/tmp',
|
||||
platform: 'codex',
|
||||
stopHookActive: true,
|
||||
lastAssistantMessage: 'ignored',
|
||||
});
|
||||
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(extractCallCount).toBe(0);
|
||||
expect(workerCallLog).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips <private> tags and their content from last_assistant_message', async () => {
|
||||
mockExtractedMessage = 'Hello <private>SECRET-VALUE-42</private> world';
|
||||
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('Hook Lifecycle - Event Handlers', () => {
|
||||
describe('worker fallback failure counter', () => {
|
||||
it('resets stale unreachable state before 429/5xx API fallbacks', () => {
|
||||
const source = readFileSync('src/shared/worker-utils.ts', 'utf-8');
|
||||
const nonOkRegion = source.slice(
|
||||
source.indexOf('if (!response.ok)'),
|
||||
source.indexOf('const text = await response.text();'),
|
||||
);
|
||||
|
||||
expect(nonOkRegion.indexOf('resetWorkerFailureCounter()'))
|
||||
.toBeLessThan(nonOkRegion.indexOf('response.status === 429 || response.status >= 500'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventHandler', () => {
|
||||
it('should return handler for all recognized event types', async () => {
|
||||
const { getEventHandler } = await import('../src/cli/handlers/index.js');
|
||||
const recognizedTypes = [
|
||||
'context', 'session-init', 'observation',
|
||||
'summarize', 'user-message', 'file-edit'
|
||||
'summarize', 'user-message', 'file-edit', 'file-context'
|
||||
];
|
||||
for (const type of recognizedTypes) {
|
||||
const handler = getEventHandler(type);
|
||||
@@ -35,10 +51,10 @@ describe('Hook Lifecycle - Event Handlers', () => {
|
||||
|
||||
describe('Codex CLI Compatibility (#744)', () => {
|
||||
describe('getPlatformAdapter', () => {
|
||||
it('should return rawAdapter for unknown platforms like codex', async () => {
|
||||
const { getPlatformAdapter, rawAdapter } = await import('../src/cli/adapters/index.js');
|
||||
it('should return codexAdapter for codex', async () => {
|
||||
const { getPlatformAdapter, codexAdapter } = await import('../src/cli/adapters/index.js');
|
||||
const adapter = getPlatformAdapter('codex');
|
||||
expect(adapter).toBe(rawAdapter);
|
||||
expect(adapter).toBe(codexAdapter);
|
||||
});
|
||||
|
||||
it('should return rawAdapter for any unrecognized platform string', async () => {
|
||||
@@ -81,6 +97,120 @@ describe('Codex CLI Compatibility (#744)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('codexAdapter', () => {
|
||||
it('normalizes snake_case Stop payloads with last assistant message', async () => {
|
||||
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
|
||||
const input = codexAdapter.normalizeInput({
|
||||
hook_event_name: 'Stop',
|
||||
session_id: 'codex-session',
|
||||
turn_id: 'turn-1',
|
||||
cwd: '/tmp',
|
||||
stop_hook_active: false,
|
||||
last_assistant_message: 'done',
|
||||
});
|
||||
|
||||
expect(input.sessionId).toBe('codex-session');
|
||||
expect(input.turnId).toBe('turn-1');
|
||||
expect(input.lastAssistantMessage).toBe('done');
|
||||
expect(input.stopHookActive).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes string stop_hook_active payloads', async () => {
|
||||
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
|
||||
const active = codexAdapter.normalizeInput({
|
||||
hook_event_name: 'Stop',
|
||||
session_id: 'codex-session',
|
||||
cwd: '/tmp',
|
||||
stop_hook_active: 'true',
|
||||
});
|
||||
const inactive = codexAdapter.normalizeInput({
|
||||
hook_event_name: 'Stop',
|
||||
session_id: 'codex-session',
|
||||
cwd: '/tmp',
|
||||
stop_hook_active: 'false',
|
||||
});
|
||||
|
||||
expect(active.stopHookActive).toBe(true);
|
||||
expect(inactive.stopHookActive).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects payloads without a session_id', async () => {
|
||||
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
|
||||
const { AdapterRejectedInput } = await import('../src/cli/adapters/errors.js');
|
||||
|
||||
expect(() => codexAdapter.normalizeInput({
|
||||
hook_event_name: 'Stop',
|
||||
cwd: '/tmp',
|
||||
})).toThrow(AdapterRejectedInput);
|
||||
});
|
||||
|
||||
it('adds filePaths without dropping the original object tool input', async () => {
|
||||
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'codex-adapter-'));
|
||||
try {
|
||||
writeFileSync(join(tmpDir, 'README.md'), 'readme');
|
||||
|
||||
const input = codexAdapter.normalizeInput({
|
||||
hook_event_name: 'PreToolUse',
|
||||
session_id: 'codex-session',
|
||||
cwd: tmpDir,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'cat README.md' },
|
||||
});
|
||||
|
||||
expect(input.toolInput).toEqual({
|
||||
command: 'cat README.md',
|
||||
filePaths: ['README.md'],
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves non-object tool input payloads', async () => {
|
||||
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
|
||||
const input = codexAdapter.normalizeInput({
|
||||
hook_event_name: 'PreToolUse',
|
||||
session_id: 'codex-session',
|
||||
cwd: '/tmp',
|
||||
tool_name: 'Bash',
|
||||
tool_input: 'cat README.md',
|
||||
});
|
||||
|
||||
expect(input.toolInput).toBe('cat README.md');
|
||||
});
|
||||
|
||||
it('drops PreToolUse allow decisions because Codex only accepts deny', async () => {
|
||||
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
|
||||
const output = codexAdapter.formatOutput({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: 'file history',
|
||||
permissionDecision: 'allow',
|
||||
},
|
||||
}) as any;
|
||||
|
||||
expect(output.hookSpecificOutput).toEqual({
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: 'file history',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit hookSpecificOutput for Stop outputs', async () => {
|
||||
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
|
||||
const output = codexAdapter.formatOutput({
|
||||
continue: true,
|
||||
suppressOutput: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'Stop',
|
||||
additionalContext: 'ignored',
|
||||
},
|
||||
}) as any;
|
||||
|
||||
expect(output).toEqual({ continue: true, suppressOutput: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-init handler undefined prompt', () => {
|
||||
it('should not throw when prompt is undefined', () => {
|
||||
const rawPrompt: string | undefined = undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, utimesSync, rmSync } from 'fs';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, utimesSync, rmSync } from 'fs';
|
||||
import { tmpdir, homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -181,4 +181,74 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
expect(ctx).not.toContain('Only line 1 was read');
|
||||
expect(ctx).toContain('full requested section');
|
||||
});
|
||||
|
||||
it('accepts a Codex filePaths array and joins per-file context blocks', async () => {
|
||||
const otherFile = join(tmpDir, 'other.md');
|
||||
writeFileSync(otherFile, PADDING);
|
||||
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((url: string | URL | Request) => {
|
||||
const text = String(url);
|
||||
if (text.includes('other.md')) {
|
||||
return Promise.resolve(makeObservationsResponse([{ id: 2, created_at_epoch: future, title: 'Other file context' }]));
|
||||
}
|
||||
return Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future, title: 'Main file context' }]));
|
||||
});
|
||||
|
||||
const result = await fileContextHandler.execute({
|
||||
sessionId: 'sess',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Bash',
|
||||
toolInput: { filePaths: [testFile, otherFile] },
|
||||
});
|
||||
|
||||
const ctx = result.hookSpecificOutput!.additionalContext as string;
|
||||
expect(ctx).toContain('Main file context');
|
||||
expect(ctx).toContain('Other file context');
|
||||
expect(ctx).toContain('\n\n---\n\n');
|
||||
});
|
||||
|
||||
it('keeps successful timelines when one file lookup fails', async () => {
|
||||
const otherFile = join(tmpDir, 'other.md');
|
||||
writeFileSync(otherFile, PADDING);
|
||||
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((url: string | URL | Request) => {
|
||||
const text = String(url);
|
||||
if (text.includes('other.md')) {
|
||||
return Promise.reject(new Error('worker unavailable'));
|
||||
}
|
||||
return Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future, title: 'Main file context' }]));
|
||||
});
|
||||
|
||||
const result = await fileContextHandler.execute({
|
||||
sessionId: 'sess',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Bash',
|
||||
toolInput: { filePaths: [testFile, otherFile] },
|
||||
});
|
||||
|
||||
const ctx = result.hookSpecificOutput!.additionalContext as string;
|
||||
expect(ctx).toContain('Main file context');
|
||||
expect(ctx).not.toContain('worker unavailable');
|
||||
});
|
||||
|
||||
it('skips directories before querying file history', async () => {
|
||||
const directoryPath = join(tmpDir, 'large-dir');
|
||||
mkdirSync(directoryPath);
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
makeObservationsResponse([{ id: 1, created_at_epoch: Date.now() + 60_000 }])
|
||||
);
|
||||
|
||||
const result = await fileContextHandler.execute({
|
||||
sessionId: 'sess',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Bash',
|
||||
toolInput: { filePaths: [directoryPath] },
|
||||
});
|
||||
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.hookSpecificOutput).toBeUndefined();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,22 @@ const installSourcePath = join(
|
||||
'install.ts',
|
||||
);
|
||||
const installSource = readFileSync(installSourcePath, 'utf-8');
|
||||
const codexInstallerSourcePath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'services',
|
||||
'integrations',
|
||||
'CodexCliInstaller.ts',
|
||||
);
|
||||
const codexInstallerSource = readFileSync(codexInstallerSourcePath, 'utf-8');
|
||||
const syncMarketplaceSourcePath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'scripts',
|
||||
'sync-marketplace.cjs',
|
||||
);
|
||||
const syncMarketplaceSource = readFileSync(syncMarketplaceSourcePath, 'utf-8');
|
||||
|
||||
describe('Install Non-TTY Support', () => {
|
||||
describe('isInteractive flag', () => {
|
||||
@@ -76,6 +92,76 @@ describe('Install Non-TTY Support', () => {
|
||||
it('uses console.log for note/summary in non-interactive mode', () => {
|
||||
expect(installSource).toContain("console.log(`\\n ${installStatus}`)");
|
||||
});
|
||||
|
||||
it('copies Codex marketplace metadata to the durable marketplace directory', () => {
|
||||
const copyRegion = installSource.slice(
|
||||
installSource.indexOf('const allowedTopLevelEntries = ['),
|
||||
installSource.indexOf('function copyPluginToCache'),
|
||||
);
|
||||
expect(copyRegion).toContain("'.agents'");
|
||||
expect(copyRegion).toContain("'.codex-plugin'");
|
||||
expect(copyRegion).toContain("'.mcp.json'");
|
||||
});
|
||||
|
||||
it('does not exclude MCP manifests during local marketplace sync', () => {
|
||||
const gitignoreExcludeRegion = syncMarketplaceSource.slice(
|
||||
syncMarketplaceSource.indexOf('function getGitignoreExcludes'),
|
||||
syncMarketplaceSource.indexOf('const branch = getCurrentBranch'),
|
||||
);
|
||||
expect(gitignoreExcludeRegion).toContain("'.mcp.json'");
|
||||
expect(gitignoreExcludeRegion).toContain('syncManagedFiles.has(line)');
|
||||
});
|
||||
|
||||
it('registers Codex against the durable marketplace directory', () => {
|
||||
expect(installSource).toContain('installCodexCli(marketplaceDirectory())');
|
||||
});
|
||||
|
||||
it('captures Codex CLI output for install failure reporting', () => {
|
||||
const runCodexRegion = codexInstallerSource.slice(
|
||||
codexInstallerSource.indexOf('function runCodex'),
|
||||
codexInstallerSource.indexOf('function removeCodexAgentsMdContext'),
|
||||
);
|
||||
expect(runCodexRegion).toContain('spawnSync');
|
||||
expect(runCodexRegion).not.toContain("stdio: 'inherit'");
|
||||
});
|
||||
|
||||
it('checks Codex CLI marketplace version before registration', () => {
|
||||
const installRegion = codexInstallerSource.slice(
|
||||
codexInstallerSource.indexOf('export async function installCodexCli'),
|
||||
codexInstallerSource.indexOf('export function uninstallCodexCli'),
|
||||
);
|
||||
expect(codexInstallerSource).toContain("const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0'");
|
||||
expect(codexInstallerSource).toContain("spawnSync('codex', ['--version']");
|
||||
expect(installRegion.indexOf('assertCodexMarketplaceSupported()'))
|
||||
.toBeLessThan(installRegion.indexOf("runCodex(['plugin', 'marketplace', 'add', marketplaceRoot])"));
|
||||
});
|
||||
|
||||
it('removes legacy Codex AGENTS context only after marketplace registration succeeds', () => {
|
||||
const installRegion = codexInstallerSource.slice(
|
||||
codexInstallerSource.indexOf('export async function installCodexCli'),
|
||||
codexInstallerSource.indexOf('export function uninstallCodexCli'),
|
||||
);
|
||||
expect(installRegion.indexOf("runCodex(['plugin', 'marketplace', 'add', marketplaceRoot])"))
|
||||
.toBeLessThan(installRegion.indexOf('cleanupLegacyCodexAgentsMdContext()'));
|
||||
});
|
||||
|
||||
it('reports legacy Codex AGENTS cleanup failures to callers', () => {
|
||||
expect(codexInstallerSource).toContain('function removeCodexAgentsMdContext(): boolean');
|
||||
expect(codexInstallerSource).toContain('if (!cleanupLegacyCodexAgentsMdContext())');
|
||||
});
|
||||
|
||||
it('does not fail Codex install after marketplace registration when only AGENTS cleanup fails', () => {
|
||||
const installRegion = codexInstallerSource.slice(
|
||||
codexInstallerSource.indexOf('export async function installCodexCli'),
|
||||
codexInstallerSource.indexOf('export function uninstallCodexCli'),
|
||||
);
|
||||
const cleanupFailureRegion = installRegion.slice(
|
||||
installRegion.indexOf('if (!cleanupLegacyCodexAgentsMdContext())'),
|
||||
installRegion.indexOf('Installation complete!'),
|
||||
);
|
||||
expect(cleanupFailureRegion).toContain('console.warn');
|
||||
expect(cleanupFailureRegion).not.toContain('return 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskDescriptor interface', () => {
|
||||
|
||||
Reference in New Issue
Block a user