Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Vladyslav Tezyk
2026-08-12 09:03:00 +02:00
818 changed files with 56046 additions and 6191 deletions
+5
View File
@@ -2,6 +2,7 @@
const fs = require('fs');
const path = require('path');
const { normalizeAgentTools } = require('./agent-tools');
/**
* Parse YAML frontmatter from a markdown string.
@@ -35,6 +36,10 @@ function parseFrontmatter(content) {
value = value.slice(1, -1);
}
if (key === 'tools') {
value = normalizeAgentTools(value);
}
frontmatter[key] = value;
}
+47 -1
View File
@@ -14,6 +14,7 @@
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('./path-safety');
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
@@ -94,6 +95,41 @@ function getDefaultClaudeAgentDataHome() {
return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME);
}
function warnUnsafeProjectConfig() {
console.error(
'[ECC] Ignoring unsafe agent data project config: agentDataHome must stay ' +
'within the default Cursor or Claude data directories. Use ' +
'ECC_AGENT_DATA_HOME for an explicit trusted override.'
);
}
function isSafeProjectConfigSyntax(candidate) {
const trimmed = candidate.trim();
const isUserAnchored = trimmed.startsWith('~') || path.isAbsolute(trimmed);
const hasParentTraversal = trimmed.split(/[/\\]+/).includes('..');
return isUserAnchored && !hasParentTraversal;
}
function resolveAllowedProjectConfigHome(candidate) {
const allowedRoots = [
getDefaultCursorAgentDataHome(),
getDefaultClaudeAgentDataHome(),
];
for (const allowedRoot of allowedRoots) {
try {
return assertWithinTrustedRoot(
candidate,
allowedRoot,
'use project agent data home'
);
} catch {
// Try the next explicitly allowed default root.
}
}
return null;
}
function readProjectConfigAt(configPath) {
if (!configPath || typeof configPath !== 'string') return null;
if (!fs.existsSync(configPath)) return null;
@@ -103,8 +139,18 @@ function readProjectConfigAt(configPath) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME;
if (typeof candidate !== 'string' || !candidate.trim()) return null;
if (!isSafeProjectConfigSyntax(candidate)) {
warnUnsafeProjectConfig();
return null;
}
const projectRoot = resolveProjectRootFromConfigPath(configPath);
return expandHomePath(candidate, projectRoot);
const resolved = expandHomePath(candidate, projectRoot);
const allowedHome = resolveAllowedProjectConfigHome(resolved);
if (!allowedHome) {
warnUnsafeProjectConfig();
return null;
}
return allowedHome;
} catch (error) {
console.error(
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
+97
View File
@@ -0,0 +1,97 @@
'use strict';
function stripSurroundingQuotes(value) {
const trimmed = value.trim();
const quote = trimmed[0];
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function splitTopLevelToolList(value) {
const items = [];
const delimiters = [];
let quote = null;
let escaped = false;
let itemStart = 0;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (quote) {
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === quote) {
quote = null;
}
continue;
}
if (character === '"' || character === "'") {
quote = character;
continue;
}
if (character === '(' || character === '[' || character === '{') {
delimiters.push(character);
continue;
}
const expectedOpener = {
')': '(',
']': '[',
'}': '{',
}[character];
if (expectedOpener && delimiters.at(-1) === expectedOpener) {
delimiters.pop();
continue;
}
if (character === ',' && delimiters.length === 0) {
items.push(value.slice(itemStart, index));
itemStart = index + 1;
}
}
items.push(value.slice(itemStart));
return items;
}
/**
* Normalize Claude agent frontmatter tools to the array shape used internally.
*
* Claude Code expects tools to be a comma-separated scalar. Flow sequences are
* still accepted here so ECC can read legacy or harness-adapted agent files.
*/
function normalizeAgentTools(value) {
if (Array.isArray(value)) {
return value
.filter(item => typeof item === 'string')
.map(stripSurroundingQuotes)
.filter(Boolean);
}
if (typeof value !== 'string') {
return [];
}
const trimmed = value.trim();
const listValue = trimmed.startsWith('[') && trimmed.endsWith(']')
? trimmed.slice(1, -1)
: stripSurroundingQuotes(trimmed);
if (!listValue.trim()) {
return [];
}
return splitTopLevelToolList(listValue)
.map(stripSurroundingQuotes)
.filter(Boolean);
}
module.exports = {
normalizeAgentTools,
};
+39
View File
@@ -0,0 +1,39 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function writeFileAtomic(filePath, content, options = {}) {
const resolvedPath = path.resolve(filePath);
const parentDir = path.dirname(resolvedPath);
const tempPath = path.join(
parentDir,
`.${path.basename(resolvedPath)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`
);
const mode = options.mode || 0o600;
fs.mkdirSync(parentDir, { recursive: true });
let descriptor;
try {
descriptor = fs.openSync(tempPath, 'wx', mode);
fs.writeFileSync(descriptor, content, { encoding: options.encoding || 'utf8' });
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
fs.renameSync(tempPath, resolvedPath);
} catch (error) {
if (descriptor !== undefined) {
fs.closeSync(descriptor);
}
fs.rmSync(tempPath, { force: true });
throw error;
}
return resolvedPath;
}
module.exports = {
writeFileAtomic,
};
+43
View File
@@ -0,0 +1,43 @@
'use strict';
// Claude Code appends a `Co-Authored-By` trailer to commits and PRs unless the
// user opts out, so ECC-managed installs default that off.
//
// Two settings control the trailer. `attribution: { commit, pr }` is the current
// one and wins when set; `includeCoAuthoredBy` is deprecated as of Claude Code
// 2.1.x but still honored, and is the only one older versions understand. We
// write the deprecated key because unknown keys fail settings validation, so
// writing `attribution` would break users on older Claude Code. Either key being
// present counts as a deliberate user choice that ECC must not overwrite.
const COAUTHOR_SETTING_KEY = 'includeCoAuthoredBy';
function hasExplicitCommitAttributionPreference(settings) {
if (!settings || typeof settings !== 'object') {
return false;
}
if (typeof settings[COAUTHOR_SETTING_KEY] === 'boolean') {
return true;
}
const attribution = settings.attribution;
return Boolean(attribution)
&& typeof attribution === 'object'
&& !Array.isArray(attribution)
&& (attribution.commit !== undefined || attribution.pr !== undefined);
}
function withCommitAttributionDisabled(settings) {
if (hasExplicitCommitAttributionPreference(settings)) {
return settings;
}
return {
...settings,
[COAUTHOR_SETTING_KEY]: false,
};
}
module.exports = {
COAUTHOR_SETTING_KEY,
hasExplicitCommitAttributionPreference,
withCommitAttributionDisabled,
};
+676
View File
@@ -0,0 +1,676 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { writeFileAtomic } = require('./atomic-write');
const {
hasExplicitCommitAttributionPreference,
withCommitAttributionDisabled,
} = require('./claude-commit-attribution');
const { normalizeGitHubGitOrigin } = require('./github-origin');
const {
CURRENT_PLUGIN_ID,
LEGACY_PLUGIN_IDS,
findManagedClaudeInstalls,
findManualClaudePlugin,
resolveClaudePaths,
} = require('./install/inventory');
const OFFICIAL_MARKETPLACE_NAME = 'ecc';
const OFFICIAL_MARKETPLACE_REPO = 'affaan-m/ecc';
const OFFICIAL_MARKETPLACE_URL = 'https://github.com/affaan-m/ECC';
const PROVIDER_COMMAND_TIMEOUT_MS = 120 * 1000;
const VALID_SCOPES = new Set(['user', 'project', 'local']);
const VALID_HOOK_MODES = new Set(['off', 'minimal', 'standard', 'strict']);
class ClaudeSetupError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'ClaudeSetupError';
this.code = code;
this.phase = details.phase || 'preflight';
this.observedScopes = [...(details.observedScopes || [])];
this.recovery = [...(details.recovery || [])];
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
phase: this.phase,
observedScopes: [...this.observedScopes],
recovery: [...this.recovery],
},
};
}
}
function fail(code, message, details) {
throw new ClaudeSetupError(code, message, details);
}
function normalizeGitHubRepository(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().replace(/\.git$/i, '').replace(/\/+$/, '');
const match = normalized.match(/^([^/]+\/[^/]+)$/);
return match ? match[1].toLowerCase() : null;
}
function normalizeMarketplaceRepository(marketplace) {
return marketplace?.source === 'github'
? normalizeGitHubRepository(marketplace.repo)
: normalizeGitHubGitOrigin(marketplace?.url);
}
function isOfficialMarketplace(marketplace) {
if (!marketplace || marketplace.name !== OFFICIAL_MARKETPLACE_NAME) return false;
return normalizeMarketplaceRepository(marketplace) === OFFICIAL_MARKETPLACE_REPO;
}
function parseJsonArray(stdout, label) {
let parsed;
try {
parsed = JSON.parse(String(stdout || ''));
} catch (error) {
fail(
`INVALID_${label.toUpperCase()}_INVENTORY`,
`Claude ${label} inventory returned invalid JSON: ${error.message}`
);
}
if (!Array.isArray(parsed)) {
fail(
`INVALID_${label.toUpperCase()}_INVENTORY`,
`Claude ${label} inventory is invalid: expected a JSON array`
);
}
return parsed;
}
function parsePluginList(stdout) {
const plugins = parseJsonArray(stdout, 'plugin');
for (const plugin of plugins) {
const isRelevant = plugin && (
plugin.id === CURRENT_PLUGIN_ID
|| String(plugin.id || '').startsWith('ecc@')
|| LEGACY_PLUGIN_IDS.has(plugin.id)
|| String(plugin.id || '').startsWith('everything-claude-code@')
);
if (!isRelevant) continue;
if (
typeof plugin.id !== 'string'
|| !VALID_SCOPES.has(plugin.scope)
|| typeof plugin.enabled !== 'boolean'
) {
fail(
'INVALID_PLUGIN_INVENTORY',
'Claude plugin inventory contains an invalid ECC plugin entry'
);
}
}
return plugins;
}
function parseMarketplaceList(stdout) {
const marketplaces = parseJsonArray(stdout, 'marketplace');
for (const marketplace of marketplaces) {
if (!marketplace || marketplace.name !== OFFICIAL_MARKETPLACE_NAME) continue;
if (
typeof marketplace.name !== 'string'
|| typeof marketplace.source !== 'string'
|| !['github', 'git'].includes(marketplace.source)
|| !normalizeMarketplaceRepository(marketplace)
) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Claude marketplace inventory contains an invalid `ecc` entry'
);
}
}
return marketplaces;
}
const UNSAFE_WINDOWS_SHELL_CHARS = /[\r\n&|<>^%!]/;
function quoteWindowsCommandToken(value) {
const token = String(value);
if (UNSAFE_WINDOWS_SHELL_CHARS.test(token)) {
throw new Error('Claude Code command contains characters that are unsafe for cmd.exe');
}
if (token === '') return '""';
if (!/[\s"]/.test(token)) return token;
return `"${token.replace(/"/g, '""')}"`;
}
function buildWindowsCommandLine(command, args) {
return [command, ...args].map(quoteWindowsCommandToken).join(' ');
}
function resolveWindowsCmdShim(command, env) {
if (typeof command !== 'string' || command.length === 0) return null;
if (/\.(cmd|bat)$/i.test(command)) return command;
if (path.extname(command)) return null;
const isPathLike = path.isAbsolute(command)
|| command.includes('/')
|| command.includes('\\');
if (isPathLike) {
const candidate = `${command}.cmd`;
return fs.existsSync(candidate) ? candidate : null;
}
const lookup = spawnSync('where.exe', [`${command}.cmd`], {
env,
encoding: 'utf8',
windowsHide: true,
});
if (lookup.error || lookup.status !== 0) return null;
return String(lookup.stdout || '')
.split(/\r?\n/)
.map(line => line.trim())
.find(Boolean) || null;
}
function runClaude(args, options = {}, dependencies = {}) {
const command = options.command || 'claude';
const spawn = dependencies.spawnSync || spawnSync;
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
const spawnOptions = {
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
killSignal: 'SIGKILL',
timeout: timeoutMs,
windowsHide: true,
};
let result = spawn(command, args, spawnOptions);
if (process.platform === 'win32' && result.error) {
const shim = resolveWindowsCmdShim(command, spawnOptions.env);
if (shim) {
let commandLine;
try {
commandLine = buildWindowsCommandLine(shim, args);
} catch (error) {
fail(
'CLAUDE_COMMAND_FAILED',
`Could not run Claude Code: ${error.message}`,
{ phase: options.phase || 'provider' }
);
}
result = spawn(commandLine, {
...spawnOptions,
shell: true,
});
}
}
const timedOut = (
result.error?.code === 'ETIMEDOUT'
|| (result.error?.killed === true && result.error?.signal === spawnOptions.killSignal)
);
if (timedOut) {
fail(
'CLAUDE_COMMAND_FAILED',
`Claude Code command timed out after ${timeoutMs} ms`,
{ phase: options.phase || 'provider' }
);
}
if (result.error) {
if (result.error.code === 'ENOENT') {
fail(
'CLAUDE_NOT_FOUND',
'Claude Code is not installed or `claude` is not on PATH. Install Claude Code, then rerun ECC setup.',
{ phase: options.phase || 'inventory' }
);
}
fail(
'CLAUDE_COMMAND_FAILED',
`Could not run Claude Code: ${result.error.message}`,
{ phase: options.phase || 'provider' }
);
}
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '').trim();
fail(
'CLAUDE_COMMAND_FAILED',
`Claude Code command failed${detail ? `: ${detail}` : ''}`,
{ phase: options.phase || 'provider' }
);
}
return result;
}
function readSettings(settingsPath) {
if (!fs.existsSync(settingsPath)) return {};
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch (error) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: ${error.message}`,
{ phase: 'preflight' }
);
}
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: expected a JSON object`,
{ phase: 'preflight' }
);
}
const pluginConfigs = settings.pluginConfigs;
if (pluginConfigs !== undefined && (
!pluginConfigs
|| typeof pluginConfigs !== 'object'
|| Array.isArray(pluginConfigs)
)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: pluginConfigs must be an object`,
{ phase: 'preflight' }
);
}
const eccConfig = pluginConfigs?.[CURRENT_PLUGIN_ID];
if (eccConfig !== undefined && (
!eccConfig
|| typeof eccConfig !== 'object'
|| Array.isArray(eccConfig)
)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: ${CURRENT_PLUGIN_ID} config must be an object`,
{ phase: 'preflight' }
);
}
if (eccConfig?.options !== undefined && (
!eccConfig.options
|| typeof eccConfig.options !== 'object'
|| Array.isArray(eccConfig.options)
)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: ${CURRENT_PLUGIN_ID} options must be an object`,
{ phase: 'preflight' }
);
}
return settings;
}
function hookOptions(hooks) {
return {
hooks_enabled: hooks !== 'off',
hook_profile: hooks === 'off' ? 'standard' : hooks,
};
}
function readStoredHookOptions(settings) {
const options = settings.pluginConfigs?.[CURRENT_PLUGIN_ID]?.options || {};
return {
hooks_enabled: options.hooks_enabled !== false,
hook_profile: VALID_HOOK_MODES.has(options.hook_profile)
&& options.hook_profile !== 'off'
? options.hook_profile
: 'standard',
};
}
function deriveHookMode(settings) {
const options = readStoredHookOptions(settings);
return options.hooks_enabled ? options.hook_profile : 'off';
}
function withClaudeCommitAttributionPreference(settings) {
return withCommitAttributionDisabled(settings);
}
function needsClaudeCommitAttributionPreferenceWrite(settings) {
return !hasExplicitCommitAttributionPreference(settings);
}
function writeClaudePluginOptions(settingsPath, hooks) {
const settings = readSettings(settingsPath);
const pluginConfigs = settings.pluginConfigs || {};
const eccConfig = pluginConfigs[CURRENT_PLUGIN_ID] || {};
const options = eccConfig.options || {};
const nextOptions = hooks === undefined
? { ...options }
: {
...options,
...hookOptions(hooks),
};
const nextSettings = {
...withClaudeCommitAttributionPreference(settings),
pluginConfigs: {
...pluginConfigs,
[CURRENT_PLUGIN_ID]: {
...eccConfig,
options: nextOptions,
},
},
};
writeFileAtomic(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`);
return settingsPath;
}
function currentEccPlugins(plugins) {
return plugins.filter(plugin => plugin?.id === CURRENT_PLUGIN_ID);
}
function assertNoConflictingEccPlugins(plugins) {
const legacy = plugins.find(plugin => (
LEGACY_PLUGIN_IDS.has(plugin?.id)
|| String(plugin?.id || '').startsWith('everything-claude-code@')
));
if (legacy) {
fail(
'LEGACY_PLUGIN_INSTALLED',
`Legacy plugin ${legacy.id} is installed. Uninstall it before setting up ${CURRENT_PLUGIN_ID}.`,
{
observedScopes: [legacy.scope],
recovery: [`claude plugin uninstall ${legacy.id} --scope ${legacy.scope} --keep-data`],
}
);
}
const conflictingEcc = plugins.find(plugin => (
typeof plugin?.id === 'string'
&& plugin.id.startsWith('ecc@')
&& plugin.id !== CURRENT_PLUGIN_ID
));
if (conflictingEcc) {
fail(
'DUPLICATE_ECC_PLUGIN',
`${conflictingEcc.id} is already installed and would duplicate ECC surfaces. Uninstall it before setting up ${CURRENT_PLUGIN_ID}.`,
{
observedScopes: [conflictingEcc.scope],
recovery: [
`claude plugin uninstall ${conflictingEcc.id} --scope ${conflictingEcc.scope} --keep-data`,
],
}
);
}
}
function inspectPluginInventory(plugins, requestedScope) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
if (installed.length > 1 || new Set(observedScopes).size !== observedScopes.length) {
fail(
'MULTIPLE_PLUGIN_SCOPES',
`${CURRENT_PLUGIN_ID} is installed in multiple scopes. Resolve the duplicate scopes before setup.`,
{ observedScopes }
);
}
if (!requestedScope && installed.length === 0) {
fail(
'SCOPE_REQUIRED',
'A fresh install requires --scope user, project, or local.'
);
}
const scope = requestedScope || installed[0].scope;
if (!VALID_SCOPES.has(scope)) {
fail('INVALID_SCOPE', `Invalid plugin scope: ${scope}`);
}
if (installed.length === 1 && installed[0].scope !== scope) {
fail(
'SCOPE_MOVE_REQUIRED',
`${CURRENT_PLUGIN_ID} is already installed at ${installed[0].scope} scope. Use the scope migration workflow to move it to ${scope}.`,
{
observedScopes,
recovery: [
`ecc setup --mode claude-plugin --scope ${scope} --move-scope --yes`,
],
}
);
}
return {
installed: installed[0] || null,
observedScopes,
scope,
};
}
function assertSafeLocalInventory(options) {
const manual = findManualClaudePlugin(options);
if (manual) {
fail(
'MANUAL_PLUGIN_INSTALL',
`A manual ECC plugin layout exists at ${manual.manifestPath}. Remove or migrate the manual install before setup.`
);
}
let managedInstalls;
try {
managedInstalls = findManagedClaudeInstalls(options);
} catch (error) {
fail('INVALID_MANAGED_STATE', error.message);
}
const overlap = managedInstalls.find(install => install.overlapsPlugin);
if (overlap) {
fail(
'MANAGED_INSTALL_OVERLAP',
`Managed ECC content at ${overlap.statePath} overlaps the Claude plugin. Remove that managed overlap before setup.`
);
}
return managedInstalls;
}
function ensureOfficialMarketplace(options) {
const run = options.run || runClaude;
const existing = options.marketplaces.find(entry => entry?.name === OFFICIAL_MARKETPLACE_NAME);
if (existing && !isOfficialMarketplace(existing)) {
fail(
'MARKETPLACE_COLLISION',
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.'
);
}
if (existing) {
run(
['plugin', 'marketplace', 'update', OFFICIAL_MARKETPLACE_NAME],
{ cwd: options.projectRoot, phase: 'marketplace' }
);
} else {
run(
[
'plugin', 'marketplace', 'add',
OFFICIAL_MARKETPLACE_URL,
'--scope', options.scope,
],
{ cwd: options.projectRoot, phase: 'marketplace' }
);
}
const verified = parseMarketplaceList(
run(
['plugin', 'marketplace', 'list', '--json'],
{ cwd: options.projectRoot, phase: 'marketplace-verification' }
).stdout
).find(entry => entry?.name === OFFICIAL_MARKETPLACE_NAME);
if (!verified || !isOfficialMarketplace(verified)) {
fail(
'MARKETPLACE_VERIFICATION_FAILED',
'Could not verify the official ECC marketplace after the marketplace change.',
{ phase: 'marketplace-verification' }
);
}
return verified;
}
function verifyPluginAtScope(options) {
const run = options.run || runClaude;
const plugins = parsePluginList(
run(
['plugin', 'list', '--json'],
{ cwd: options.projectRoot, phase: options.phase || 'plugin-verification' }
).stdout
);
const installed = currentEccPlugins(plugins);
const valid = (
installed.length === 1
&& installed[0].scope === options.scope
&& installed[0].enabled === true
);
if (!valid) {
fail(
'PLUGIN_VERIFICATION_FAILED',
`Could not verify ${CURRENT_PLUGIN_ID} as enabled only at ${options.scope} scope.`,
{
phase: options.phase || 'plugin-verification',
observedScopes: installed.map(plugin => plugin.scope),
}
);
}
return installed[0];
}
function ensurePluginAtScope(options) {
const run = options.run || runClaude;
const configuredHooks = options.hookConfiguration || hookOptions(options.hooks);
if (options.installed) {
run(
['plugin', 'update', CURRENT_PLUGIN_ID, '--scope', options.scope],
{ cwd: options.projectRoot, phase: 'plugin-update' }
);
return 'updated';
}
run(
[
'plugin', 'install', CURRENT_PLUGIN_ID,
'--scope', options.scope,
'--config', `hooks_enabled=${configuredHooks.hooks_enabled}`,
'--config', `hook_profile=${configuredHooks.hook_profile}`,
],
{ cwd: options.projectRoot, phase: 'plugin-install' }
);
return 'installed';
}
function setupClaudePlugin(options = {}, dependencies = {}) {
const paths = resolveClaudePaths(options);
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
fail('INVALID_HOOK_MODE', `Invalid hook mode: ${options.hooks}`);
}
if (options.scope !== undefined && !VALID_SCOPES.has(options.scope)) {
fail('INVALID_SCOPE', `Invalid plugin scope: ${options.scope}`);
}
const settingsPath = path.join(paths.configDir, 'settings.json');
const initialSettings = readSettings(settingsPath);
assertSafeLocalInventory(paths);
const run = dependencies.runClaude || runClaude;
const plugins = parsePluginList(
run(
['plugin', 'list', '--json'],
{ cwd: paths.projectRoot, phase: 'inventory' }
).stdout
);
const inventory = inspectPluginInventory(plugins, options.scope);
const hooks = options.hooks === undefined && inventory.installed
? deriveHookMode(initialSettings)
: (options.hooks || 'standard');
const marketplaces = parseMarketplaceList(
run(
['plugin', 'marketplace', 'list', '--json'],
{ cwd: paths.projectRoot, phase: 'marketplace-inventory' }
).stdout
);
const namedMarketplace = marketplaces.find(entry => (
entry?.name === OFFICIAL_MARKETPLACE_NAME
));
if (namedMarketplace && !isOfficialMarketplace(namedMarketplace)) {
fail(
'MARKETPLACE_COLLISION',
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.'
);
}
if (options.dryRun) {
return {
action: inventory.installed ? 'would-update' : 'would-install',
dryRun: true,
hooks,
marketplaceAction: namedMarketplace ? 'would-update' : 'would-add',
pluginId: CURRENT_PLUGIN_ID,
scope: inventory.scope,
};
}
ensureOfficialMarketplace({
marketplaces,
projectRoot: paths.projectRoot,
run,
scope: inventory.scope,
});
const action = ensurePluginAtScope({
hooks,
installed: inventory.installed,
projectRoot: paths.projectRoot,
run,
scope: inventory.scope,
});
verifyPluginAtScope({
phase: 'plugin-verification',
projectRoot: paths.projectRoot,
run,
scope: inventory.scope,
});
const hooksToPersist = options.hooks !== undefined || !inventory.installed
? hooks
: undefined;
if (
options.hooks !== undefined
|| !inventory.installed
|| needsClaudeCommitAttributionPreferenceWrite(initialSettings)
) {
writeClaudePluginOptions(settingsPath, hooksToPersist);
}
return {
action,
hooks,
pluginId: CURRENT_PLUGIN_ID,
restartRequired: true,
scope: inventory.scope,
settingsPath,
};
}
module.exports = {
ClaudeSetupError,
CURRENT_PLUGIN_ID,
OFFICIAL_MARKETPLACE_NAME,
OFFICIAL_MARKETPLACE_URL,
PROVIDER_COMMAND_TIMEOUT_MS,
VALID_HOOK_MODES,
VALID_SCOPES,
buildWindowsCommandLine,
assertNoConflictingEccPlugins,
assertSafeLocalInventory,
currentEccPlugins,
deriveHookMode,
ensureOfficialMarketplace,
ensurePluginAtScope,
hookOptions,
inspectPluginInventory,
isOfficialMarketplace,
parseMarketplaceList,
parsePluginList,
readStoredHookOptions,
readSettings,
runClaude,
setupClaudePlugin,
verifyPluginAtScope,
needsClaudeCommitAttributionPreferenceWrite,
withClaudeCommitAttributionPreference,
writeClaudePluginOptions,
};
+404
View File
@@ -0,0 +1,404 @@
'use strict';
const path = require('path');
const {
ClaudeSetupError,
CURRENT_PLUGIN_ID,
OFFICIAL_MARKETPLACE_URL,
VALID_HOOK_MODES,
VALID_SCOPES,
assertNoConflictingEccPlugins,
assertSafeLocalInventory,
currentEccPlugins,
deriveHookMode,
ensureOfficialMarketplace,
ensurePluginAtScope,
hookOptions,
isOfficialMarketplace,
needsClaudeCommitAttributionPreferenceWrite,
parseMarketplaceList,
parsePluginList,
readSettings,
readStoredHookOptions,
runClaude,
writeClaudePluginOptions,
} = require('./claude-plugin-setup');
const { resolveClaudePaths } = require('./install/inventory');
function migrationError(code, message, details = {}) {
return new ClaudeSetupError(code, message, details);
}
function recoveryCommands(sourceScope, destinationScope) {
const commands = [];
if (sourceScope) {
commands.push(
`claude plugin uninstall ${CURRENT_PLUGIN_ID} --scope ${sourceScope} --keep-data`
);
}
commands.push(
`ecc setup --mode claude-plugin --scope ${destinationScope} --move-scope --yes`
);
return commands;
}
function readPluginInventory(run, projectRoot, phase) {
return parsePluginList(
run(
['plugin', 'list', '--json'],
{ cwd: projectRoot, phase }
).stdout
);
}
function assertMigrationInventory(plugins, destinationScope) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
const uniqueScopes = new Set(observedScopes);
if (installed.length === 0) {
throw migrationError(
'PLUGIN_NOT_INSTALLED',
`${CURRENT_PLUGIN_ID} is not installed, so there is no source scope to migrate.`,
{
observedScopes,
recovery: [
`ecc setup --mode claude-plugin --scope ${destinationScope} --yes`,
],
}
);
}
if (
installed.length > 2
|| uniqueScopes.size !== installed.length
|| (
installed.length === 2
&& !uniqueScopes.has(destinationScope)
)
) {
throw migrationError(
'AMBIGUOUS_PLUGIN_SCOPES',
`Cannot safely migrate ${CURRENT_PLUGIN_ID} from ambiguous scopes: ${observedScopes.join(', ')}.`,
{ observedScopes }
);
}
if (installed.length === 1 && installed[0].scope === destinationScope) {
if (installed[0].enabled !== true) {
throw migrationError(
'DESTINATION_VERIFICATION_FAILED',
`${CURRENT_PLUGIN_ID} exists at ${destinationScope} scope but is not enabled.`,
{
phase: 'destination-verification',
observedScopes,
recovery: recoveryCommands(null, destinationScope),
}
);
}
return {
destination: installed[0],
mode: 'already-migrated',
observedScopes,
sourceScope: null,
};
}
if (installed.length === 1) {
return {
destination: null,
mode: 'migrate',
observedScopes,
sourceScope: installed[0].scope,
};
}
return {
destination: installed.find(plugin => plugin.scope === destinationScope),
mode: 'resume',
observedScopes,
sourceScope: installed.find(plugin => plugin.scope !== destinationScope).scope,
};
}
function validateExpectedScopes(plugins, expectedScopes, options = {}) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
const actual = [...observedScopes].sort();
const expected = [...expectedScopes].sort();
const destination = installed.find(plugin => plugin.scope === options.destinationScope);
const matches = (
actual.length === expected.length
&& actual.every((scope, index) => scope === expected[index])
&& destination?.enabled === true
);
if (!matches) {
throw migrationError(
options.code,
options.message,
{
phase: options.phase,
observedScopes,
recovery: options.recovery || [],
}
);
}
return installed;
}
function plannedActions(migration, destinationScope, marketplaceAction, hookConfiguration) {
const actions = [];
if (migration.mode === 'migrate') {
actions.push(marketplaceAction);
actions.push([
'plugin', 'install', CURRENT_PLUGIN_ID,
'--scope', destinationScope,
'--config', `hooks_enabled=${hookConfiguration.hooks_enabled}`,
'--config', `hook_profile=${hookConfiguration.hook_profile}`,
]);
}
actions.push(['plugin', 'list', '--json']);
actions.push(['plugin', 'list', '--json']);
actions.push([
'plugin', 'uninstall', CURRENT_PLUGIN_ID,
'--scope', migration.sourceScope,
'--keep-data',
]);
actions.push(['plugin', 'list', '--json']);
return actions;
}
function verifySourceAndDestination(run, paths, migration, destinationScope, phase) {
const expectedScopes = [migration.sourceScope, destinationScope];
return validateExpectedScopes(
readPluginInventory(run, paths.projectRoot, phase),
expectedScopes,
{
code: phase === 'concurrency-check'
? 'CONCURRENT_SCOPE_CHANGE'
: 'DESTINATION_VERIFICATION_FAILED',
destinationScope,
message: phase === 'concurrency-check'
? 'Claude plugin scopes changed during migration; the source was not removed.'
: `Could not verify ${CURRENT_PLUGIN_ID} at the destination before source cleanup.`,
phase,
recovery: recoveryCommands(null, destinationScope),
}
);
}
function uninstallSource(run, paths, migration, destinationScope) {
const args = [
'plugin', 'uninstall', CURRENT_PLUGIN_ID,
'--scope', migration.sourceScope,
'--keep-data',
];
try {
run(args, { cwd: paths.projectRoot, phase: 'source-uninstall' });
return [];
} catch {
let observedScopes = [migration.sourceScope, destinationScope];
try {
const plugins = readPluginInventory(
run,
paths.projectRoot,
'source-uninstall-verification'
);
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
observedScopes = installed.map(plugin => plugin.scope);
if (
installed.length === 1
&& installed[0].scope === destinationScope
&& installed[0].enabled === true
) {
return ['Claude reported an uninstall error, but destination-only state was verified.'];
}
} catch {
// Preserve the safest known two-scope state in the structured recovery.
}
throw migrationError(
'SOURCE_UNINSTALL_FAILED',
`The destination is installed, but Claude could not remove the ${migration.sourceScope} source scope.`,
{
phase: 'source-uninstall',
observedScopes,
recovery: recoveryCommands(migration.sourceScope, destinationScope),
}
);
}
}
function verifyFinalState(run, paths, destinationScope) {
const plugins = readPluginInventory(run, paths.projectRoot, 'final-verification');
return validateExpectedScopes(plugins, [destinationScope], {
code: 'FINAL_VERIFICATION_FAILED',
destinationScope,
message: `Could not verify destination-only ${CURRENT_PLUGIN_ID} state after source cleanup.`,
phase: 'final-verification',
recovery: recoveryCommands(null, destinationScope),
});
}
function migrateClaudePluginScope(options = {}, dependencies = {}) {
if (!VALID_SCOPES.has(options.scope)) {
throw migrationError(
'INVALID_SCOPE',
'Scope migration requires --scope user, project, or local.'
);
}
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
throw migrationError('INVALID_HOOK_MODE', `Invalid hook mode: ${options.hooks}`);
}
const paths = resolveClaudePaths(options);
const settingsPath = path.join(paths.configDir, 'settings.json');
const settings = readSettings(settingsPath);
assertSafeLocalInventory(paths);
const run = dependencies.runClaude || runClaude;
const plugins = readPluginInventory(run, paths.projectRoot, 'inventory');
const migration = assertMigrationInventory(plugins, options.scope);
const hooks = options.hooks === undefined
? deriveHookMode(settings)
: options.hooks;
const hookConfiguration = options.hooks === undefined
? readStoredHookOptions(settings)
: hookOptions(options.hooks);
const needsCommitAttributionPreference = needsClaudeCommitAttributionPreferenceWrite(settings);
const marketplaces = parseMarketplaceList(
run(
['plugin', 'marketplace', 'list', '--json'],
{ cwd: paths.projectRoot, phase: 'marketplace-inventory' }
).stdout
);
const namedMarketplace = marketplaces.find(entry => entry?.name === 'ecc');
if (namedMarketplace && !isOfficialMarketplace(namedMarketplace)) {
throw migrationError(
'MARKETPLACE_COLLISION',
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.',
{
phase: 'marketplace-inventory',
observedScopes: migration.observedScopes,
}
);
}
if (migration.mode === 'already-migrated') {
const result = {
action: 'already-migrated',
hooks,
pluginId: CURRENT_PLUGIN_ID,
sourceScope: null,
scope: options.scope,
};
if (options.dryRun) {
return {
...result,
dryRun: true,
preferencesUpdated: false,
plannedActions: [
...(options.hooks === undefined ? [] : [{
action: 'write-hook-preferences',
...hookConfiguration,
}]),
...(needsCommitAttributionPreference ? [{
action: 'write-commit-attribution-preference',
includeCoAuthoredBy: false,
}] : []),
],
};
}
if (options.hooks !== undefined || needsCommitAttributionPreference) {
writeClaudePluginOptions(
settingsPath,
options.hooks !== undefined ? options.hooks : undefined
);
return { ...result, preferencesUpdated: true };
}
return result;
}
let marketplaceAction = null;
if (migration.mode === 'migrate') {
marketplaceAction = namedMarketplace
? ['plugin', 'marketplace', 'update', 'ecc']
: [
'plugin', 'marketplace', 'add',
OFFICIAL_MARKETPLACE_URL,
'--scope', options.scope,
];
}
if (options.dryRun) {
return {
action: migration.mode === 'resume' ? 'would-resume' : 'would-migrate',
dryRun: true,
hooks,
plannedActions: plannedActions(
migration,
options.scope,
marketplaceAction,
hookConfiguration
),
pluginId: CURRENT_PLUGIN_ID,
sourceScope: migration.sourceScope,
scope: options.scope,
};
}
if (migration.mode === 'migrate') {
ensureOfficialMarketplace({
marketplaces,
projectRoot: paths.projectRoot,
run,
scope: options.scope,
});
ensurePluginAtScope({
hookConfiguration,
hooks,
installed: false,
projectRoot: paths.projectRoot,
run,
scope: options.scope,
});
}
verifySourceAndDestination(
run,
paths,
migration,
options.scope,
'destination-verification'
);
verifySourceAndDestination(
run,
paths,
migration,
options.scope,
'concurrency-check'
);
const warnings = uninstallSource(run, paths, migration, options.scope);
verifyFinalState(run, paths, options.scope);
if (options.hooks !== undefined || needsCommitAttributionPreference) {
writeClaudePluginOptions(
settingsPath,
options.hooks !== undefined ? options.hooks : undefined
);
}
const result = {
action: migration.mode === 'resume' ? 'resumed' : 'migrated',
hooks,
pluginId: CURRENT_PLUGIN_ID,
sourceScope: migration.sourceScope,
scope: options.scope,
};
return warnings.length > 0 ? { ...result, warnings } : result;
}
module.exports = {
migrateClaudePluginScope,
};
+478
View File
@@ -0,0 +1,478 @@
'use strict';
const { execFile: nodeExecFile } = require('child_process');
const path = require('path');
const { normalizeGitHubGitOrigin } = require('./github-origin');
const CODEX_PLUGIN_ID = 'ecc@ecc';
const OFFICIAL_MARKETPLACE_NAME = 'ecc';
const OFFICIAL_MARKETPLACE_REPO = 'affaan-m/ECC';
const NORMALIZED_OFFICIAL_MARKETPLACE_REPO = OFFICIAL_MARKETPLACE_REPO.toLowerCase();
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
const PROVIDER_COMMAND_TIMEOUT_MS = 120 * 1000;
class CodexPluginSetupError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'CodexPluginSetupError';
this.code = code;
this.phase = details.phase || 'inventory';
this.argv = [...(details.argv || [])];
}
}
function fail(code, message, details) {
throw new CodexPluginSetupError(code, message, details);
}
function parseJsonObject(stdout, inventoryName, phase = 'inventory') {
let parsed;
try {
parsed = JSON.parse(String(stdout || ''));
} catch (error) {
fail(
`INVALID_${inventoryName.toUpperCase()}_INVENTORY`,
`Codex ${inventoryName} inventory returned invalid JSON: ${error.message}`,
{ phase }
);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
fail(
`INVALID_${inventoryName.toUpperCase()}_INVENTORY`,
`Codex ${inventoryName} inventory is invalid: expected a JSON object`,
{ phase }
);
}
return parsed;
}
function parseMarketplaceInventory(stdout, phase) {
const inventory = parseJsonObject(stdout, 'marketplace', phase);
if (!Array.isArray(inventory.marketplaces)) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Codex marketplace inventory is invalid: expected `marketplaces` to be an array',
{ phase }
);
}
for (const marketplace of inventory.marketplaces) {
if (
!marketplace
|| typeof marketplace.name !== 'string'
|| marketplace.name.length === 0
|| typeof marketplace.root !== 'string'
|| marketplace.root.length === 0
) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Codex marketplace inventory contains an invalid marketplace entry',
{ phase }
);
}
}
const eccEntries = inventory.marketplaces.filter(
marketplace => marketplace.name === OFFICIAL_MARKETPLACE_NAME
);
if (eccEntries.length > 1) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Codex marketplace inventory contains duplicate `ecc` entries',
{ phase }
);
}
return inventory.marketplaces;
}
function assertPluginEntries(entries, field, phase) {
if (!Array.isArray(entries)) {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory is invalid: expected \`${field}\` to be an array`,
{ phase }
);
}
for (const plugin of entries) {
if (
!plugin
|| typeof plugin.pluginId !== 'string'
|| plugin.pluginId.length === 0
) {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains an invalid \`${field}\` entry`,
{ phase }
);
}
if (plugin.installed !== undefined && typeof plugin.installed !== 'boolean') {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains an invalid \`${field}\` install state`,
{ phase }
);
}
if (plugin.enabled !== undefined && typeof plugin.enabled !== 'boolean') {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains an invalid \`${field}\` enabled state`,
{ phase }
);
}
}
}
function parsePluginInventory(stdout, phase) {
const inventory = parseJsonObject(stdout, 'plugin', phase);
assertPluginEntries(inventory.installed, 'installed', phase);
assertPluginEntries(inventory.available, 'available', phase);
const eccEntries = inventory.installed.filter(
plugin => plugin.pluginId === CODEX_PLUGIN_ID
);
if (eccEntries.length > 1) {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains duplicate ${CODEX_PLUGIN_ID} entries`,
{ phase }
);
}
return {
installed: [...inventory.installed],
available: [...inventory.available],
};
}
function executeFile(execFile, command, args, options) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
if (error.stderr === undefined) error.stderr = stderr;
if (error.stdout === undefined) error.stdout = stdout;
reject(error);
return;
}
resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
});
});
}
function isCommandTimeout(error, killSignal = 'SIGKILL') {
return error?.code === 'ETIMEDOUT'
|| (error?.killed === true && error?.signal === killSignal);
}
async function runCodexCommand(args, options = {}, dependencies = {}) {
const command = dependencies.command || options.command || 'codex';
const execFile = dependencies.execFile || nodeExecFile;
const argv = [...args];
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
const killSignal = 'SIGKILL';
try {
return await executeFile(execFile, command, argv, {
cwd: options.cwd || process.cwd(),
encoding: 'utf8',
env: options.env || process.env,
maxBuffer: MAX_OUTPUT_BYTES,
killSignal,
shell: false,
timeout: timeoutMs,
windowsHide: true,
});
} catch (error) {
if (isCommandTimeout(error, killSignal)) {
fail(
'CODEX_COMMAND_TIMEOUT',
`Codex command timed out after ${timeoutMs} ms`,
{ argv, phase: options.phase }
);
}
if (error?.code === 'ENOENT') {
fail(
'CODEX_NOT_FOUND',
'Codex CLI is not installed or `codex` is not on PATH. Install Codex, then rerun ECC setup.',
{ argv, phase: options.phase }
);
}
const detail = String(error?.stderr || error?.stdout || error?.message || '').trim();
fail(
'CODEX_COMMAND_FAILED',
`Codex command failed${detail ? `: ${detail}` : ''}`,
{ argv, phase: options.phase }
);
}
}
async function resolveMarketplaceRepository(marketplace, options = {}, dependencies = {}) {
const execFile = dependencies.execFile || nodeExecFile;
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
const killSignal = 'SIGKILL';
let result;
try {
result = await executeFile(
execFile,
dependencies.gitCommand || 'git',
['-C', marketplace.root, 'remote', 'get-url', 'origin'],
{
cwd: options.cwd || process.cwd(),
encoding: 'utf8',
env: options.env || process.env,
maxBuffer: MAX_OUTPUT_BYTES,
killSignal,
shell: false,
timeout: timeoutMs,
windowsHide: true,
}
);
} catch (error) {
if (isCommandTimeout(error, killSignal)) {
fail(
'MARKETPLACE_PROVENANCE_TIMEOUT',
`Git provenance verification timed out after ${timeoutMs} ms`,
{ phase: options.phase || 'marketplace-provenance' }
);
}
const detail = String(error?.stderr || error?.message || '').trim();
fail(
'MARKETPLACE_COLLISION',
`Refusing the existing \`ecc\` marketplace because its Git provenance could not be verified${detail ? `: ${detail}` : ''}.`,
{ phase: options.phase || 'marketplace-provenance' }
);
}
return String(result.stdout || '').trim();
}
async function assertOfficialMarketplace(
marketplace,
options,
dependencies,
phase = 'marketplace-provenance'
) {
if (!marketplace) return;
const resolveRepository = dependencies.resolveMarketplaceRepository
|| (entry => resolveMarketplaceRepository(
entry,
{ ...options, phase },
dependencies
));
let repository;
try {
repository = normalizeGitHubGitOrigin(await resolveRepository(marketplace));
} catch (error) {
if (error instanceof CodexPluginSetupError) throw error;
const detail = String(error?.message || error || '').trim();
fail(
'MARKETPLACE_COLLISION',
`Refusing the existing \`ecc\` marketplace because its provenance could not be verified${detail ? `: ${detail}` : ''}.`,
{ phase }
);
}
if (repository !== NORMALIZED_OFFICIAL_MARKETPLACE_REPO) {
fail(
'MARKETPLACE_COLLISION',
'Refusing the existing `ecc` marketplace because it is not the official affaan-m/ECC source.',
{ phase }
);
}
}
function normalizeMarketplaceRoot(value) {
if (typeof value !== 'string' || value.length === 0) return null;
const isWindowsPath = /^[a-z]:[\\/]/i.test(value) || /^\\\\/.test(value);
const normalized = isWindowsPath
? path.win32.normalize(value)
: path.posix.normalize(value);
return isWindowsPath ? normalized.toLowerCase() : normalized;
}
function parseMarketplaceUpgradeResult(stdout, marketplace) {
const phase = 'marketplace-upgrade';
const argv = [
'plugin', 'marketplace', 'upgrade', OFFICIAL_MARKETPLACE_NAME, '--json',
];
let result;
try {
result = JSON.parse(String(stdout || ''));
} catch (error) {
fail(
'INVALID_MARKETPLACE_UPGRADE_RESULT',
`Codex marketplace refresh returned invalid JSON: ${error.message}`,
{ phase, argv }
);
}
const validShape = (
result
&& typeof result === 'object'
&& !Array.isArray(result)
&& Array.isArray(result.selectedMarketplaces)
&& result.selectedMarketplaces.every(name => typeof name === 'string')
&& Array.isArray(result.upgradedRoots)
&& result.upgradedRoots.every(root => typeof root === 'string' && root.length > 0)
&& Array.isArray(result.errors)
);
if (!validShape) {
fail(
'INVALID_MARKETPLACE_UPGRADE_RESULT',
'Codex marketplace refresh returned an invalid result.',
{ phase, argv }
);
}
const expectedRoot = normalizeMarketplaceRoot(marketplace.root);
const upgradedRoot = result.upgradedRoots.length === 1
? normalizeMarketplaceRoot(result.upgradedRoots[0])
: null;
if (
result.errors.length > 0
|| result.selectedMarketplaces.length !== 1
|| result.selectedMarketplaces[0] !== OFFICIAL_MARKETPLACE_NAME
|| upgradedRoot !== expectedRoot
) {
fail(
'MARKETPLACE_REFRESH_FAILED',
'Codex did not confirm that the official ECC marketplace was refreshed.',
{ phase, argv }
);
}
return result;
}
function findEccMarketplace(marketplaces) {
return marketplaces.find(
marketplace => marketplace.name === OFFICIAL_MARKETPLACE_NAME
) || null;
}
function findInstalledEccPlugin(inventory) {
return inventory.installed.find(
plugin => plugin.pluginId === CODEX_PLUGIN_ID
) || null;
}
async function readMarketplaceInventory(run, phase) {
const result = await run(
['plugin', 'marketplace', 'list', '--json'],
{ phase }
);
return parseMarketplaceInventory(result.stdout, phase);
}
async function readPluginInventory(run, phase) {
const result = await run(['plugin', 'list', '--json'], { phase });
return parsePluginInventory(result.stdout, phase);
}
async function reconcileCodexPlugin(options = {}, dependencies = {}) {
const run = (args, details = {}) => runCodexCommand(
args,
{
command: options.command,
cwd: options.cwd,
env: options.env,
phase: details.phase,
},
dependencies
);
const marketplaces = await readMarketplaceInventory(run, 'marketplace-inventory');
const plugins = await readPluginInventory(run, 'plugin-inventory');
const marketplace = findEccMarketplace(marketplaces);
const installedPlugin = findInstalledEccPlugin(plugins);
await assertOfficialMarketplace(marketplace, options, dependencies);
const pluginReady = (
installedPlugin?.installed === true
&& installedPlugin.enabled === true
);
const isReconciled = Boolean(marketplace && pluginReady);
if (options.dryRun) {
return {
action: isReconciled
? 'unchanged'
: (installedPlugin ? 'would-update' : 'would-install'),
dryRun: true,
marketplaceAction: marketplace
? 'would-upgrade'
: 'would-add',
pluginId: CODEX_PLUGIN_ID,
restartRequired: !isReconciled,
};
}
const marketplaceArgs = marketplace
? ['plugin', 'marketplace', 'upgrade', OFFICIAL_MARKETPLACE_NAME, '--json']
: ['plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_REPO, '--json'];
const marketplaceAction = marketplace ? 'upgraded' : 'added';
const marketplaceResult = await run(marketplaceArgs, {
phase: marketplace ? 'marketplace-upgrade' : 'marketplace-add',
});
if (marketplace) {
parseMarketplaceUpgradeResult(marketplaceResult.stdout, marketplace);
}
const verifiedMarketplaces = await readMarketplaceInventory(
run,
'marketplace-verification'
);
if (!findEccMarketplace(verifiedMarketplaces)) {
fail(
'MARKETPLACE_VERIFICATION_FAILED',
'Could not verify the ECC marketplace after reconciliation.',
{ phase: 'marketplace-verification' }
);
}
await assertOfficialMarketplace(
findEccMarketplace(verifiedMarketplaces),
options,
dependencies,
'marketplace-verification'
);
const pluginsAfterMarketplace = marketplace
? await readPluginInventory(run, 'plugin-verification')
: plugins;
const pluginAfterMarketplace = findInstalledEccPlugin(pluginsAfterMarketplace);
const pluginReadyAfterMarketplace = (
pluginAfterMarketplace?.installed === true
&& pluginAfterMarketplace.enabled === true
);
if (!pluginReadyAfterMarketplace) {
await run(
['plugin', 'add', CODEX_PLUGIN_ID, '--json'],
{ phase: 'plugin-add' }
);
}
const verifiedPlugins = pluginReadyAfterMarketplace
? pluginsAfterMarketplace
: await readPluginInventory(run, 'plugin-verification');
const verifiedPlugin = findInstalledEccPlugin(verifiedPlugins);
if (!(verifiedPlugin?.installed === true && verifiedPlugin.enabled === true)) {
fail(
'PLUGIN_VERIFICATION_FAILED',
`Could not verify ${CODEX_PLUGIN_ID} as installed and enabled after reconciliation.`,
{ phase: 'plugin-verification' }
);
}
return {
action: installedPlugin ? 'updated' : 'installed',
marketplaceAction,
pluginId: CODEX_PLUGIN_ID,
restartRequired: marketplaceAction === 'upgraded' || !pluginReadyAfterMarketplace,
};
}
module.exports = {
CODEX_PLUGIN_ID,
CodexPluginSetupError,
OFFICIAL_MARKETPLACE_NAME,
OFFICIAL_MARKETPLACE_REPO,
PROVIDER_COMMAND_TIMEOUT_MS,
executeFile,
findEccMarketplace,
findInstalledEccPlugin,
normalizeGitHubGitOrigin,
parseMarketplaceInventory,
parseMarketplaceUpgradeResult,
parsePluginInventory,
reconcileCodexPlugin,
resolveMarketplaceRepository,
runCodexCommand,
};
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const ITO_COMPUTE_URL = 'https://compute.itomarkets.com';
function getComputeSponsorCopy() {
return "Run or self-host any open-source model. Itô is ECC's preferred compute sponsor: "
+ 'open its dashboard to sign in and rent or manage GPUs at '
+ ITO_COMPUTE_URL
+ '. Any GPU provider works. This sponsorship link is passive: it does not invoke '
+ 'an RFQ, reserve capacity, provision compute, or configure serving. Separately, '
+ 'the opt-in "ecc ito find" bridge invokes the explicitly configured canonical '
+ 'Itô CLI and submits a live authenticated RFQ; it does not reserve capacity. '
+ 'Managed inference through Itô is not live yet.';
}
module.exports = Object.freeze({
ITO_COMPUTE_URL,
getComputeSponsorCopy,
});
+191
View File
@@ -0,0 +1,191 @@
'use strict';
/**
* Self-contained 3D "agent airspace" visualization, served by the control pane.
*
* Renders each agent as a point in code-space (positions from the proximity
* embedding), sized by working-set size and colored by collision risk, with
* links between converging pairs (amber = transmit advisory, red = steer). The
* scene auto-rotates so you can read the cloud. Dependency-free: a hand-rolled
* 3D2D projection on a <canvas>, no external scripts (CSP/offline friendly).
*
* This is the operator/Enterprise view of Layer 4: multi-agent observability:
* literally watch the swarm and watch one agent steer away from a collision.
*/
function renderProximityVizHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ECC Agent Airspace</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 -apple-system, system-ui, sans-serif; background: #0b0e14; color: #e6edf3; }
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
header h1 { font-size: 15px; margin: 0; }
header .sub { color: #8b949e; font-size: 12px; }
#wrap { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 49px); }
#stage { position: relative; }
canvas { width: 100%; height: 100%; display: block; }
#side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; }
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; }
.adv { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
.adv.resolution { border-color: #b3402f; }
.adv.advisory { border-color: #9a6700; }
.adv .lv { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.adv.resolution .lv { color: #ff7b72; }
.adv.advisory .lv { color: #e3b341; }
.adv .who { color: #c9d1d9; }
.adv .act { color: #8b949e; font-size: 12px; margin-top: 3px; }
.empty { color: #6e7681; }
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
</style>
</head>
<body>
<header>
<h1>ECC - Agent Airspace</h1>
<span class="sub" id="status">connecting...</span>
</header>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<div id="legend">
<div><span class="dot" style="background:#3fb950"></span>clear</div>
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
<div><span class="dot" style="background:#ff7b72"></span>resolution (steer)</div>
</div>
</div>
<div id="side">
<h2>Advisories</h2>
<div id="advisories"><div class="empty">No advisories - airspace clear.</div></div>
</div>
</div>
<script>
(function () {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var state = { positions: [], links: [], advisories: [], riskByAgent: {} };
var angle = 0;
function resize() {
var r = canvas.parentElement.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(r.width * dpr));
canvas.height = Math.max(1, Math.floor(r.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
function riskColor(risk) {
if (risk >= 0.7) return '#ff7b72';
if (risk >= 0.35) return '#e3b341';
return '#3fb950';
}
// 3D to 2D: rotate around Y, simple perspective.
function project(p, w, h) {
var x = p[0], y = p[1] || 0, z = p[2] || 0;
var ca = Math.cos(angle), sa = Math.sin(angle);
var rx = x * ca - z * sa;
var rz = x * sa + z * ca;
var scale = 2.4 / (3.2 + rz); // perspective
return [w / 2 + rx * scale * (Math.min(w, h) * 0.32), h / 2 + y * scale * (Math.min(w, h) * 0.32), scale];
}
function draw() {
var w = canvas.clientWidth, h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
var pos = {};
for (var i = 0; i < state.positions.length; i++) {
var a = state.positions[i];
pos[a.agentId] = project(a.position || [0, 0, 0], w, h);
}
// links first (under the points)
for (var l = 0; l < state.links.length; l++) {
var link = state.links[l];
if (link.risk < 0.2) continue;
var pa = pos[link.a], pb = pos[link.b];
if (!pa || !pb) continue;
ctx.strokeStyle = riskColor(link.risk);
ctx.globalAlpha = Math.min(1, 0.25 + link.risk * 0.7);
ctx.lineWidth = 1 + link.risk * 3;
ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1]); ctx.stroke();
}
ctx.globalAlpha = 1;
// points
for (var k = 0; k < state.positions.length; k++) {
var ag = state.positions[k];
var p = pos[ag.agentId];
var radius = (6 + Math.sqrt(ag.fileCount || 1) * 3) * p[2];
ctx.fillStyle = riskColor(state.riskByAgent[ag.agentId] || 0);
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#c9d1d9';
ctx.font = '11px -apple-system, system-ui, sans-serif';
ctx.fillText(String(ag.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
}
angle += 0.0035;
requestAnimationFrame(draw);
}
function renderAdvisories() {
var box = document.getElementById('advisories');
box.textContent = '';
if (!state.advisories.length) {
var e = document.createElement('div'); e.className = 'empty';
e.textContent = 'No advisories - airspace clear.'; box.appendChild(e); return;
}
state.advisories.forEach(function (adv) {
var el = document.createElement('div');
el.className = 'adv ' + (adv.level === 'resolution' ? 'resolution' : 'advisory');
var lv = document.createElement('div'); lv.className = 'lv';
lv.textContent = Math.round(adv.risk * 100) + '% - ' + adv.level; el.appendChild(lv);
var who = document.createElement('div'); who.className = 'who';
who.textContent = (adv.aLabel || adv.a) + ' <-> ' + (adv.bLabel || adv.b); el.appendChild(who);
var act = document.createElement('div'); act.className = 'act';
act.textContent = adv.level === 'resolution'
? (adv.steer + ' steers - ' + adv.hold + ' holds')
: 'both transmit intent';
el.appendChild(act);
box.appendChild(el);
});
}
function applySnapshot(prox) {
state.positions = prox.positions || [];
state.links = prox.links || [];
state.advisories = prox.advisories || [];
var risk = {};
state.links.forEach(function (l) {
risk[l.a] = Math.max(risk[l.a] || 0, l.risk);
risk[l.b] = Math.max(risk[l.b] || 0, l.risk);
});
state.riskByAgent = risk;
renderAdvisories();
var c = prox.counts || {};
document.getElementById('status').textContent =
(c.agents || 0) + ' agents - ' + (c.advisories || 0) + ' advisories - ' + (c.resolutions || 0) + ' steering';
}
function poll() {
fetch('/api/proximity').then(function (r) { return r.json(); }).then(function (data) {
applySnapshot(data && data.enabled ? data : (data || {}));
}).catch(function () {
document.getElementById('status').textContent = 'offline';
});
}
resize();
poll();
setInterval(poll, 5000);
requestAnimationFrame(draw);
})();
</script>
</body>
</html>`;
}
module.exports = { renderProximityVizHtml };
+28 -36
View File
@@ -8,6 +8,7 @@ const { spawn } = require('child_process');
const { buildControlPaneAction } = require('./actions');
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
const { renderControlPaneHtml } = require('./ui');
const { renderProximityVizHtml } = require('./proximity-viz');
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
// Run a single write against the local work-item store, then close it. Kept
@@ -23,42 +24,14 @@ async function withStateStore(stateDbPath, fn) {
}
}
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
// Extract the hostname portion of an HTTP Host header value, stripping any
// port. Returns null when the header is missing or malformed. Used to gate
// requests against a local-only allowlist so DNS-rebinding cannot pivot a
// browser tab into the loopback control-pane API.
function parseHostHeader(value) {
if (!value || typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
if (!match) return null;
return match[1].toLowerCase();
}
function buildAllowedHostnames(configuredHost) {
const set = new Set(LOOPBACK_HOSTNAMES);
if (configuredHost) set.add(String(configuredHost).toLowerCase());
return set;
}
function isAllowedHostHeader(hostHeader, allowedHostnames) {
const hostname = parseHostHeader(hostHeader);
if (!hostname) return false;
return allowedHostnames.has(hostname);
}
function isAllowedOrigin(originHeader, allowedHostnames) {
if (!originHeader || typeof originHeader !== 'string') return true;
try {
const url = new URL(originHeader);
return allowedHostnames.has(url.hostname.toLowerCase());
} catch {
return false;
}
}
// Host/Origin gating lives in scripts/lib/loopback-guard.js so every ECC
// loopback server shares one hardened implementation; re-exported below to
// keep this module's public API stable.
const {
buildAllowedHostnames,
isAllowedHostHeader,
isAllowedOrigin
} = require('../loopback-guard');
function usage() {
return [
@@ -265,6 +238,25 @@ function createControlPaneServer(options = {}) {
return;
}
// 3D agent-airspace visualization (Layer 4 observability).
if (req.method === 'GET' && requestUrl.pathname === '/proximity') {
sendText(res, 200, renderProximityVizHtml(), 'text/html; charset=utf-8');
return;
}
if (req.method === 'GET' && requestUrl.pathname === '/api/proximity') {
const snapshot = await buildControlPaneSnapshot({
repoRoot,
dbPath: resolvedConfig.dbPath,
stateDbPath: resolvedConfig.stateDbPath,
config: resolvedConfig,
allowActions,
includeProximity: true
});
sendJson(res, 200, snapshot.proximity || { enabled: true, advisories: [], positions: [], links: [], counts: {} });
return;
}
const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/);
if (req.method === 'POST' && actionMatch) {
if (!allowActions) {
+39
View File
@@ -0,0 +1,39 @@
const REPOSITORY_ISSUES_URL = 'https://github.com/affaan-m/ECC/issues/new';
const FEEDBACK_ROUTES = Object.freeze({
problem: `${REPOSITORY_ISSUES_URL}?template=install-problem.yml`,
feedback: `${REPOSITORY_ISSUES_URL}?template=quick-feedback.yml`,
feature: `${REPOSITORY_ISSUES_URL}?template=feature-request.yml`,
});
function getFeedbackPayload() {
return {
schemaVersion: 'ecc.feedback.v1',
privacy: 'public-github',
diagnosticsUploaded: false,
routes: { ...FEEDBACK_ROUTES },
};
}
function problemReportLines() {
return [
'Report this problem (public GitHub issue):',
FEEDBACK_ROUTES.problem,
'ECC does not upload diagnostics. Redact paths, repository names, prompts, and secrets before sharing output.',
];
}
function exitFeedbackLines() {
return [
'Optional 20-second exit feedback (public GitHub issue):',
FEEDBACK_ROUTES.feedback,
'ECC does not upload diagnostics or block uninstall.',
];
}
module.exports = {
FEEDBACK_ROUTES,
exitFeedbackLines,
getFeedbackPayload,
problemReportLines,
};
+14
View File
@@ -0,0 +1,14 @@
'use strict';
function normalizeGitHubGitOrigin(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().replace(/\.git$/i, '').replace(/\/+$/, '');
const match = normalized.match(
/^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^/]+\/[^/]+)$/i
);
return match ? match[1].toLowerCase() : null;
}
module.exports = {
normalizeGitHubGitOrigin,
};
+360
View File
@@ -0,0 +1,360 @@
const path = require('path');
const { SUPPORTED_INSTALL_TARGETS } = require('./install-manifests');
const { listInstallTargetAdapters } = require('./install-targets/registry');
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
for (const child of Object.values(value)) {
deepFreeze(child);
}
return Object.freeze(value);
}
function scope(id, targetId, root) {
return { id, targetId, root };
}
function hooks(mode, eccConfigured, note) {
return {
mode,
eccConfigured,
note,
summary: note,
};
}
const HARNESS_CAPABILITIES = deepFreeze([
{
id: 'claude',
label: 'Claude Code',
targetIds: ['claude', 'claude-project'],
channel: 'native-plugin',
installMode: 'native-plugin',
guidedReady: true,
availability: 'guided',
destination: 'Selected Claude plugin scope: ~/.claude or ./.claude',
scopes: [
scope('user', 'claude', '~/.claude'),
scope('project', 'claude-project', './.claude'),
scope('local', 'claude-project', './.claude'),
],
hooks: hooks(
'profile-selection',
true,
'ECC hooks are configured through the selected off, minimal, standard, or strict profile.'
),
aliases: ['claude-code'],
},
{
id: 'codex',
label: 'Codex',
targetIds: ['codex'],
channel: 'native-plugin',
installMode: 'native-plugin',
guidedReady: true,
availability: 'guided',
destination: '~/.codex through the Codex native plugin lifecycle',
scopes: [scope('native', 'codex', '~/.codex')],
hooks: hooks(
'native-trust',
true,
'ECC hooks use Codex native plugin discovery and remain subject to Codex review and trust.'
),
aliases: ['openai-codex'],
},
{
id: 'kimi',
label: 'Kimi Code',
targetIds: ['kimi'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: true,
availability: 'guided',
destination: './.kimi-code',
scopes: [scope('project', 'kimi', './.kimi-code')],
hooks: hooks(
'not-configured',
false,
'ECC hooks are not configured for the Kimi managed-project install.'
),
aliases: ['kimi-code'],
},
{
id: 'cursor',
label: 'Cursor',
targetIds: ['cursor'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.cursor',
scopes: [scope('project', 'cursor', './.cursor')],
hooks: hooks(
'adapter-configured',
true,
'ECC hooks use the Cursor project adapter and Cursor event configuration.'
),
aliases: [],
},
{
id: 'antigravity',
label: 'Antigravity',
targetIds: ['antigravity'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.agent',
scopes: [scope('project', 'antigravity', './.agent')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['google-antigravity'],
},
{
id: 'gemini',
label: 'Gemini CLI',
targetIds: ['gemini'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.gemini',
scopes: [scope('project', 'gemini', './.gemini')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['gemini-cli'],
},
{
id: 'opencode',
label: 'OpenCode',
targetIds: ['opencode'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.opencode',
scopes: [scope('home', 'opencode', '~/.opencode')],
hooks: hooks(
'adapter-opt-in',
false,
'ECC hook runtime support is available through the OpenCode adapter but is not installed by default.'
),
aliases: ['open-code'],
},
{
id: 'codebuddy',
label: 'CodeBuddy',
targetIds: ['codebuddy'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.codebuddy',
scopes: [scope('project', 'codebuddy', './.codebuddy')],
hooks: hooks(
'managed-files',
true,
'ECC hook runtime files are installed through the CodeBuddy project adapter.'
),
aliases: ['code-buddy'],
},
{
id: 'joycode',
label: 'JoyCode',
targetIds: ['joycode'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.joycode',
scopes: [scope('project', 'joycode', './.joycode')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['joy-code'],
},
{
id: 'qwen',
label: 'Qwen Code',
targetIds: ['qwen'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.qwen',
scopes: [scope('home', 'qwen', '~/.qwen')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['qwen-code'],
},
{
id: 'zed',
label: 'Zed',
targetIds: ['zed'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.zed',
scopes: [scope('project', 'zed', './.zed')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: [],
},
{
id: 'hermes',
label: 'Hermes',
targetIds: ['hermes'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.hermes',
scopes: [scope('home', 'hermes', '~/.hermes')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['hermes-agent'],
},
{
id: 'openclaw',
label: 'OpenClaw',
targetIds: ['openclaw'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.openclaw',
scopes: [scope('home', 'openclaw', '~/.openclaw')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['open-claw'],
},
]);
const GUIDED_HARNESS_IDS = deepFreeze(
HARNESS_CAPABILITIES
.filter(harness => harness.guidedReady)
.map(harness => harness.id)
);
function normalizeLookupToken(value) {
return String(value).trim().toLowerCase().replace(/[\s_]+/g, '-');
}
const LOOKUP = new Map();
for (const harness of HARNESS_CAPABILITIES) {
const keys = [harness.id, harness.label, ...harness.targetIds, ...harness.aliases];
for (const key of keys) {
LOOKUP.set(normalizeLookupToken(key), harness);
}
}
function expectedRootForAdapter(adapter) {
const homeDir = path.resolve('/__ecc_catalog_home__');
const projectRoot = path.resolve('/__ecc_catalog_project__');
const absoluteRoot = adapter.resolveRoot({ homeDir, projectRoot });
const baseRoot = adapter.kind === 'home' ? homeDir : projectRoot;
const prefix = adapter.kind === 'home' ? '~/' : './';
return `${prefix}${path.relative(baseRoot, absoluteRoot).replace(/\\/g, '/')}`;
}
function validateCatalog() {
const adapters = listInstallTargetAdapters();
const adapterByTarget = new Map(adapters.map(adapter => [adapter.target, adapter]));
const catalogTargetIds = HARNESS_CAPABILITIES.flatMap(harness => harness.targetIds);
if (new Set(catalogTargetIds).size !== catalogTargetIds.length) {
throw new Error('Harness capability catalog contains duplicate install target ids');
}
const supported = [...SUPPORTED_INSTALL_TARGETS].sort();
const registered = adapters.map(adapter => adapter.target).sort();
const catalogued = [...catalogTargetIds].sort();
if (
JSON.stringify(catalogued) !== JSON.stringify(supported)
|| JSON.stringify(catalogued) !== JSON.stringify(registered)
) {
throw new Error('Harness capability catalog is out of sync with install targets');
}
for (const harness of HARNESS_CAPABILITIES) {
for (const declaredScope of harness.scopes) {
const adapter = adapterByTarget.get(declaredScope.targetId);
if (!adapter || expectedRootForAdapter(adapter) !== declaredScope.root) {
throw new Error(
`Harness capability root is out of sync for target ${declaredScope.targetId}`
);
}
}
}
}
validateCatalog();
function listHarnessCapabilities() {
return HARNESS_CAPABILITIES.slice();
}
function listGuidedHarnesses() {
return GUIDED_HARNESS_IDS.map(id => LOOKUP.get(id));
}
function getHarnessCapability(value) {
if (typeof value !== 'string' || value.trim() === '') {
return null;
}
return LOOKUP.get(normalizeLookupToken(value)) || null;
}
function tokenizeSelection(selection) {
const values = Array.isArray(selection) ? selection : [selection];
return values.flatMap(value => (
typeof value === 'string' ? value.split(',') : []
)).map(value => value.trim()).filter(Boolean);
}
function normalizeHarnessSelection(selection) {
const tokens = tokenizeSelection(selection);
if (tokens.length === 0 || tokens.every(token => normalizeLookupToken(token) === 'none')) {
throw new Error('At least one guided harness must be selected');
}
const allTokens = tokens.filter(token => ['all', '*'].includes(normalizeLookupToken(token)));
const explicitTokens = tokens.filter(token => !['all', '*'].includes(normalizeLookupToken(token)));
if (allTokens.length > 0 && explicitTokens.length > 0) {
throw new Error('The all/* harness selection cannot be combined with other selections');
}
if (allTokens.length > 0) {
return GUIDED_HARNESS_IDS.slice();
}
const selected = new Set();
for (const token of tokens) {
const normalizedToken = normalizeLookupToken(token);
const menuIndex = /^\d+$/.test(normalizedToken) ? Number(normalizedToken) - 1 : -1;
const harness = menuIndex >= 0
? listGuidedHarnesses()[menuIndex] || null
: getHarnessCapability(token);
if (!harness) {
throw new Error(`Unknown guided harness selection: ${token}`);
}
if (!harness.guidedReady) {
throw new Error(`${harness.label} is an advanced harness and is not guided-ready`);
}
selected.add(harness.id);
}
if (selected.size === 0) {
throw new Error('At least one guided harness must be selected');
}
return GUIDED_HARNESS_IDS.filter(id => selected.has(id));
}
module.exports = {
GUIDED_HARNESS_IDS,
HARNESS_CAPABILITIES,
getHarnessCapability,
listGuidedHarnesses,
listHarnessCapabilities,
normalizeHarnessSelection,
};
+82 -8
View File
@@ -3,25 +3,90 @@
* Shared hook enable/disable controls.
*
* Controls:
* - ECC_HOOKS_ENABLED=true|false (default: true)
* - ECC_HOOK_PROFILE=minimal|standard|strict (default: standard)
* - ECC_DISABLED_HOOKS=comma,separated,hook,ids
*
* Claude plugin options are used when their corresponding ECC variable is
* absent. A managed install can provide ecc/setup.json as the final fallback.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const VALID_PROFILES = new Set(['minimal', 'standard', 'strict']);
function normalizeId(value) {
return String(value || '').trim().toLowerCase();
}
function getHookProfile() {
const raw = String(process.env.ECC_HOOK_PROFILE || 'standard').trim().toLowerCase();
function parseBoolean(value, fallback = true) {
if (value === undefined || value === null || String(value).trim() === '') {
return fallback;
}
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function sanitizeDiagnostic(value) {
return String(value || '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|\([A-Z]|[A-Z])/g, '')
.replace(/[^\x20-\x7E]/g, '?');
}
function readManagedHookConfig(env = process.env) {
const pluginRoot = String(
env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || ''
).trim();
const configPath = String(env.ECC_HOOK_CONFIG || '').trim()
|| (pluginRoot ? path.join(pluginRoot, 'ecc', 'setup.json') : '');
if (!configPath || !fs.existsSync(configPath)) return {};
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return config?.hooks
&& typeof config.hooks === 'object'
&& !Array.isArray(config.hooks)
? config.hooks
: {};
} catch (error) {
process.stderr.write(`${sanitizeDiagnostic(
`Warning: unable to read managed ECC hook config at ${configPath}: ${error.message}`
)}\n`);
return {};
}
}
function areHooksEnabled(env = process.env, managed = readManagedHookConfig(env)) {
const raw = env.ECC_HOOKS_ENABLED !== undefined
? env.ECC_HOOKS_ENABLED
: (
env.CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED !== undefined
? env.CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED
: managed.enabled
);
return parseBoolean(raw, true);
}
function getHookProfile(env = process.env, managed = readManagedHookConfig(env)) {
const selected = env.ECC_HOOK_PROFILE !== undefined
? env.ECC_HOOK_PROFILE
: (
env.CLAUDE_PLUGIN_OPTION_HOOK_PROFILE !== undefined
? env.CLAUDE_PLUGIN_OPTION_HOOK_PROFILE
: managed.profile
);
const raw = String(selected ?? 'standard').trim().toLowerCase();
return VALID_PROFILES.has(raw) ? raw : 'standard';
}
function getDisabledHookIds() {
const raw = String(process.env.ECC_DISABLED_HOOKS || '');
function getDisabledHookIds(env = process.env) {
const raw = String(env.ECC_DISABLED_HOOKS || '');
if (!raw.trim()) return new Set();
return new Set(
@@ -50,20 +115,26 @@ function parseProfiles(rawProfiles, fallback = ['standard', 'strict']) {
return parsed.length > 0 ? parsed : [...fallback];
}
function isDryRun() {
return process.env.ECC_DRY_RUN === '1';
function isDryRun(env = process.env) {
return env.ECC_DRY_RUN === '1';
}
function isHookEnabled(hookId, options = {}) {
const env = options.env || process.env;
const managed = readManagedHookConfig(env);
if (!areHooksEnabled(env, managed)) {
return false;
}
const id = normalizeId(hookId);
if (!id) return true;
const disabled = getDisabledHookIds();
const disabled = getDisabledHookIds(env);
if (disabled.has(id)) {
return false;
}
const profile = getHookProfile();
const profile = getHookProfile(env, managed);
const allowedProfiles = parseProfiles(options.profiles);
return allowedProfiles.includes(profile);
}
@@ -71,6 +142,9 @@ function isHookEnabled(hookId, options = {}) {
module.exports = {
VALID_PROFILES,
normalizeId,
parseBoolean,
readManagedHookConfig,
areHooksEnabled,
getHookProfile,
getDisabledHookIds,
parseProfiles,
+40 -4
View File
@@ -118,9 +118,14 @@ function createStatePreview(options) {
return createInstallState(options);
}
function applyInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
const { applyInstallPlan: applyPlan } = require('./install/apply');
return applyPlan(plan);
return applyPlan(plan, dependencies);
}
function previewInstallPlan(plan) {
const { previewInstallPlan: previewPlan } = require('./install/apply');
return previewPlan(plan);
}
function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) {
@@ -688,6 +693,32 @@ function materializeScaffoldOperation(sourceRoot, operation) {
});
}
function dedupeCopyFileOperations(operations) {
// A `copy-file` operation fully overwrites its destination, so when several
// of them target the same path (e.g. a generic `commands/<name>.md` shadowed
// by an OpenCode `.opencode/commands/<name>.md` override) only the last one
// actually determines the installed content. Recording the shadowed earlier
// writes in install-state makes `doctor` report perpetual drift and drives
// `repair` to clobber the override with the generic source (issue #2414).
// Keep only the last `copy-file` per destination - matching the sequential
// apply order in applyInstallPlan - and leave every other operation kind
// (e.g. accumulating `merge-json` writes into a shared config) untouched and
// in order.
const lastCopyIndexByDestination = new Map();
operations.forEach((operation, index) => {
if (operation.kind === 'copy-file' && operation.destinationPath) {
lastCopyIndexByDestination.set(operation.destinationPath, index);
}
});
return operations.filter((operation, index) => {
if (operation.kind !== 'copy-file' || !operation.destinationPath) {
return true;
}
return lastCopyIndexByDestination.get(operation.destinationPath) === index;
});
}
function createManifestInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
@@ -713,10 +744,13 @@ function createManifestInstallPlan(options = {}) {
moduleIds: options.moduleIds || [],
includeComponentIds: options.includeComponentIds || [],
excludeComponentIds: options.excludeComponentIds || [],
target
target,
exemptValidationCodes: options.exemptValidationCodes || [],
});
const adapter = getInstallTargetAdapter(target);
const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation));
const operations = dedupeCopyFileOperations(
plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation))
);
const source = {
repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot),
@@ -773,9 +807,11 @@ module.exports = {
SUPPORTED_INSTALL_TARGETS,
LEGACY_INSTALL_TARGETS,
applyInstallPlan,
previewInstallPlan,
createLegacyCompatInstallPlan,
createManifestInstallPlan,
createLegacyInstallPlan,
dedupeCopyFileOperations,
getSourceRoot,
listAvailableLanguages,
parseInstallArgs
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -4,7 +4,7 @@ const path = require('path');
const { getInstallTargetAdapter, planInstallTargetScaffold } = require('./install-targets/registry');
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed'];
const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi'];
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
@@ -74,6 +74,27 @@ const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
'platform-configs',
'workflow-quality',
],
hermes: [
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'workflow-quality',
],
openclaw: [
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'workflow-quality',
],
kimi: [
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'workflow-quality',
],
});
const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
c: 'c',
@@ -660,6 +681,7 @@ function resolveInstallPlan(options = {}) {
projectRoot: targetPlanningInput.projectRoot,
homeDir: targetPlanningInput.homeDir,
modules: selectedModules,
exemptValidationCodes: options.exemptValidationCodes || [],
})
: null;
+11 -18
View File
@@ -1,17 +1,11 @@
const fs = require('fs');
const path = require('path');
let Ajv = null;
try {
// Prefer schema-backed validation when dependencies are installed.
// The fallback validator below keeps source checkouts usable in bare environments.
const ajvModule = require('ajv');
Ajv = ajvModule.default || ajvModule;
} catch (_error) {
Ajv = null;
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'install-state.schema.json');
// Dependency-free, self-contained validation. The installer closure must not
// require any non-builtin package (enterprise supply-chain vetting: the vetted
// bytes must be the installed bytes). install-state is validated by the
// hand-rolled validator below, which enforces the same constraints as
// schemas/install-state.schema.json (ecc.install.v1).
let cachedValidator = null;
@@ -36,13 +30,6 @@ function getValidator() {
return cachedValidator;
}
if (Ajv) {
const schema = readJson(SCHEMA_PATH, 'install-state schema');
const ajv = new Ajv({ allErrors: true });
cachedValidator = ajv.compile(schema);
return cachedValidator;
}
cachedValidator = createFallbackValidator();
return cachedValidator;
}
@@ -208,6 +195,12 @@ function createFallbackValidator() {
if (typeof operation.scaffoldOnly !== 'boolean') {
pushError(`${instancePath}/scaffoldOnly`, 'must be boolean');
}
if (
operation.contentSha256 !== undefined
&& !/^[a-f0-9]{64}$/i.test(operation.contentSha256)
) {
pushError(`${instancePath}/contentSha256`, 'must be a SHA-256 hex digest');
}
}
}
@@ -7,7 +7,7 @@ const {
normalizeRelativePath,
} = require('./helpers');
const SUPPORTED_SOURCE_PREFIXES = ['rules', 'commands', 'agents', 'skills', '.agents', 'AGENTS.md'];
const SUPPORTED_SOURCE_PREFIXES = ['rules', 'commands', 'agents', '.agents', 'AGENTS.md'];
function supportsAntigravitySourcePath(sourceRelativePath) {
const normalizedPath = normalizeRelativePath(sourceRelativePath);
+1 -2
View File
@@ -27,14 +27,13 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
}
if (normalizedSourcePath === 'skills') {
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
return path.join(targetRoot, 'skills');
}
if (normalizedSourcePath.startsWith('skills/')) {
return path.join(
targetRoot,
'skills',
CLAUDE_ECC_NAMESPACE,
normalizedSourcePath.slice('skills/'.length)
);
}
@@ -27,14 +27,13 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
}
if (normalizedSourcePath === 'skills') {
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
return path.join(targetRoot, 'skills');
}
if (normalizedSourcePath.startsWith('skills/')) {
return path.join(
targetRoot,
'skills',
CLAUDE_ECC_NAMESPACE,
normalizedSourcePath.slice('skills/'.length)
);
}
+4
View File
@@ -7,8 +7,12 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.codex': 'codex',
'.cursor': 'cursor',
'.gemini': 'gemini',
'.hermes': 'hermes',
'.kimi': 'kimi',
'.kimi-code': 'kimi',
'.joycode': 'joycode',
'.opencode': 'opencode',
'.openclaw': 'openclaw',
'.codebuddy': 'codebuddy',
'.qwen': 'qwen',
'.zed': 'zed',
@@ -0,0 +1,10 @@
const { createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'hermes-home',
target: 'hermes',
kind: 'home',
rootSegments: ['.hermes'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.hermes',
});
+109
View File
@@ -0,0 +1,109 @@
const fs = require('fs');
const path = require('path');
const {
createInstallTargetAdapter,
createManagedOperation,
isForeignPlatformPath,
} = require('./helpers');
function readJsonObject(filePath, label) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
}
return parsed;
}
function createMcpMergeOperation(moduleId, repoRoot, targetRoot) {
if (!repoRoot) {
throw new Error('repoRoot is required to plan Kimi MCP configuration');
}
const sourceRelativePath = '.mcp.json';
const sourcePath = path.join(repoRoot, sourceRelativePath);
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
return null;
}
return createManagedOperation({
kind: 'merge-json',
moduleId,
sourceRelativePath,
destinationPath: path.join(targetRoot, 'mcp.json'),
strategy: 'merge-json',
scaffoldOnly: false,
mergePayload: readJsonObject(sourcePath, sourceRelativePath),
});
}
module.exports = createInstallTargetAdapter({
id: 'kimi-project',
target: 'kimi',
kind: 'project',
rootSegments: ['.kimi-code'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.kimi-code',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
? input.modules
: (input.module ? [input.module] : []);
const planningInput = {
repoRoot: input.repoRoot,
projectRoot: input.projectRoot,
homeDir: input.homeDir,
};
const targetRoot = adapter.resolveRoot(planningInput);
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(sourceRelativePath => !isForeignPlatformPath(sourceRelativePath, adapter.target))
.flatMap(sourceRelativePath => {
if (sourceRelativePath === '.kimi') {
// The repository's compatibility documentation still lives in
// .kimi/. Sync its children into the current native root without
// creating that obsolete directory in the destination project.
return [createManagedOperation({
moduleId: module.id,
sourceRelativePath,
destinationPath: targetRoot,
strategy: 'sync-root-children',
})];
}
if (sourceRelativePath === '.agents') {
const skillsSourcePath = path.join(input.repoRoot || '', '.agents', 'skills');
if (!input.repoRoot || !fs.existsSync(skillsSourcePath)) {
return [];
}
return [createManagedOperation({
moduleId: module.id,
sourceRelativePath: '.agents/skills',
destinationPath: path.join(targetRoot, 'skills'),
strategy: 'preserve-relative-path',
})];
}
if (sourceRelativePath === 'mcp-configs') {
const mcpMergeOperation = createMcpMergeOperation(module.id, input.repoRoot, targetRoot);
return [
adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput),
...(mcpMergeOperation ? [mcpMergeOperation] : []),
];
}
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
});
@@ -0,0 +1,10 @@
const { createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'openclaw-home',
target: 'openclaw',
kind: 'home',
rootSegments: ['.openclaw'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.openclaw',
});
+10 -1
View File
@@ -5,7 +5,10 @@ const codebuddyProject = require('./codebuddy-project');
const codexHome = require('./codex-home');
const cursorProject = require('./cursor-project');
const geminiProject = require('./gemini-project');
const hermesHome = require('./hermes-home');
const joycodeProject = require('./joycode-project');
const kimiProject = require('./kimi-project');
const openclawHome = require('./openclaw-home');
const opencodeHome = require('./opencode-home');
const qwenHome = require('./qwen-home');
const zedProject = require('./zed-project');
@@ -17,9 +20,12 @@ const ADAPTERS = Object.freeze([
antigravityProject,
codexHome,
geminiProject,
hermesHome,
opencodeHome,
openclawHome,
codebuddyProject,
joycodeProject,
kimiProject,
qwenHome,
zedProject,
]);
@@ -41,13 +47,16 @@ function getInstallTargetAdapter(targetOrAdapterId) {
function planInstallTargetScaffold(options = {}) {
const adapter = getInstallTargetAdapter(options.target);
const modules = Array.isArray(options.modules) ? options.modules : [];
const exemptValidationCodes = new Set(Array.isArray(options.exemptValidationCodes) ? options.exemptValidationCodes : []);
const planningInput = {
repoRoot: options.repoRoot,
projectRoot: options.projectRoot || options.repoRoot,
homeDir: options.homeDir,
};
const validationIssues = adapter.validate(planningInput);
const blockingIssues = validationIssues.filter(issue => issue.severity === 'error');
const blockingIssues = validationIssues.filter(issue => (
issue.severity === 'error' && !exemptValidationCodes.has(issue.code)
));
if (blockingIssues.length > 0) {
throw new Error(blockingIssues.map(issue => issue.message).join('; '));
}
+242 -9
View File
@@ -1,10 +1,45 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const {
hasExplicitCommitAttributionPreference,
withCommitAttributionDisabled,
} = require('../claude-commit-attribution');
const { writeInstallState } = require('../install-state');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
assertSafeClaudeSkillOperation,
prepareClaudeSkillMigration,
removeLegacyClaudeSkillFiles,
} = require('./claude-skill-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
function isMarkdownPath(filePath) {
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
}
// Map every copy-file operation to { sourceRel, destRel } so relative links in
// namespaced markdown can be rewritten to the file's actual installed location
// (issue #2340). Returns null when the plan lacks the data needed to do so.
function buildLinkIndexForPlan(plan) {
if (!plan || !plan.targetRoot || !Array.isArray(plan.operations)) {
return null;
}
const mappings = [];
for (const operation of plan.operations) {
if (operation.kind === 'copy-file' && operation.sourceRelativePath) {
mappings.push({
sourceRel: operation.sourceRelativePath,
destRel: path.relative(plan.targetRoot, operation.destinationPath),
});
}
}
return buildInstallIndex(mappings);
}
function readJsonObject(filePath, label) {
let parsed;
@@ -21,6 +56,27 @@ function readJsonObject(filePath, label) {
return parsed;
}
function stateWithContentDigests(state) {
return {
...state,
operations: (state.operations || []).map(operation => {
if (
!operation.destinationPath
|| !fs.existsSync(operation.destinationPath)
|| !fs.statSync(operation.destinationPath).isFile()
) {
return { ...operation };
}
return {
...operation,
contentSha256: crypto.createHash('sha256')
.update(fs.readFileSync(operation.destinationPath))
.digest('hex'),
};
}),
};
}
function cloneJsonValue(value) {
if (value === undefined) {
return undefined;
@@ -53,6 +109,52 @@ function formatJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function shouldSetClaudeCommitAttributionPreference(plan) {
if (!plan?.adapter || !['claude', 'claude-project'].includes(plan.adapter.target)) {
return false;
}
return plan.operations.some(operation => {
if (typeof operation?.destinationPath !== 'string') {
return false;
}
const relativePath = path.relative(plan.targetRoot, operation.destinationPath);
return relativePath && !relativePath.startsWith(`docs${path.sep}`) && relativePath !== 'docs';
});
}
function writeClaudeCommitAttributionPreference(settingsPath) {
// Read once rather than probing with existsSync first. Checking for the file and
// then writing it is a file system race (CodeQL js/file-system-race), and a
// missing file is simply the fresh-install case.
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch (error) {
if (error.code !== 'ENOENT') {
// Unreadable or malformed settings belong to the user; leave them untouched.
return false;
}
settings = {};
}
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
return false;
}
if (hasExplicitCommitAttributionPreference(settings)) {
return false;
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
formatJson(withCommitAttributionDisabled(settings)),
'utf8'
);
return true;
}
function replacePluginRootPlaceholders(value, pluginRoot) {
if (!pluginRoot) {
return value;
@@ -78,9 +180,12 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
return value;
}
function findHooksSourcePath(plan, hooksDestinationPath) {
const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath);
return operation ? operation.sourcePath : null;
function findHooksOperation(plan, hooksDestinationPath) {
return plan.operations.find(item => (
item.destinationPath === hooksDestinationPath
&& item.moduleId === 'hooks-runtime'
&& typeof item.sourcePath === 'string'
));
}
function isMcpConfigPath(filePath) {
@@ -88,6 +193,38 @@ function isMcpConfigPath(filePath) {
return basename === '.mcp.json' || basename === 'mcp.json';
}
function assertSafeInstallOperation(plan, operation) {
if (!operation || typeof operation.destinationPath !== 'string') {
throw new Error('Refusing to apply install operation: missing destination path.');
}
const targetRoot = plan && plan.targetRoot;
assertWithinTrustedRoot(operation.destinationPath, targetRoot, 'install ECC file');
const resolvedRoot = path.resolve(targetRoot);
const resolvedTarget = path.resolve(operation.destinationPath);
const relativePath = path.relative(resolvedRoot, resolvedTarget);
const segments = relativePath ? relativePath.split(path.sep) : [];
for (const segmentIndex of Array.from({ length: segments.length + 1 }, (_value, index) => index)) {
const currentPath = segmentIndex === 0
? resolvedRoot
: path.join(resolvedRoot, ...segments.slice(0, segmentIndex));
try {
const stats = fs.lstatSync(currentPath);
if (stats.isSymbolicLink()) {
throw new Error(
`Refusing to install ECC file through symlinked path: '${currentPath}'.`
);
}
} catch (error) {
if (error && error.code === 'ENOENT') {
break;
}
throw error;
}
}
}
function buildResolvedClaudeHooks(plan) {
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
return null;
@@ -95,7 +232,11 @@ function buildResolvedClaudeHooks(plan) {
const pluginRoot = plan.targetRoot;
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
const hooksSourcePath = findHooksSourcePath(plan, hooksDestinationPath) || hooksDestinationPath;
const hooksOperation = findHooksOperation(plan, hooksDestinationPath);
if (!hooksOperation) {
return null;
}
const hooksSourcePath = hooksOperation.sourcePath;
if (!fs.existsSync(hooksSourcePath)) {
return null;
}
@@ -107,6 +248,7 @@ function buildResolvedClaudeHooks(plan) {
}
return {
hooksOperation,
hooksDestinationPath,
resolvedHooksConfig: {
...hooksConfig,
@@ -115,12 +257,59 @@ function buildResolvedClaudeHooks(plan) {
};
}
function applyInstallPlan(plan) {
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
function previewInstallPlan(plan) {
const migration = prepareClaudeSkillMigration(plan);
return {
...plan,
statePreview: migration.finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
],
applied: false,
};
}
for (const operation of plan.operations) {
function applyInstallPlan(plan, dependencies = {}) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeOperationWrite = dependencies.beforeOperationWrite;
const beforeInstallStateWrite = dependencies.beforeInstallStateWrite;
const migration = prepareClaudeSkillMigration(plan);
const appliedPlan = {
...plan,
operations: migration.appliedOperations,
};
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(appliedPlan);
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
const linkIndex = buildLinkIndexForPlan(appliedPlan);
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
if (migration.requiresBridgeState) {
// Own every operation that may be written during a flat-skill migration
// before the first copy. A later failure is retryable and uninstall can
// clean the entire partial install, including non-skill files. During
// legacy migration the bridge also retains the prior managed operations.
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
}
persistInstallState(plan.installStatePath, migration.bridgeState);
}
for (const operation of appliedPlan.operations) {
assertSafeInstallOperation(appliedPlan, operation);
assertSafeClaudeSkillOperation(appliedPlan, operation);
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
// Recheck directories that were absent during the first validation. This
// narrows the symlink-swap window around mkdirSync, but path checks cannot
// eliminate a later TOCTOU race before the file write.
assertSafeInstallOperation(appliedPlan, operation);
assertSafeClaudeSkillOperation(appliedPlan, operation);
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation });
}
if (operation.kind === 'merge-json') {
const payload = cloneJsonValue(operation.mergePayload);
@@ -149,11 +338,33 @@ function applyInstallPlan(plan) {
continue;
}
// Markdown may reference files whose installed paths move, such as rules
// copied under rules/ecc. Rewrite only links that point at installed targets;
// untouched links and non-markdown files stay on the byte-for-byte path.
if (
linkIndex
&& operation.kind === 'copy-file'
&& operation.sourceRelativePath
&& isMarkdownPath(operation.destinationPath)
) {
const rewritten = rewriteRelativeLinks(
fs.readFileSync(operation.sourcePath, 'utf8'),
{ sourceRel: operation.sourceRelativePath, index: linkIndex }
);
fs.writeFileSync(operation.destinationPath, rewritten, 'utf8');
continue;
}
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
}
if (resolvedClaudeHooksPlan) {
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation });
}
fs.writeFileSync(
resolvedClaudeHooksPlan.hooksDestinationPath,
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
@@ -161,14 +372,36 @@ function applyInstallPlan(plan) {
);
}
writeInstallState(plan.installStatePath, plan.statePreview);
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json'));
}
const finalState = stateWithContentDigests(migration.finalState);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
}
persistInstallState(plan.installStatePath, finalState);
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
],
applied: true,
};
}
module.exports = {
applyInstallPlan,
assertSafeInstallOperation,
previewInstallPlan,
};
@@ -0,0 +1,415 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const CLAUDE_TARGETS = new Set(['claude', 'claude-project']);
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function normalizeSourceRelativePath(sourceRelativePath) {
const slashNormalized = String(sourceRelativePath || '').replace(/\\/g, '/');
const normalized = path.posix.normalize(slashNormalized).replace(/^\.\//, '');
if (
!normalized
|| normalized === '.'
|| normalized === '..'
|| normalized.startsWith('../')
|| path.posix.isAbsolute(normalized)
) {
return null;
}
return normalized;
}
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function samePath(leftPath, rightPath) {
return comparablePath(leftPath) === comparablePath(rightPath);
}
function assertSafeSkillPath(targetPath, targetRoot, action) {
const resolvedRoot = path.resolve(targetRoot);
const resolvedTarget = path.resolve(targetPath);
const relativePath = path.relative(resolvedRoot, resolvedTarget);
if (
relativePath === ''
|| relativePath.startsWith('..')
|| path.isAbsolute(relativePath)
) {
throw new Error(
`Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.`
);
}
let currentPath = resolvedRoot;
for (const segment of relativePath.split(path.sep)) {
currentPath = path.join(currentPath, segment);
let stats;
try {
stats = fs.lstatSync(currentPath);
} catch (error) {
if (error && error.code === 'ENOENT') {
break;
}
throw error;
}
if (stats.isSymbolicLink()) {
throw new Error(
`Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.`
);
}
}
if (pathExists(targetRoot)) {
assertWithinTrustedRoot(targetPath, targetRoot, action);
}
}
function describeClaudeSkillOperation(targetRoot, operation) {
if (!operation || operation.kind !== 'copy-file') {
return null;
}
const sourceRelativePath = normalizeSourceRelativePath(operation.sourceRelativePath);
if (!sourceRelativePath) {
return null;
}
const sourceParts = sourceRelativePath.split('/');
if (sourceParts[0] !== 'skills' || sourceParts.length < 3 || !sourceParts[1]) {
return null;
}
const skillName = sourceParts[1];
const relativeParts = sourceParts.slice(2);
const flatSkillRoot = path.join(targetRoot, 'skills', skillName);
const legacySkillRoot = path.join(targetRoot, 'skills', 'ecc', skillName);
return {
sourceKey: sourceRelativePath,
skillName,
flatSkillRoot,
flatDestinationPath: path.join(flatSkillRoot, ...relativeParts),
legacySkillRoot,
legacyDestinationPath: path.join(legacySkillRoot, ...relativeParts),
};
}
function assertSafeClaudeSkillOperation(plan, operation) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return;
}
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
return;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'install Claude skill'
);
}
function isManagedOperation(operation) {
return operation && operation.ownership === 'managed';
}
function uniqueOperations(operations) {
const seen = new Set();
return operations.filter(operation => {
const key = [
operation.kind,
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
comparablePath(operation.destinationPath),
].join('\0');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function buildState(statePreview, operations) {
return {
...statePreview,
operations: uniqueOperations(operations).map(operation => ({ ...operation })),
};
}
function groupCurrentSkillOperations(plan) {
const groups = new Map();
for (const operation of plan.operations) {
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
continue;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'install Claude skill'
);
const current = groups.get(descriptor.flatSkillRoot) || [];
current.push({ operation, descriptor });
groups.set(descriptor.flatSkillRoot, current);
}
return groups;
}
function classifyPreviousOperations(plan, previousState) {
const flatByDestination = new Map();
const legacyBySource = new Map();
const legacyBySkillRoot = new Map();
for (const operation of (previousState && previousState.operations) || []) {
if (!isManagedOperation(operation)) {
continue;
}
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor) {
continue;
}
if (samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'inspect managed Claude skill'
);
flatByDestination.set(comparablePath(operation.destinationPath), operation);
continue;
}
if (!samePath(operation.destinationPath, descriptor.legacyDestinationPath)) {
continue;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'migrate managed Claude skill'
);
legacyBySource.set(descriptor.sourceKey, operation);
const current = legacyBySkillRoot.get(descriptor.legacySkillRoot) || [];
current.push({ operation, descriptor });
legacyBySkillRoot.set(descriptor.legacySkillRoot, current);
}
return {
flatByDestination,
legacyBySource,
legacyBySkillRoot,
};
}
function createConflictWarning(skillName, flatSkillRoot, retainsLegacy) {
const legacySuffix = retainsLegacy
? ' The existing ECC-managed nested copy was retained and remains tracked for uninstall.'
: '';
return `Skipped Claude skill '${skillName}' at ${flatSkillRoot}: the flat skill directory is user-owned because it is not recorded in ECC install-state.${legacySuffix}`;
}
function createFileConflictWarning(destinationPath, retainsLegacy) {
const legacySuffix = retainsLegacy
? ' The matching ECC-managed nested file was retained and remains tracked for uninstall.'
: '';
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
}
function createDisabledMigration(plan) {
return {
enabled: false,
appliedOperations: [...plan.operations],
skippedOperations: [],
warnings: [],
bridgeState: plan.statePreview,
finalState: plan.statePreview,
legacyOperationsToRemove: [],
requiresBridgeState: false,
};
}
function collectRetainedLegacyOperations(currentGroups, previous) {
const currentSourceKeys = new Set(
[...currentGroups.values()]
.flat()
.map(({ descriptor }) => descriptor.sourceKey)
);
return (
[...previous.legacyBySource.entries()]
.filter(([sourceKey]) => !currentSourceKeys.has(sourceKey))
.map(([_sourceKey, operation]) => operation)
);
}
function classifySkillGroup(flatSkillRoot, entries, previous) {
const hasManagedFlatFile = entries.some(({ operation }) => (
previous.flatByDestination.has(comparablePath(operation.destinationPath))
));
const legacyEntries = previous.legacyBySkillRoot.get(
entries[0].descriptor.legacySkillRoot
) || [];
if (pathExists(flatSkillRoot) && !hasManagedFlatFile) {
return {
skippedOperations: entries.map(({ operation }) => operation),
warnings: [createConflictWarning(
entries[0].descriptor.skillName,
flatSkillRoot,
legacyEntries.length > 0
)],
retainedLegacyOperations: legacyEntries.map(({ operation }) => operation),
};
}
const conflicts = entries.filter(({ operation }) => (
pathExists(operation.destinationPath)
&& !previous.flatByDestination.has(comparablePath(operation.destinationPath))
));
return {
skippedOperations: conflicts.map(({ operation }) => operation),
warnings: conflicts.map(({ operation, descriptor }) => createFileConflictWarning(
operation.destinationPath,
previous.legacyBySource.has(descriptor.sourceKey)
)),
retainedLegacyOperations: conflicts
.map(({ descriptor }) => previous.legacyBySource.get(descriptor.sourceKey))
.filter(Boolean),
};
}
function classifySkillConflicts(currentGroups, previous) {
const groupClassifications = [...currentGroups.entries()]
.map(([flatSkillRoot, entries]) => classifySkillGroup(
flatSkillRoot,
entries,
previous
));
const skippedOperations = groupClassifications
.flatMap(classification => classification.skippedOperations);
return {
skippedOperations,
skippedDestinations: new Set(
skippedOperations.map(operation => comparablePath(operation.destinationPath))
),
warnings: groupClassifications.flatMap(classification => classification.warnings),
retainedLegacyOperations: new Set([
...collectRetainedLegacyOperations(currentGroups, previous),
...groupClassifications.flatMap(
classification => classification.retainedLegacyOperations
),
]),
};
}
function buildMigrationStates(plan, previousState, previous, classification) {
const { skippedDestinations, retainedLegacyOperations } = classification;
const appliedOperations = plan.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
));
const legacyOperations = [...previous.legacyBySource.values()];
const legacyOperationsToRemove = legacyOperations.filter(operation => (
!retainedLegacyOperations.has(operation)
));
const finalOperations = [
...plan.statePreview.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
)),
...retainedLegacyOperations,
];
const bridgeOperations = [
...((previousState && previousState.operations) || []),
...appliedOperations,
];
return {
appliedOperations,
bridgeState: buildState(plan.statePreview, bridgeOperations),
finalState: buildState(plan.statePreview, finalOperations),
legacyOperationsToRemove,
requiresBridgeState: appliedOperations.length > 0,
};
}
function prepareClaudeSkillMigration(plan) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan);
}
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
const currentGroups = groupCurrentSkillOperations(plan);
const previous = classifyPreviousOperations(plan, previousState);
const classification = classifySkillConflicts(currentGroups, previous);
const states = buildMigrationStates(
plan,
previousState,
previous,
classification
);
return {
enabled: true,
appliedOperations: states.appliedOperations,
skippedOperations: classification.skippedOperations,
warnings: classification.warnings,
bridgeState: states.bridgeState,
finalState: states.finalState,
legacyOperationsToRemove: states.legacyOperationsToRemove,
requiresBridgeState: states.requiresBridgeState,
};
}
function cleanupEmptyLegacyParents(filePath, targetRoot) {
const skillsRoot = path.join(targetRoot, 'skills');
let currentPath = path.dirname(filePath);
while (!samePath(currentPath, skillsRoot)) {
assertSafeSkillPath(currentPath, targetRoot, 'clean Claude skill migration');
if (!pathExists(currentPath) || fs.readdirSync(currentPath).length > 0) {
return;
}
fs.rmdirSync(currentPath);
currentPath = path.dirname(currentPath);
}
}
function removeLegacyClaudeSkillFiles(migration, targetRoot) {
for (const operation of migration.legacyOperationsToRemove) {
assertSafeSkillPath(
operation.destinationPath,
targetRoot,
'migrate managed Claude skill'
);
fs.rmSync(operation.destinationPath, { force: true });
cleanupEmptyLegacyParents(operation.destinationPath, targetRoot);
}
}
module.exports = {
assertSafeClaudeSkillOperation,
prepareClaudeSkillMigration,
removeLegacyClaudeSkillFiles,
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { isWithinRoot, realpathNearestExisting } = require('../path-safety');
const CURRENT_PLUGIN_ID = 'ecc@ecc';
const LEGACY_PLUGIN_IDS = new Set([
'everything-claude-code@everything-claude-code',
'everything-claude-code@ecc',
]);
function resolveClaudePaths(options = {}) {
const homeDir = options.homeDir
|| process.env.HOME
|| process.env.USERPROFILE
|| os.homedir();
const configDir = options.configDir
|| process.env.CLAUDE_CONFIG_DIR
|| path.join(homeDir, '.claude');
const projectRoot = options.projectRoot || process.cwd();
return {
homeDir: path.resolve(homeDir),
configDir: path.resolve(configDir),
projectRoot: path.resolve(projectRoot),
};
}
function readJsonObject(filePath, label) {
let value;
try {
value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`${label} is invalid at ${filePath}: ${error.message}`);
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} is invalid at ${filePath}: expected a JSON object`);
}
return value;
}
function findManualClaudePlugin(options = {}) {
const { configDir } = resolveClaudePaths(options);
const pluginsDir = path.join(configDir, 'plugins');
const candidates = [
['ecc', '.claude-plugin', 'plugin.json'],
['ecc', 'plugin.json'],
['ecc@ecc', '.claude-plugin', 'plugin.json'],
['ecc@ecc', 'plugin.json'],
['everything-claude-code', '.claude-plugin', 'plugin.json'],
['everything-claude-code', 'plugin.json'],
];
for (const segments of candidates) {
const manifestPath = path.join(pluginsDir, ...segments);
if (fs.existsSync(manifestPath)) {
return {
manifestPath,
installPath: path.dirname(path.dirname(manifestPath)),
};
}
}
return null;
}
function validateManagedState(state, statePath, expectedRoot) {
const selectedModules = state?.resolution?.selectedModules;
const operations = state?.operations;
if (
state?.schemaVersion !== 'ecc.install.v1'
|| !state.target
|| typeof state.target !== 'object'
|| Array.isArray(state.target)
|| !Array.isArray(selectedModules)
|| !selectedModules.every(moduleId => typeof moduleId === 'string' && moduleId.length > 0)
|| !Array.isArray(operations)
) {
throw new Error(`Managed Claude install-state is invalid at ${statePath}`);
}
for (const operation of operations) {
if (
!operation
|| typeof operation !== 'object'
|| typeof operation.destinationPath !== 'string'
|| !path.isAbsolute(operation.destinationPath)
|| !isWithinRoot(operation.destinationPath, expectedRoot)
) {
throw new Error(`Managed Claude install-state is invalid at ${statePath}`);
}
}
return { selectedModules, operations };
}
function operationOverlapsPlugin(operation, expectedRoot) {
const canonicalRoot = realpathNearestExisting(expectedRoot);
const canonicalDestination = realpathNearestExisting(operation.destinationPath);
const relativePath = path.relative(canonicalRoot, canonicalDestination);
const firstSegment = relativePath.split(path.sep)[0];
return ['agents', 'commands', 'hooks', 'skills'].includes(firstSegment);
}
function findManagedClaudeInstalls(options = {}) {
const { configDir, projectRoot } = resolveClaudePaths(options);
const candidates = [
{
statePath: path.join(configDir, 'ecc', 'install-state.json'),
expectedRoot: configDir,
},
{
statePath: path.join(projectRoot, '.claude', 'ecc', 'install-state.json'),
expectedRoot: path.join(projectRoot, '.claude'),
},
];
const findings = [];
for (const candidate of candidates) {
if (!fs.existsSync(candidate.statePath)) continue;
const state = readJsonObject(candidate.statePath, 'Managed Claude install-state');
const { selectedModules, operations } = validateManagedState(
state,
candidate.statePath,
candidate.expectedRoot
);
const modulesOverlap = selectedModules.some(moduleId => moduleId !== 'rules-core');
const operationsOverlap = operations.some(operation => (
operationOverlapsPlugin(operation, candidate.expectedRoot)
));
findings.push({
statePath: candidate.statePath,
selectedModules: [...selectedModules],
overlapsPlugin: modulesOverlap || operationsOverlap,
});
}
return findings;
}
module.exports = {
CURRENT_PLUGIN_ID,
LEGACY_PLUGIN_IDS,
findManagedClaudeInstalls,
findManualClaudePlugin,
resolveClaudePaths,
};
+167
View File
@@ -0,0 +1,167 @@
'use strict';
const path = require('path');
const posix = path.posix;
// Matches inline markdown links and images: `](target)` / `](target "title")`.
// We deliberately scope to the inline form because that is what skill/rule docs
// use for cross-directory references. Reference-style and autolinks are left
// untouched (they are rare in these files and carry higher false-positive risk).
const INLINE_LINK_PATTERN = /(!?\]\()([^()\s]+)(\s+"[^"]*")?(\))/g;
function toPosix(relativePath) {
return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
}
function stripTrailingSlash(value) {
return value.length > 1 ? value.replace(/\/+$/, '') : value;
}
// Build file + directory lookup maps from the plan's own file placements.
// `fileMappings` is a list of { sourceRel, destRel } where both are paths
// relative to the repo root and the install root respectively. The directory
// map is derived by walking shared ancestors of each source/dest pair, which is
// exact for prefix-insertion namespacing (e.g. `rules/x` -> `rules/ecc/x`):
// the path suffix below the inserted segment is preserved, so ancestor `k`
// of the source maps to the dest with the matching number of trailing
// segments removed.
function buildInstallIndex(fileMappings) {
const byFile = new Map();
const byDir = new Map();
for (const mapping of fileMappings || []) {
const sourceRel = toPosix(mapping.sourceRel);
const destRel = toPosix(mapping.destRel);
if (!sourceRel || !destRel) {
continue;
}
byFile.set(sourceRel, destRel);
const sourceParts = sourceRel.split('/');
const destParts = destRel.split('/');
// Map every source ancestor directory to its installed counterpart by
// removing the same count of trailing segments from the dest path.
for (let depth = 1; depth < sourceParts.length; depth += 1) {
const trailing = sourceParts.length - depth;
const destDepth = destParts.length - trailing;
if (destDepth < 1) {
continue;
}
const sourceDir = sourceParts.slice(0, depth).join('/');
const destDir = destParts.slice(0, destDepth).join('/');
// Only record real prefix-insertion mappings (suffix preserved). If a
// directory resolves to itself (no namespace change) we skip it so the
// rewriter leaves those links alone.
if (sourceDir !== destDir) {
byDir.set(sourceDir, destDir);
}
}
}
return { byFile, byDir };
}
function isExternalOrAnchor(target) {
return (
target === ''
|| target.startsWith('#')
|| target.startsWith('/')
|| target.startsWith('mailto:')
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(target) // has a URL scheme (http:, https:, file:, ...)
);
}
// Resolve `target` (a relative link from `sourceDir`) to its repo-relative
// path, then return the install-relative path it should point to, or null when
// the target is not installed by this plan (leave such links untouched).
function resolveInstalledTarget(target, sourceDir, index) {
const hadTrailingSlash = target.endsWith('/');
const resolved = stripTrailingSlash(toPosix(posix.normalize(posix.join(sourceDir, target))));
// Escapes the repo root (starts with `..`) -> not something we placed.
if (resolved === '' || resolved === '.' || resolved.startsWith('..')) {
return null;
}
if (!hadTrailingSlash && index.byFile.has(resolved)) {
return { installed: index.byFile.get(resolved), trailingSlash: false };
}
if (index.byDir.has(resolved)) {
return { installed: index.byDir.get(resolved), trailingSlash: hadTrailingSlash };
}
return null;
}
// Rewrite relative links in a markdown file so they resolve to installed target
// locations. The source file may itself install at the same relative path; links
// can still need changes when their targets move, such as rules -> rules/ecc.
// Pure: no IO.
function rewriteRelativeLinks(content, options) {
const { sourceRel, index } = options || {};
const normalizedSource = toPosix(sourceRel);
const installedSource = index && index.byFile.get(normalizedSource);
if (!installedSource) {
return content;
}
const installedSourceDir = posix.dirname(installedSource);
const sourceDir = posix.dirname(normalizedSource);
const lines = String(content).split('\n');
let inFence = false;
for (let i = 0; i < lines.length; i += 1) {
const fenceToggle = /^\s*(```|~~~)/.test(lines[i]);
if (fenceToggle) {
inFence = !inFence;
continue;
}
if (inFence) {
continue; // never rewrite inside fenced code blocks
}
lines[i] = lines[i].replace(
INLINE_LINK_PATTERN,
(match, open, target, title, close) => {
// Preserve any `#fragment` so anchors survive the rewrite.
const hashIdx = target.indexOf('#');
const pathPart = hashIdx === -1 ? target : target.slice(0, hashIdx);
const fragment = hashIdx === -1 ? '' : target.slice(hashIdx);
if (isExternalOrAnchor(pathPart)) {
return match;
}
const resolution = resolveInstalledTarget(pathPart, sourceDir, index);
if (!resolution) {
return match;
}
let rewritten = posix.relative(installedSourceDir, resolution.installed);
if (rewritten === '') {
rewritten = '.';
}
if (resolution.trailingSlash && !rewritten.endsWith('/')) {
rewritten += '/';
}
// If the recomputed link points to the same place as the original
// (e.g. an intra-namespace `./sibling.md` whose endpoints both shift by
// the same prefix), keep the original text verbatim - including any
// leading `./` - so the rewrite stays a strict no-op where it must.
if (posix.normalize(rewritten) === posix.normalize(pathPart)) {
return match;
}
return `${open}${rewritten}${fragment}${title || ''}${close}`;
}
);
}
return lines.join('\n');
}
module.exports = {
buildInstallIndex,
rewriteRelativeLinks,
};
+173
View File
@@ -0,0 +1,173 @@
/**
* Instinct relevance ranking for SessionStart.
*
* At SessionStart there is no user task yet, so "relevance" is location/stack
* relevance: instincts scoped to the current project, or whose domain/trigger
* matches the detected stack, get a small additive boost on top of their
* confidence when ranking which instincts to inject. The confidence >=
* threshold floor and the injection cap are enforced by the caller; this
* module only computes the additive boost and the stack keyword set. When
* nothing is project-scoped and no stack is detected, every boost is 0 and the
* ranking degrades to confidence-only (unchanged behaviour).
*
* Resolves part (b) of:
* https://github.com/affaan-m/everything-claude-code/issues/2371
*/
const fs = require('fs');
const path = require('path');
const { detectProjectType } = require('./project-detect');
// Additive ranking boosts. These are intentionally NOT env-configurable: part
// (b) of the issue asks for relevance ranking, not more tunable knobs (part (a)
// already made the injection count + confidence threshold configurable). The
// values are chosen so a project-scoped 0.7 instinct (0.7 + 0.25 = 0.95) can
// surface above an unrelated global 0.9, and a stack-matching 0.75 instinct
// (0.75 + 0.2 = 0.95) can surface above an unrelated 0.9.
const DEFAULT_PROJECT_SCOPE_BOOST = 0.25;
const DEFAULT_STACK_MATCH_BOOST = 0.2;
/**
* Whether a file with any of the given extensions exists directly in the root
* (non-recursive, top-level only — kept cheap for a blocking SessionStart hook).
* @param {string} root - Project root directory.
* @param {string[]} extensions - Extensions to look for (e.g. ['.tf']).
* @returns {boolean}
*/
function hasFileWithExtension(root, extensions) {
try {
return fs.readdirSync(root, { withFileTypes: true }).some(
(entry) => entry.isFile() && extensions.includes(path.extname(entry.name))
);
} catch {
return false;
}
}
/**
* Whether a named file exists directly in the root.
* @param {string} root - Project root directory.
* @param {string} name - File name relative to root.
* @returns {boolean}
*/
function fileExists(root, name) {
try {
return fs.existsSync(path.join(root, name));
} catch {
return false;
}
}
/**
* Resolve whether relevance ranking is enabled. Default on; opt out by setting
* `ECC_INSTINCT_RELEVANCE_RANKING` to `off`, `false`, `0`, or `no`
* (case-insensitive). Any other value (including unset) keeps ranking on.
* @returns {boolean}
*/
function isRelevanceRankingEnabled() {
const raw = process.env.ECC_INSTINCT_RELEVANCE_RANKING;
if (raw === undefined || raw === null || raw === '') return true;
const normalized = String(raw).trim().toLowerCase();
return !['off', 'false', '0', 'no'].includes(normalized);
}
/**
* Cheap, non-recursive stack-keyword detection for the project root. Reuses
* detectProjectType (languages + frameworks) and layers the extra IaC/data
* markers issue #2371 calls out that detectProjectType does not cover
* (`*.tf` / `*.tfvars` -> terraform, `dbt_project.yml` -> dbt).
* @param {string} [projectRoot] - Defaults to process.cwd().
* @param {{languages?: string[], frameworks?: string[]}} [projectInfo] -
* Optional precomputed detectProjectType() result, to avoid a second pass.
* @returns {Set<string>} Lowercase keyword set (may be empty).
*/
function detectStackKeywords(projectRoot, projectInfo) {
const root = projectRoot || process.cwd();
const keywords = new Set();
let info = projectInfo;
if (!info) {
try {
info = detectProjectType(root);
} catch {
info = { languages: [], frameworks: [] };
}
}
for (const language of info.languages || []) keywords.add(String(language).toLowerCase());
for (const framework of info.frameworks || []) keywords.add(String(framework).toLowerCase());
if (hasFileWithExtension(root, ['.tf', '.tfvars'])) keywords.add('terraform');
if (fileExists(root, 'dbt_project.yml')) keywords.add('dbt');
return keywords;
}
/**
* Tokenize a free-text field into lowercase word tokens (split on
* non-alphanumerics). Token-set matching avoids substring false positives such
* as the keyword `go` matching the word `good`.
* @param {string} value
* @returns {string[]}
*/
function tokenize(value) {
return String(value || '')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
/**
* Whether an instinct's domain/trigger/stack fields intersect the stack
* keywords by whole-token match.
* @param {object} instinct - Parsed instinct (frontmatter fields as properties).
* @param {Set<string>} stackKeywords
* @returns {boolean}
*/
function instinctMatchesStack(instinct, stackKeywords) {
if (!instinct || !stackKeywords || stackKeywords.size === 0) return false;
const tokens = new Set([
...tokenize(instinct.domain),
...tokenize(instinct.trigger),
...tokenize(instinct.stack),
]);
for (const keyword of stackKeywords) {
if (tokens.has(keyword)) return true;
}
return false;
}
/**
* Additive relevance boost for ranking. Deterministic and pure. A
* project-scoped instinct (location-relevant by construction) and a
* stack-matching instinct each contribute their boost; both can apply.
* @param {object} instinct - Must carry `_scopeLabel` ('project'|'global') and
* optional `domain`/`trigger`/`stack` fields.
* @param {Set<string>} stackKeywords
* @param {{projectBoost?: number, stackBoost?: number}} [opts]
* @returns {number}
*/
function computeRelevanceBoost(instinct, stackKeywords, opts) {
const options = opts || {};
const projectBoost = Number.isFinite(options.projectBoost)
? options.projectBoost
: DEFAULT_PROJECT_SCOPE_BOOST;
const stackBoost = Number.isFinite(options.stackBoost)
? options.stackBoost
: DEFAULT_STACK_MATCH_BOOST;
let boost = 0;
if (instinct && instinct._scopeLabel === 'project') boost += projectBoost;
if (instinctMatchesStack(instinct, stackKeywords)) boost += stackBoost;
return boost;
}
module.exports = {
DEFAULT_PROJECT_SCOPE_BOOST,
DEFAULT_STACK_MATCH_BOOST,
isRelevanceRankingEnabled,
detectStackKeywords,
instinctMatchesStack,
computeRelevanceBoost,
// Exported for testing.
tokenize,
};
+113
View File
@@ -0,0 +1,113 @@
"use strict";
const SYSTEM_ENVIRONMENT_KEYS = Object.freeze([
"CI",
"ComSpec",
"DISPLAY",
"FORCE_COLOR",
"HOME",
"LANG",
"LC_ALL",
"NO_COLOR",
"PATH",
"PATHEXT",
"SHELL",
"SystemRoot",
"TEMP",
"TERM",
"TMP",
"TMPDIR",
"USERPROFILE",
"WAYLAND_DISPLAY",
"WINDIR",
"XDG_RUNTIME_DIR",
]);
const ITO_RUNTIME_ENVIRONMENT_KEYS = Object.freeze([
"ITO_API_KEY",
"ITO_API_URL",
"ITO_INVENTORY_URL",
"ITO_AUTH_MODE",
"ITO_ALLOW_FILE_TOKEN",
"ITO_TOKEN_FILE",
]);
const ITO_EVAL_ENVIRONMENT_KEYS = Object.freeze([
"ITO_ENABLE_SIXTYTWO_LIVE",
"SIXTYTWO_API_TOKEN",
"SIXTYTWO_TOKEN",
"SSH_AUTH_SOCK",
"SSH_AGENT_PID",
]);
const ECC_ITO_CONTROL_KEYS = Object.freeze([
"ECC_DRY_RUN",
"ECC_ITO_CLI_EXECUTABLE",
"NODE_ENV",
]);
const ITO_RUNTIME_COMMANDS = new Set(["login", "logout", "auth", "find", "status"]);
function copyDefined(source, target, key) {
if (typeof source[key] === "string") {
target[key] = source[key];
}
}
function createSafeItoEnvironment(source = process.env, options = {}) {
const safe = {};
for (const key of SYSTEM_ENVIRONMENT_KEYS) {
copyDefined(source, safe, key);
}
for (const [key] of Object.entries(source)) {
if (key.startsWith("LC_")) copyDefined(source, safe, key);
}
if (options.includeItoRuntime) {
for (const key of ITO_RUNTIME_ENVIRONMENT_KEYS) {
if (key === "ITO_API_KEY" && options.includeItoApiKey !== true) continue;
copyDefined(source, safe, key);
}
}
if (options.includeItoEvals) {
for (const key of ITO_EVAL_ENVIRONMENT_KEYS) {
copyDefined(source, safe, key);
}
}
if (options.includeControls) {
for (const key of ECC_ITO_CONTROL_KEYS) {
copyDefined(source, safe, key);
}
}
return Object.freeze(safe);
}
function getInvocationCommand(args = []) {
return args.filter((value) => value !== "--json")[0];
}
function createSafeItoInvocationEnvironment(
source = process.env,
args = [],
options = {},
) {
const command = getInvocationCommand(args);
return createSafeItoEnvironment(source, {
includeControls: options.includeControls === true,
includeItoRuntime: ITO_RUNTIME_COMMANDS.has(command),
includeItoApiKey: ["auth", "find", "status"].includes(command),
includeItoEvals: command === "evals",
});
}
module.exports = Object.freeze({
ECC_ITO_CONTROL_KEYS,
ITO_EVAL_ENVIRONMENT_KEYS,
ITO_RUNTIME_ENVIRONMENT_KEYS,
SYSTEM_ENVIRONMENT_KEYS,
createSafeItoEnvironment,
createSafeItoInvocationEnvironment,
getInvocationCommand,
});
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* LLM-powered session summary generator
*
* Uses `claude -p` (Claude Code CLI) to generate rich, contextual session
* summaries from JSONL transcripts. Requires no API key — reuses Claude Code's
* own authentication.
*
* Recursion guard: sets ECC_SKIP_LLM_SUMMARY=1 in subprocess env so any Stop
* hooks fired by the subprocess do NOT re-enter LLM summarization.
*/
'use strict';
const { spawnSync } = require('child_process');
const fs = require('fs');
const MAX_TRANSCRIPT_CHARS = 7000;
const MAX_TURNS = 25;
const LLM_TIMEOUT_MS = 90000;
function getLLMModel() {
return process.env.ECC_LLM_SUMMARY_MODEL || 'haiku';
}
function getContextThreshold() {
const raw = parseInt(process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD || '20', 10);
return Number.isFinite(raw) && raw > 0 && raw <= 100 ? raw : 20;
}
/**
* Extract the last MAX_TURNS user+assistant turns from a JSONL transcript.
* Returns null when the transcript is missing or has no parseable turns.
*/
function extractConversationText(transcriptPath) {
let content;
try {
content = fs.readFileSync(transcriptPath, 'utf8');
} catch {
return null;
}
const lines = content.split('\n').filter(Boolean);
const turns = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
const isUser = entry.type === 'user' || entry.message?.role === 'user';
const isAssistant = entry.type === 'assistant';
if (isUser) {
const rawContent = entry.message?.content ?? entry.content;
const text =
typeof rawContent === 'string'
? rawContent
: Array.isArray(rawContent)
? rawContent
.filter(c => c?.type === 'text')
.map(c => c.text)
.join(' ')
: '';
const cleaned = text.replace(/\n+/g, ' ').trim();
if (cleaned) {
turns.push({ role: 'User', text: cleaned.slice(0, 400) });
}
}
if (isAssistant && Array.isArray(entry.message?.content)) {
const textParts = entry.message.content
.filter(b => b?.type === 'text')
.map(b => b.text)
.join(' ')
.replace(/\n+/g, ' ')
.trim();
if (textParts) {
turns.push({ role: 'Claude', text: textParts.slice(0, 600) });
}
}
} catch {
// Skip unparseable lines
}
}
if (turns.length === 0) return null;
const recent = turns.slice(-MAX_TURNS);
const formatted = recent.map(t => `**${t.role}:** ${t.text}`).join('\n\n');
return formatted.length > MAX_TRANSCRIPT_CHARS ? '...(前略)\n\n' + formatted.slice(-MAX_TRANSCRIPT_CHARS) : formatted;
}
/**
* Read the context remaining percentage from a transcript's latest usage record.
* Returns null when unavailable.
*/
function getContextRemainingPct(transcriptPath) {
try {
const { readLatestContextTokens, resolveContextWindowTokens } = require('./transcript-context');
const usage = readLatestContextTokens(transcriptPath);
if (!usage) return null;
const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model);
return Math.round((1 - usage.tokens / windowTokens) * 100);
} catch {
return null;
}
}
/**
* Generate a session summary using `claude -p`.
* Returns the summary string, or null on failure or when recursion guard is active.
*/
function generateSessionSummary(transcriptPath) {
if (process.env.ECC_SKIP_LLM_SUMMARY) return null;
const conversation = extractConversationText(transcriptPath);
if (!conversation) return null;
const prompt = [
'Below is a conversation log from a Claude Code coding session.',
'Create a summary to help the next session quickly understand the context.',
'',
'## Prioritize including',
'- Design decisions and technology choices made this session',
'- Bugs and problems solved',
'- Files changed or created, with a brief description of changes',
'- Unfinished tasks and work to continue in the next session',
'- Important context the next session needs to know',
'',
'## Conversation log',
conversation,
'',
'## Output format (Markdown only, no preamble)',
'',
'## Session Summary',
'',
'### Tasks',
'(main tasks worked on this session)',
'',
'### Decisions Made',
'(design decisions and technology choices)',
'',
'### Files Modified',
'(files changed or created)',
'',
'### Unresolved Issues',
'(unfinished tasks and work to continue)',
'',
'### Next Session Context',
'(important context for the next session)'
].join('\n');
try {
const result = spawnSync('claude', ['--model', getLLMModel(), '-p'], {
input: prompt,
encoding: 'utf8',
env: {
...process.env,
CLAUDECODE: '',
ECC_SKIP_LLM_SUMMARY: '1'
},
timeout: LLM_TIMEOUT_MS,
shell: process.platform === 'win32'
});
if (result.error || result.status !== 0) {
return null;
}
const output = (result.stdout || '').trim();
return output || null;
} catch {
return null;
}
}
module.exports = { generateSessionSummary, extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel };
+57
View File
@@ -0,0 +1,57 @@
'use strict';
/**
* Host/Origin gating for ECC's loopback HTTP servers (control pane, plan
* canvas). DNS rebinding can point an attacker-controlled hostname at
* 127.0.0.1, so every request must present a Host header from this
* allowlist before the server does any work.
*/
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
// Extract the hostname portion of an HTTP Host header value, stripping any
// port. Returns null when the header is missing or malformed.
function parseHostHeader(value) {
if (!value || typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/);
if (!match) return null;
if (match[2] !== undefined) {
const port = Number(match[2]);
if (!Number.isInteger(port) || port > 65535) return null;
}
return match[1].toLowerCase();
}
function buildAllowedHostnames(configuredHost) {
const set = new Set(LOOPBACK_HOSTNAMES);
if (configuredHost) set.add(String(configuredHost).toLowerCase());
return set;
}
function isAllowedHostHeader(hostHeader, allowedHostnames) {
const hostname = parseHostHeader(hostHeader);
if (!hostname) return false;
return allowedHostnames.has(hostname);
}
// Origin is absent on same-origin navigations and CLI clients; when present
// it must resolve to an allowed hostname.
function isAllowedOrigin(originHeader, allowedHostnames) {
if (!originHeader || typeof originHeader !== 'string') return true;
try {
const url = new URL(originHeader);
return allowedHostnames.has(url.hostname.toLowerCase());
} catch {
return false;
}
}
module.exports = {
LOOPBACK_HOSTNAMES,
buildAllowedHostnames,
isAllowedHostHeader,
isAllowedOrigin,
parseHostHeader
};
+309
View File
@@ -0,0 +1,309 @@
'use strict';
const { TextDecoder } = require('util');
const MEMORY_SCHEMA_VERSION = 'ecc.memory.v1';
const MEMORY_KINDS = Object.freeze([
'context',
'decision',
'fact',
'handoff',
'lesson',
'note',
'preference',
'runbook',
]);
const MEMORY_SCOPES = Object.freeze(['project', 'team', 'user']);
const MEMORY_TRUST_STATES = Object.freeze(['unreviewed']);
const MEMORY_STATUSES = Object.freeze(['active', 'rejected', 'superseded']);
const MAX_BODY_BYTES = 64 * 1024;
const MAX_DOCUMENT_BYTES = 128 * 1024;
const MAX_TITLE_CHARS = 200;
const MAX_TAGS = 32;
const MAX_LINKS = 64;
const MAX_TARGETS = 32;
const MEMORY_ID_PATTERN = /^mem_[a-z0-9][a-z0-9_-]{2,127}$/;
const SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const FRONTMATTER_FIELDS = Object.freeze([
['schema', 'schema'],
['id', 'id'],
['title', 'title'],
['kind', 'kind'],
['scope', 'scope'],
['trust', 'trust'],
['status', 'status'],
['source_harness', 'sourceHarness'],
['target_harnesses', 'targetHarnesses'],
['tags', 'tags'],
['links', 'links'],
['created_at', 'createdAt'],
['updated_at', 'updatedAt'],
]);
const FRONTMATTER_KEYS = new Map(FRONTMATTER_FIELDS);
const FATAL_UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
const SECRET_PATTERNS = Object.freeze([
{ label: 'provider API key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/i },
{ label: 'Stripe key', pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/ },
{ label: 'npm token', pattern: /\bnpm_[A-Za-z0-9]{20,}\b/ },
{ label: 'Hugging Face token', pattern: /\bhf_[A-Za-z0-9]{20,}\b/ },
{ label: 'GitHub token', pattern: /\bgh[pors]_[A-Za-z0-9]{16,}\b/ },
{ label: 'GitHub token', pattern: /\bgithub_pat_[A-Za-z0-9_]{16,}\b/ },
{ label: 'Google API key', pattern: /\bAIza[A-Za-z0-9_-]{16,}\b/ },
{ label: 'Slack token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
{ label: 'AWS access key', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
{ label: 'private key', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
]);
function hasUnsafeControlCharacters(value, allowBodyWhitespace = false) {
return Array.from(value).some(character => {
const codePoint = character.codePointAt(0);
const allowedWhitespace = allowBodyWhitespace
&& (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d);
const isControl = (codePoint <= 0x1f && !allowedWhitespace)
|| (codePoint >= 0x7f && codePoint <= 0x9f);
const isBidirectionalFormatting = (
(codePoint >= 0x202a && codePoint <= 0x202e)
|| (codePoint >= 0x2066 && codePoint <= 0x2069)
);
return isControl || isBidirectionalFormatting;
});
}
function asNonEmptyString(value, label, maxChars = 10_000) {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`${label} must be a non-empty string.`);
}
const normalized = value.trim();
if (normalized.length > maxChars) {
throw new Error(`${label} is too long (maximum ${maxChars} characters).`);
}
if (hasUnsafeControlCharacters(normalized)) {
throw new Error(`${label} must not contain control or bidirectional formatting characters.`);
}
return normalized;
}
function validateEnum(value, allowed, label) {
const normalized = asNonEmptyString(value, label, 64);
if (!allowed.includes(normalized)) {
throw new Error(`${label} must be one of: ${allowed.join(', ')}.`);
}
return normalized;
}
function validateSlug(value, label) {
const normalized = asNonEmptyString(value, label, 64);
if (!SLUG_PATTERN.test(normalized)) {
throw new Error(`${label} must be a lowercase letters/numbers slug.`);
}
return normalized;
}
function validateMemoryId(value) {
const normalized = asNonEmptyString(value, 'memory id', 132);
if (!MEMORY_ID_PATTERN.test(normalized)) {
throw new Error('memory id must match mem_<lowercase-id> and cannot contain a path.');
}
return normalized;
}
function uniqueStrings(values, { label, limit, validator }) {
if (!Array.isArray(values)) {
throw new Error(`${label} must be an array.`);
}
if (values.length > limit) {
throw new Error(`${label} has too many values (maximum ${limit}).`);
}
return values.reduce((result, value) => {
const normalized = validator(value);
if (result.includes(normalized)) {
throw new Error(`${label} must not contain duplicate values.`);
}
return [...result, normalized];
}, []);
}
function validateTimestamp(value, label) {
const normalized = asNonEmptyString(value, label, 64);
const parsed = new Date(normalized);
if (
!ISO_TIMESTAMP_PATTERN.test(normalized)
|| Number.isNaN(parsed.getTime())
|| parsed.toISOString() !== normalized
) {
throw new Error(`${label} must be an ISO-8601 timestamp.`);
}
return normalized;
}
function normalizeBody(value) {
if (typeof value !== 'string') {
throw new Error('memory body must be a string.');
}
if (hasUnsafeControlCharacters(value, true)) {
throw new Error('memory body must not contain unsafe control or bidirectional formatting characters.');
}
const normalized = value.trim();
if (normalized.length === 0) {
throw new Error('memory body must contain non-whitespace context.');
}
if (Buffer.byteLength(normalized, 'utf8') > MAX_BODY_BYTES) {
throw new Error(`memory body is too large (maximum ${MAX_BODY_BYTES} bytes).`);
}
return normalized;
}
function normalizeMemory(memory) {
if (!memory || typeof memory !== 'object' || Array.isArray(memory)) {
throw new Error('memory must be an object.');
}
const targetHarnesses = uniqueStrings(memory.targetHarnesses, {
label: 'target harnesses',
limit: MAX_TARGETS,
validator: value => validateSlug(value, 'target harness'),
});
if (targetHarnesses.length === 0) {
throw new Error('target harnesses must contain at least one harness or "all".');
}
if (memory.schema !== MEMORY_SCHEMA_VERSION) {
throw new Error('Unsupported memory schema.');
}
return {
schema: memory.schema,
id: validateMemoryId(memory.id),
title: asNonEmptyString(memory.title, 'memory title', MAX_TITLE_CHARS),
kind: validateEnum(memory.kind, MEMORY_KINDS, 'memory kind'),
scope: validateEnum(memory.scope, MEMORY_SCOPES, 'memory scope'),
trust: validateEnum(memory.trust, MEMORY_TRUST_STATES, 'memory trust'),
status: validateEnum(memory.status, MEMORY_STATUSES, 'memory status'),
sourceHarness: validateSlug(memory.sourceHarness, 'source harness'),
targetHarnesses,
tags: uniqueStrings(memory.tags, {
label: 'tags',
limit: MAX_TAGS,
validator: value => validateSlug(value, 'tag'),
}),
links: uniqueStrings(memory.links, {
label: 'links',
limit: MAX_LINKS,
validator: validateMemoryId,
}),
createdAt: validateTimestamp(memory.createdAt, 'created_at'),
updatedAt: validateTimestamp(memory.updatedAt, 'updated_at'),
body: normalizeBody(memory.body),
};
}
function serializeMemoryDocument(memory) {
const normalized = normalizeMemory(memory);
const metadata = FRONTMATTER_FIELDS.map(([serializedKey, objectKey]) => (
`${serializedKey}: ${JSON.stringify(normalized[objectKey])}`
)).join('\n');
const body = normalized.body.length > 0 ? `\n\n${normalized.body}` : '';
return `---\n${metadata}\n---${body}\n`;
}
function decodeUtf8(buffer, label = 'text') {
try {
return FATAL_UTF8_DECODER.decode(buffer);
} catch {
throw new Error(`${label} must contain valid UTF-8 text.`);
}
}
function parseFrontmatterLine(line, sourcePath, seen) {
const separator = line.indexOf(':');
if (separator <= 0) {
throw new Error(`Invalid memory frontmatter line in ${sourcePath}.`);
}
const serializedKey = line.slice(0, separator).trim();
const objectKey = FRONTMATTER_KEYS.get(serializedKey);
if (!objectKey) {
throw new Error(`Unknown memory frontmatter field in ${sourcePath}.`);
}
if (seen.has(objectKey)) {
throw new Error(`Duplicate memory frontmatter field in ${sourcePath}.`);
}
const rawValue = line.slice(separator + 1).trim();
try {
return { objectKey, value: JSON.parse(rawValue) };
} catch {
throw new Error(`Memory frontmatter field in ${sourcePath} must use a JSON value.`);
}
}
function parseMemoryDocument(source, sourcePath = '<memory>') {
const openingMarker = typeof source === 'string'
? /^---\r?\n/.exec(source)
: null;
if (!openingMarker) {
throw new Error(`Memory document ${sourcePath} must start with --- frontmatter.`);
}
if (Buffer.byteLength(source, 'utf8') > MAX_DOCUMENT_BYTES) {
throw new Error(`Memory document ${sourcePath} is too large.`);
}
const frontmatterStart = openingMarker[0].length;
const remainder = source.slice(frontmatterStart);
const closingMarker = /\r?\n---(?=\r?\n|$)/.exec(remainder);
if (!closingMarker) {
throw new Error(`Memory document ${sourcePath} has no closing frontmatter marker.`);
}
const frontmatterSource = remainder.slice(0, closingMarker.index);
const parsed = frontmatterSource.split(/\r?\n/).reduce((state, line) => {
const next = parseFrontmatterLine(line, sourcePath, state.seen);
return {
values: { ...state.values, [next.objectKey]: next.value },
seen: new Set([...state.seen, next.objectKey]),
};
}, { values: {}, seen: new Set() });
const missing = FRONTMATTER_FIELDS
.map(([, objectKey]) => objectKey)
.filter(objectKey => !parsed.seen.has(objectKey));
if (missing.length > 0) {
throw new Error(`Memory document ${sourcePath} is missing fields: ${missing.join(', ')}.`);
}
const afterMarker = remainder.slice(closingMarker.index + closingMarker[0].length);
const body = afterMarker.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
return normalizeMemory({ ...parsed.values, body });
}
function findPotentialSecrets(value) {
const text = typeof value === 'string' ? value : '';
return SECRET_PATTERNS
.filter(item => item.pattern.test(text))
.map(item => item.label)
.filter((label, index, labels) => labels.indexOf(label) === index);
}
module.exports = {
MAX_BODY_BYTES,
MAX_DOCUMENT_BYTES,
MEMORY_KINDS,
MEMORY_SCHEMA_VERSION,
MEMORY_SCOPES,
MEMORY_STATUSES,
MEMORY_TRUST_STATES,
asNonEmptyString,
decodeUtf8,
findPotentialSecrets,
hasUnsafeControlCharacters,
normalizeMemory,
parseMemoryDocument,
serializeMemoryDocument,
uniqueStrings,
validateEnum,
validateMemoryId,
validateSlug,
};
+793
View File
@@ -0,0 +1,793 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety');
const {
MAX_BODY_BYTES,
MAX_DOCUMENT_BYTES,
MEMORY_KINDS,
MEMORY_SCHEMA_VERSION,
MEMORY_SCOPES,
MEMORY_STATUSES,
MEMORY_TRUST_STATES,
asNonEmptyString,
decodeUtf8,
findPotentialSecrets,
hasUnsafeControlCharacters,
normalizeMemory,
parseMemoryDocument,
serializeMemoryDocument,
uniqueStrings,
validateEnum,
validateMemoryId,
validateSlug,
} = require('./memory-vault-format');
const DEFAULT_RECALL_SCOPES = Object.freeze(['project', 'team']);
const MAX_FILES = 5000;
const MAX_SCAN_BYTES = 16 * 1024 * 1024;
const MAX_DIAGNOSTICS = 100;
const MAX_QUERY_CHARS = 500;
const MAX_RESULTS = 100;
const PROJECT_MEMORY_GITIGNORE = '*\n!.gitignore\n';
const VAULT_ROOT_BOUNDARIES = Symbol('vaultRootBoundaries');
function findNearestProjectRoot(cwd) {
let current = path.resolve(cwd);
while (true) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return path.resolve(cwd);
}
current = parent;
}
}
function resolveOverride(value, cwd) {
return path.resolve(cwd, asNonEmptyString(value, 'memory root override', 4096));
}
function resolveVaultRoots(options = {}) {
const cwd = path.resolve(options.cwd || process.cwd());
const env = options.env || process.env;
const homeDir = path.resolve(
options.homeDir || env.HOME || env.USERPROFILE || os.homedir()
);
const projectRoot = findNearestProjectRoot(cwd);
const projectVault = env.ECC_MEMORY_PROJECT_ROOT
? resolveOverride(env.ECC_MEMORY_PROJECT_ROOT, cwd)
: path.join(projectRoot, '.ecc', 'memory');
const userVault = env.ECC_MEMORY_USER_ROOT
? resolveOverride(env.ECC_MEMORY_USER_ROOT, cwd)
: path.join(homeDir, '.ecc', 'memory');
const roots = {
project: path.join(projectVault, 'project'),
team: path.join(projectVault, 'team'),
user: userVault,
};
Object.defineProperty(roots, VAULT_ROOT_BOUNDARIES, {
value: Object.freeze({
project: env.ECC_MEMORY_PROJECT_ROOT
? realpathNearestExisting(projectVault)
: projectRoot,
team: env.ECC_MEMORY_PROJECT_ROOT
? realpathNearestExisting(projectVault)
: projectRoot,
user: env.ECC_MEMORY_USER_ROOT
? realpathNearestExisting(userVault)
: homeDir,
}),
enumerable: false,
configurable: false,
writable: false,
});
return Object.freeze(roots);
}
function assertMemoryRootSafe(roots, scope) {
if (!roots || typeof roots !== 'object' || Array.isArray(roots)) {
throw new Error('Memory roots must include a trusted boundary policy.');
}
const root = roots[scope];
if (typeof root !== 'string' || root.length === 0) {
throw new Error(`No memory root is configured for scope "${scope}".`);
}
const boundary = roots[VAULT_ROOT_BOUNDARIES]?.[scope];
if (typeof boundary !== 'string' || boundary.length === 0) {
throw new Error(`No trusted boundary policy is configured for memory scope "${scope}".`);
}
assertWithinTrustedRoot(root, boundary, 'access memory through a symlink');
if (fs.existsSync(root) && fs.lstatSync(root).isSymbolicLink()) {
throw new Error(`Refusing to access memory through symlink root: ${root}`);
}
return root;
}
function assertMemoryDirectorySafe(directory, root) {
assertWithinTrustedRoot(directory, root, 'access memory directory');
if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
throw new Error(`Refusing to access memory through symlink directory: ${directory}`);
}
return directory;
}
function sameFileIdentity(left, right) {
// The inode is the primary identity signal and must always match.
if (left.ino !== right.ino) {
return false;
}
// libuv 1.49.0 through 1.50.x resolve path-based stat() and lstat() on Windows
// through GetFileInformationByName, which leaves the volume serial unset, while
// fstat() on an open handle reports it. Comparing the two then never matches and
// every vault read and write is rejected. libuv 82cdfb75f fixed this in 1.51.0,
// so only Node 22.12-22.16 and 24.0-24.1 are affected, but the guard should not
// depend on the runtime's patch level. Compare dev only when both sides report
// one; POSIX always does, so the original strict behaviour is preserved there.
if (!left.dev || !right.dev) {
return true;
}
return left.dev === right.dev;
}
function readRegularTextFile(filePath, options = {}) {
const label = options.label || 'file';
const maxBytes = options.maxBytes || MAX_DOCUMENT_BYTES;
if (options.trustedRoot) {
assertWithinTrustedRoot(filePath, options.trustedRoot, `read ${label}`);
}
const flags = fs.constants.O_RDONLY
| (fs.constants.O_NOFOLLOW || 0)
| (fs.constants.O_NONBLOCK || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const opened = fs.fstatSync(descriptor, { bigint: true });
if (!opened.isFile()) {
throw new Error(`${label} must be a regular, non-symlink file.`);
}
const after = fs.lstatSync(filePath, { bigint: true });
if (
after.isSymbolicLink()
|| !after.isFile()
|| !sameFileIdentity(after, opened)
) {
throw new Error(`${label} must remain a regular, non-symlink file while it is opened.`);
}
if (options.trustedRoot) {
assertWithinTrustedRoot(filePath, options.trustedRoot, `read ${label}`);
}
if (opened.size > BigInt(maxBytes)) {
throw new Error(`${label} is too large (${opened.size} bytes).`);
}
const chunks = [];
let total = 0;
while (total <= maxBytes) {
const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
chunks.push(buffer.subarray(0, bytesRead));
total += bytesRead;
}
if (total > maxBytes) {
throw new Error(`${label} is too large (maximum ${maxBytes} bytes).`);
}
return decodeUtf8(Buffer.concat(chunks, total), label);
} finally {
fs.closeSync(descriptor);
}
}
function writeCreateOnlyTextFile(filePath, content, trustedRoot) {
assertWithinTrustedRoot(filePath, trustedRoot, 'write memory');
const temporaryPath = path.join(
path.dirname(filePath),
`.ecc-memory-${process.pid}-${crypto.randomUUID()}.tmp`
);
const flags = fs.constants.O_WRONLY
| fs.constants.O_CREAT
| fs.constants.O_EXCL
| (fs.constants.O_NOFOLLOW || 0);
let descriptor;
let operationError;
let cleanupError;
try {
descriptor = fs.openSync(temporaryPath, flags, 0o600);
const opened = fs.fstatSync(descriptor, { bigint: true });
const after = fs.lstatSync(temporaryPath, { bigint: true });
assertWithinTrustedRoot(temporaryPath, trustedRoot, 'write memory');
if (
!opened.isFile()
|| after.isSymbolicLink()
|| !after.isFile()
|| !sameFileIdentity(after, opened)
) {
throw new Error('Memory destination changed while it was being created.');
}
fs.writeFileSync(descriptor, content, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
assertWithinTrustedRoot(filePath, trustedRoot, 'write memory');
fs.linkSync(temporaryPath, filePath);
} catch (error) {
operationError = error;
} finally {
if (descriptor !== undefined) {
try {
fs.closeSync(descriptor);
} catch (error) {
cleanupError = error;
}
}
try {
fs.unlinkSync(temporaryPath);
} catch (error) {
if (!error || error.code !== 'ENOENT') cleanupError = cleanupError || error;
}
}
if (operationError) throw operationError;
if (cleanupError) throw cleanupError;
}
function ensureProjectScopeIgnored(roots, scope) {
if (scope !== 'project') return;
const root = roots.project;
const ignorePath = path.join(root, '.gitignore');
try {
writeCreateOnlyTextFile(ignorePath, PROJECT_MEMORY_GITIGNORE, root);
} catch (error) {
if (!error || error.code !== 'EEXIST') throw error;
const existing = readRegularTextFile(ignorePath, {
label: 'project memory .gitignore',
maxBytes: MAX_DOCUMENT_BYTES,
trustedRoot: root,
});
if (existing !== PROJECT_MEMORY_GITIGNORE) {
throw new Error(
'Project memory .gitignore does not contain the required fail-closed rules.'
);
}
}
}
function normalizeScopes(scopes = MEMORY_SCOPES) {
const values = Array.isArray(scopes) ? scopes : [scopes];
return uniqueStrings(values, {
label: 'scopes',
limit: MEMORY_SCOPES.length,
validator: value => validateEnum(value, MEMORY_SCOPES, 'memory scope'),
});
}
function initializeVault(options = {}) {
const roots = options.roots || resolveVaultRoots(options);
const scopes = normalizeScopes(options.scopes || DEFAULT_RECALL_SCOPES);
const directories = scopes.flatMap(scope => {
const root = assertMemoryRootSafe(roots, scope);
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
ensureProjectScopeIgnored(roots, scope);
return MEMORY_KINDS.map(kind => {
const directory = path.join(root, `${kind}s`);
assertMemoryDirectorySafe(directory, root);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
return directory;
});
});
return { scopes, roots, directories };
}
function defaultMemoryId(now = new Date()) {
const day = now.toISOString().slice(0, 10).replace(/-/g, '');
const random = crypto.randomUUID().replace(/-/g, '').slice(0, 20);
return `mem_${day}_${random}`;
}
function normalizeSaveInput(input, options) {
const now = options.now ? options.now() : new Date().toISOString();
const id = input.id || (
options.idFactory ? options.idFactory() : defaultMemoryId(new Date(now))
);
return normalizeMemory({
schema: MEMORY_SCHEMA_VERSION,
id,
title: input.title,
kind: input.kind || 'note',
scope: input.scope || 'project',
trust: 'unreviewed',
status: 'active',
sourceHarness: input.sourceHarness || 'unknown',
targetHarnesses: input.targetHarnesses || ['all'],
tags: input.tags || [],
links: input.links || [],
createdAt: now,
updatedAt: now,
body: input.body || '',
});
}
function saveMemory(input, options = {}) {
const roots = options.roots || resolveVaultRoots(options);
const memory = normalizeSaveInput(input || {}, options);
const secretKinds = findPotentialSecrets(JSON.stringify(memory));
if (secretKinds.length > 0) {
throw new Error(`Refusing to save memory containing a suspected secret (${secretKinds.join(', ')}).`);
}
const root = assertMemoryRootSafe(roots, memory.scope);
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
ensureProjectScopeIgnored(roots, memory.scope);
const directory = path.join(root, `${memory.kind}s`);
assertMemoryDirectorySafe(directory, root);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const destination = path.join(directory, `${memory.id}.md`);
try {
writeCreateOnlyTextFile(destination, serializeMemoryDocument(memory), root);
} catch (error) {
if (error && error.code === 'EEXIST') {
throw new Error(`Memory ${memory.id} already exists; writes are create-only.`);
}
throw error;
}
return { memory, path: destination };
}
function walkMemoryRoot(root, maxEntries = MAX_FILES) {
if (!root || !fs.existsSync(root)) {
return {
paths: [],
skippedSymlinks: [],
skippedSymlinkCount: 0,
truncated: false,
visitedCount: 0,
};
}
const paths = [];
const skippedSymlinks = [];
let skippedSymlinkCount = 0;
let visitedCount = 0;
let truncated = false;
const walk = (directory, depth) => {
if (depth > 8 || visitedCount >= maxEntries) {
truncated = true;
return;
}
const handle = fs.opendirSync(directory);
const entries = [];
try {
while (entries.length < maxEntries - visitedCount) {
const entry = handle.readSync();
if (!entry) break;
entries.push(entry);
}
if (handle.readSync() !== null) truncated = true;
} finally {
handle.closeSync();
}
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
if (visitedCount >= maxEntries) {
truncated = true;
break;
}
visitedCount += 1;
const entryPath = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
skippedSymlinkCount += 1;
if (skippedSymlinks.length < MAX_DIAGNOSTICS) {
skippedSymlinks.push(entryPath);
}
continue;
}
if (entry.isDirectory() && !entry.name.startsWith('.')) {
walk(entryPath, depth + 1);
continue;
}
const include = entry.isFile()
&& entry.name.endsWith('.md')
&& !entry.name.startsWith('.');
if (include) paths.push(entryPath);
}
};
walk(root, 0);
return {
paths,
skippedSymlinks,
skippedSymlinkCount,
truncated,
visitedCount,
};
}
function vaultRelativePath(scope, root, filePath) {
const relative = path.relative(root, filePath).split(path.sep).join('/');
return `${scope}:${relative}`;
}
function assertMemoryMatchesLocation(memory, scope, root, filePath) {
const [kindDirectory] = path.relative(root, filePath).split(path.sep);
if (memory.scope !== scope || kindDirectory !== `${memory.kind}s`) {
const error = new Error('Memory metadata does not match its vault location.');
error.code = 'ECC_MEMORY_LOCATION_MISMATCH';
throw error;
}
}
function publicMemoryFileError(error) {
if (error?.code === 'ECC_MEMORY_SECRET') {
return { code: 'suspected-secret', message: 'Memory document was quarantined.' };
}
if (error?.code === 'ECC_MEMORY_LOCATION_MISMATCH') {
return {
code: 'location-mismatch',
message: 'Memory metadata does not match its vault location.',
};
}
return {
code: 'invalid-document',
message: 'Memory document is invalid or unreadable.',
};
}
function readMemoryFiles(options = {}) {
const roots = options.roots || resolveVaultRoots(options);
const scopes = normalizeScopes(options.scopes || DEFAULT_RECALL_SCOPES);
const entries = [];
const invalidFiles = [];
const skippedSymlinks = [];
let invalidFileCount = 0;
let skippedSymlinkCount = 0;
let visitedCount = 0;
let scannedBytes = 0;
let truncated = false;
for (const scope of scopes) {
if (visitedCount >= MAX_FILES || scannedBytes >= MAX_SCAN_BYTES) {
truncated = true;
break;
}
const root = assertMemoryRootSafe(roots, scope);
const walked = walkMemoryRoot(root, MAX_FILES - visitedCount);
visitedCount += walked.visitedCount;
truncated = truncated || walked.truncated;
skippedSymlinkCount += walked.skippedSymlinkCount;
for (const skippedPath of walked.skippedSymlinks) {
if (skippedSymlinks.length >= MAX_DIAGNOSTICS) break;
skippedSymlinks.push(vaultRelativePath(scope, root, skippedPath));
}
for (const filePath of walked.paths) {
if (scannedBytes >= MAX_SCAN_BYTES) {
truncated = true;
break;
}
try {
const source = readRegularTextFile(filePath, {
label: 'memory document',
maxBytes: MAX_DOCUMENT_BYTES,
trustedRoot: root,
});
const sourceBytes = Buffer.byteLength(source, 'utf8');
if (scannedBytes + sourceBytes > MAX_SCAN_BYTES) {
truncated = true;
break;
}
scannedBytes += sourceBytes;
const memory = parseMemoryDocument(source, filePath);
assertMemoryMatchesLocation(memory, scope, root, filePath);
if (findPotentialSecrets(JSON.stringify(memory)).length > 0) {
const error = new Error('Memory contains a suspected secret.');
error.code = 'ECC_MEMORY_SECRET';
throw error;
}
entries.push({
memory,
path: vaultRelativePath(scope, root, filePath),
});
} catch (error) {
invalidFileCount += 1;
if (invalidFiles.length < MAX_DIAGNOSTICS) {
invalidFiles.push({
path: vaultRelativePath(scope, root, filePath),
...publicMemoryFileError(error),
});
}
}
}
}
return {
entries,
invalidFiles,
invalidFileCount,
skippedSymlinks,
skippedSymlinkCount,
scannedBytes,
truncated,
diagnosticsTruncated: invalidFileCount > invalidFiles.length
|| skippedSymlinkCount > skippedSymlinks.length,
};
}
function tokenize(value) {
return String(value || '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
}
function countOccurrences(haystack, needle) {
if (!needle) return 0;
let count = 0;
let offset = 0;
while (count < 8) {
const index = haystack.indexOf(needle, offset);
if (index < 0) break;
count += 1;
offset = index + needle.length;
}
return count;
}
function scoreMemory(memory, query) {
const normalizedQuery = query.toLowerCase();
const tokens = Array.from(new Set(tokenize(query)));
const title = memory.title.toLowerCase();
const body = memory.body.toLowerCase();
const tags = memory.tags.map(tag => tag.toLowerCase());
const metadata = [
memory.kind,
memory.scope,
memory.sourceHarness,
...memory.targetHarnesses,
].join(' ').toLowerCase();
const phraseScore = normalizedQuery && title.includes(normalizedQuery)
? 20
: normalizedQuery && body.includes(normalizedQuery) ? 5 : 0;
return tokens.reduce((score, token) => (
score
+ (title.includes(token) ? 8 : 0)
+ (tags.includes(token) ? 6 : 0)
+ (metadata.includes(token) ? 3 : 0)
+ Math.min(countOccurrences(body, token), 5)
), phraseScore);
}
function buildExcerpt(body, query, maxChars = 240) {
const normalized = String(body || '').replace(/\s+/g, ' ').trim();
if (normalized.length <= maxChars) return normalized;
const tokens = tokenize(query);
const lower = normalized.toLowerCase();
const matchIndex = tokens.reduce((best, token) => {
const index = lower.indexOf(token);
if (index < 0) return best;
return best < 0 ? index : Math.min(best, index);
}, -1);
const start = Math.max(0, (matchIndex < 0 ? 0 : matchIndex) - 60);
const prefix = start > 0 ? '…' : '';
const suffix = start + maxChars < normalized.length ? '…' : '';
return `${prefix}${normalized.slice(start, start + maxChars)}${suffix}`;
}
function summarizeMemory(memory) {
return Object.fromEntries(
Object.entries(memory).filter(([key]) => key !== 'body')
);
}
function searchMemories(query, options = {}) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
if (normalizedQuery.length > MAX_QUERY_CHARS) {
throw new Error(`memory search query is too long (maximum ${MAX_QUERY_CHARS} characters).`);
}
if (hasUnsafeControlCharacters(normalizedQuery)) {
throw new Error('memory search query must not contain control characters.');
}
const kinds = options.kinds
? uniqueStrings(options.kinds, {
label: 'kinds',
limit: MEMORY_KINDS.length,
validator: value => validateEnum(value, MEMORY_KINDS, 'memory kind'),
})
: null;
const trust = options.trust
? validateEnum(options.trust, MEMORY_TRUST_STATES, 'memory trust')
: null;
const targetHarness = options.targetHarness
? validateSlug(options.targetHarness, 'target harness')
: null;
const limit = Math.max(1, Math.min(Number(options.limit) || 20, MAX_RESULTS));
const loaded = readMemoryFiles({ ...options, scopes: options.scopes || options.scope });
const results = loaded.entries
.filter(({ memory }) => memory.status === 'active')
.filter(({ memory }) => !kinds || kinds.includes(memory.kind))
.filter(({ memory }) => !trust || memory.trust === trust)
.filter(({ memory }) => (
!targetHarness
|| memory.targetHarnesses.includes('all')
|| memory.targetHarnesses.includes(targetHarness)
))
.map(entry => ({
...entry,
score: normalizedQuery ? scoreMemory(entry.memory, normalizedQuery) : 0,
excerpt: buildExcerpt(entry.memory.body, normalizedQuery),
}))
.filter(result => normalizedQuery.length === 0 || result.score > 0)
.sort((left, right) => (
right.score - left.score
|| right.memory.updatedAt.localeCompare(left.memory.updatedAt)
|| left.memory.id.localeCompare(right.memory.id)
))
.slice(0, limit)
.map(result => ({
memory: summarizeMemory(result.memory),
score: result.score,
excerpt: result.excerpt,
}));
return {
results,
diagnostics: {
invalidFiles: loaded.invalidFiles,
invalidFileCount: loaded.invalidFileCount,
skippedSymlinks: loaded.skippedSymlinks,
skippedSymlinkCount: loaded.skippedSymlinkCount,
scannedBytes: loaded.scannedBytes,
truncated: loaded.truncated,
diagnosticsTruncated: loaded.diagnosticsTruncated,
},
};
}
function readMemoryById(id, options = {}) {
const memoryId = validateMemoryId(id);
const targetHarness = options.targetHarness
? validateSlug(options.targetHarness, 'target harness')
: null;
const loaded = readMemoryFiles(options);
const matches = loaded.entries
.filter(entry => entry.memory.id === memoryId)
.filter(entry => (
!targetHarness
|| entry.memory.targetHarnesses.includes('all')
|| entry.memory.targetHarnesses.includes(targetHarness)
));
if (matches.length === 0) {
throw new Error(`Memory ${memoryId} was not found.`);
}
if (matches.length > 1) {
throw new Error(`Memory ${memoryId} is duplicated in ${matches.length} files.`);
}
const allBacklinks = loaded.entries
.filter(entry => entry.memory.links.includes(memoryId))
.filter(entry => entry.memory.status === 'active')
.map(entry => entry.memory)
.filter(memory => (
!targetHarness
|| memory.targetHarnesses.includes('all')
|| memory.targetHarnesses.includes(targetHarness)
))
.sort((left, right) => left.id.localeCompare(right.id));
const backlinks = allBacklinks
.slice(0, MAX_RESULTS)
.map(summarizeMemory);
return {
...matches[0],
backlinks,
backlinksTruncated: allBacklinks.length > backlinks.length,
};
}
function doctorMemoryVault(options = {}) {
const loaded = readMemoryFiles(options);
const targetHarness = options.targetHarness
? validateSlug(options.targetHarness, 'target harness')
: null;
const visibleEntries = loaded.entries.filter(entry => (
!targetHarness
|| entry.memory.targetHarnesses.includes('all')
|| entry.memory.targetHarnesses.includes(targetHarness)
));
const byId = new Map();
for (const entry of visibleEntries) {
const paths = byId.get(entry.memory.id) || [];
paths.push(entry.path);
byId.set(entry.memory.id, paths);
}
const allDuplicateIds = Array.from(byId.entries())
.filter(([, paths]) => paths.length > 1)
.map(([id, paths]) => ({ id, paths }))
.sort((left, right) => left.id.localeCompare(right.id));
const duplicateIds = allDuplicateIds.slice(0, MAX_DIAGNOSTICS);
const knownIds = new Set(byId.keys());
const allBrokenLinks = [];
let brokenLinkCount = 0;
for (const entry of visibleEntries) {
for (const targetId of entry.memory.links) {
if (!knownIds.has(targetId)) {
brokenLinkCount += 1;
if (allBrokenLinks.length < MAX_DIAGNOSTICS) {
allBrokenLinks.push({
sourceId: entry.memory.id,
targetId,
path: entry.path,
});
}
}
}
}
const brokenLinks = [...allBrokenLinks]
.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
const ok = loaded.invalidFileCount === 0
&& allDuplicateIds.length === 0
&& brokenLinkCount === 0
&& loaded.skippedSymlinkCount === 0
&& !loaded.truncated;
return {
schemaVersion: 'ecc.memory.doctor.v1',
ok,
memoryCount: visibleEntries.length,
invalidFiles: loaded.invalidFiles,
invalidFileCount: loaded.invalidFileCount,
duplicateIds,
duplicateIdCount: allDuplicateIds.length,
brokenLinks,
brokenLinkCount,
skippedSymlinks: loaded.skippedSymlinks,
skippedSymlinkCount: loaded.skippedSymlinkCount,
scannedBytes: loaded.scannedBytes,
truncated: loaded.truncated,
diagnosticsTruncated: loaded.diagnosticsTruncated
|| allDuplicateIds.length > duplicateIds.length
|| brokenLinkCount > brokenLinks.length,
};
}
module.exports = {
DEFAULT_RECALL_SCOPES,
MAX_BODY_BYTES,
MAX_DIAGNOSTICS,
MAX_DOCUMENT_BYTES,
MAX_FILES,
MAX_QUERY_CHARS,
MAX_RESULTS,
MAX_SCAN_BYTES,
MEMORY_KINDS,
MEMORY_SCHEMA_VERSION,
MEMORY_SCOPES,
MEMORY_STATUSES,
MEMORY_TRUST_STATES,
defaultMemoryId,
decodeUtf8,
doctorMemoryVault,
findPotentialSecrets,
findNearestProjectRoot,
initializeVault,
normalizeMemory,
parseMemoryDocument,
readRegularTextFile,
readMemoryById,
readMemoryFiles,
resolveVaultRoots,
sameFileIdentity,
saveMemory,
scoreMemory,
searchMemories,
serializeMemoryDocument,
tokenize,
};
+444
View File
@@ -0,0 +1,444 @@
'use strict';
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const path = require('path');
const { assertSafeInstallOperation } = require('./install/apply');
const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety');
const VALID_CLAUDE_SCOPES = new Set(['user', 'project', 'local']);
const VALID_CLAUDE_HOOKS = new Set(['off', 'minimal', 'standard', 'strict']);
const VALID_PROFILES = new Set(['minimal', 'core', 'developer', 'security', 'research', 'full']);
function catalogHelpers() {
return require('./harness-capabilities');
}
function normalizeGuidedInstallRequest(input = {}) {
const { normalizeHarnessSelection } = catalogHelpers();
const harnesses = normalizeHarnessSelection(input.harnesses || []);
if (harnesses.length === 0) {
throw new Error('Choose at least one guided harness: Claude, Codex, or Kimi.');
}
const includesClaude = harnesses.includes('claude');
const includesKimi = harnesses.includes('kimi');
if (!includesClaude && (input.claudeScope !== undefined || input.claudeHooks !== undefined)) {
throw new Error('Claude scope and hook options require Claude to be selected.');
}
if (!includesKimi && input.profile !== undefined) {
throw new Error('The managed install profile requires Kimi to be selected.');
}
const claudeScope = includesClaude ? (input.claudeScope || 'user') : undefined;
const claudeHooks = includesClaude ? (input.claudeHooks || 'standard') : undefined;
const profile = includesKimi ? (input.profile || 'core') : undefined;
if (claudeScope && !VALID_CLAUDE_SCOPES.has(claudeScope)) {
throw new Error(`Invalid Claude scope: ${claudeScope}`);
}
if (claudeHooks && !VALID_CLAUDE_HOOKS.has(claudeHooks)) {
throw new Error(`Invalid Claude hooks preference: ${claudeHooks}`);
}
if (profile && !VALID_PROFILES.has(profile)) {
throw new Error(`Invalid Kimi install profile: ${profile}`);
}
return {
harnesses,
...(claudeHooks ? { claudeHooks } : {}),
...(claudeScope ? { claudeScope } : {}),
dryRun: Boolean(input.dryRun),
json: Boolean(input.json),
...(profile ? { profile } : {}),
yes: Boolean(input.yes),
};
}
function canonicalPath(filePath) {
return realpathNearestExisting(filePath);
}
function pathsMatch(left, right) {
return canonicalPath(left) === canonicalPath(right);
}
function fingerprintFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, sha256: null };
return {
exists: true,
sha256: crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
};
}
function operationIdentityMatches(stateOperation, plannedOperation) {
return [
'kind',
'moduleId',
'sourceRelativePath',
'strategy',
'scaffoldOnly',
].every(field => stateOperation[field] === plannedOperation[field]);
}
function assertInstallStateUnchanged(plan, expectedFingerprint) {
const currentFingerprint = fingerprintFile(plan.installStatePath);
if (
currentFingerprint.exists !== expectedFingerprint.exists
|| currentFingerprint.sha256 !== expectedFingerprint.sha256
) {
throw new Error(
`Refusing to overwrite an unowned or changed install-state at ${plan.installStatePath}. `
+ 'Re-run the guided preview and review the existing state before retrying.'
);
}
}
function assertPriorInstallStateMatchesPlan(state, plan) {
const target = state.target || {};
const adapter = plan.adapter || {};
if (
target.id !== adapter.id
|| target.target !== adapter.target
|| target.kind !== adapter.kind
) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'target identity does not match the current Kimi install plan.'
);
}
if (!pathsMatch(target.root, plan.targetRoot)) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'recorded root does not match the current install root.'
);
}
if (!pathsMatch(target.installStatePath, plan.installStatePath)) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'recorded install-state path does not match the current install-state path.'
);
}
}
function readOwnedDestinations(plan, dependencies) {
if (!plan.installStatePath) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
try {
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
} catch (error) {
throw new Error(`Refusing to trust managed install-state path: ${error.message}`);
}
if (!fs.existsSync(plan.installStatePath)) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
const readState = dependencies.readInstallState || require('./install-state').readInstallState;
const initialFingerprint = fingerprintFile(plan.installStatePath);
const state = readState(plan.installStatePath);
const validatedFingerprint = fingerprintFile(plan.installStatePath);
if (
initialFingerprint.exists !== validatedFingerprint.exists
|| initialFingerprint.sha256 !== validatedFingerprint.sha256
) {
throw new Error(
`Refusing to trust install-state that changed during validation: ${plan.installStatePath}.`
);
}
assertPriorInstallStateMatchesPlan(state, plan);
const plannedByDestination = new Map(plan.operations.map(operation => [
canonicalPath(operation.destinationPath),
operation,
]));
const destinations = new Set();
for (const operation of state.operations || []) {
if (operation.ownership !== 'managed') {
throw new Error(
`Refusing to trust non-managed ownership from install-state at ${plan.installStatePath}.`
);
}
const destinationPath = operation.destinationPath;
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'trust install-state ownership');
const canonicalDestination = canonicalPath(destinationPath);
const plannedOperation = plannedByDestination.get(canonicalDestination);
if (!plannedOperation) continue;
if (!operationIdentityMatches(operation, plannedOperation)) {
throw new Error(
`Refusing unverified ownership from install-state at ${plan.installStatePath}: `
+ `operation identity does not match the current plan for ${destinationPath}.`
);
}
const currentFingerprint = fingerprintFile(destinationPath);
if (
!currentFingerprint.exists
|| !/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')
|| currentFingerprint.sha256 !== operation.contentSha256.toLowerCase()
) {
throw new Error(
`Refusing unverified ownership from install-state at ${plan.installStatePath}: `
+ `content digest does not match ${destinationPath}.`
);
}
destinations.add(canonicalDestination);
}
return { destinations, stateFingerprint: validatedFingerprint };
}
function assertMergeDestination(destinationPath) {
if (!fs.existsSync(destinationPath)) return null;
let current;
try {
current = JSON.parse(fs.readFileSync(destinationPath, 'utf8'));
} catch (error) {
throw new Error(`Cannot merge ECC configuration into invalid JSON at ${destinationPath}: ${error.message}`);
}
if (!current || typeof current !== 'object' || Array.isArray(current)) {
throw new Error(`Cannot merge ECC configuration at ${destinationPath}: expected a JSON object.`);
}
return current;
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function findJsonConflicts(current, patch, prefix = '') {
if (!isPlainObject(patch)) return [];
return Object.entries(patch).flatMap(([key, patchValue]) => {
if (!Object.prototype.hasOwnProperty.call(current, key)) return [];
const currentValue = current[key];
const field = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(currentValue) && isPlainObject(patchValue)) {
return findJsonConflicts(currentValue, patchValue, field);
}
return JSON.stringify(currentValue) === JSON.stringify(patchValue) ? [] : [field];
});
}
function classifyManagedOperation(operation, ownedDestinations) {
const destinationPath = operation.destinationPath;
if (!fs.existsSync(destinationPath)) return 'create';
const canonicalDestination = canonicalPath(destinationPath);
if (operation.kind === 'merge-json') {
const current = assertMergeDestination(destinationPath);
if (ownedDestinations.has(canonicalDestination)) return 'managed-json-update';
const conflicts = findJsonConflicts(current, operation.mergePayload);
if (conflicts.length > 0) {
throw new Error(
`Refusing to overwrite unowned JSON fields at ${destinationPath}: ${conflicts.join(', ')}`
);
}
return 'json-merge';
}
if (ownedDestinations.has(canonicalDestination)) return 'managed-update';
if (
operation.kind === 'copy-file'
&& typeof operation.sourcePath === 'string'
&& fs.existsSync(operation.sourcePath)
&& fs.statSync(destinationPath).isFile()
&& fs.readFileSync(operation.sourcePath).equals(fs.readFileSync(destinationPath))
) {
return 'identical';
}
throw new Error(`Refusing to replace unowned existing file: ${destinationPath}`);
}
function writableRequirement(destinationPath) {
if (fs.existsSync(destinationPath)) {
const mode = fs.statSync(destinationPath).isDirectory()
? fs.constants.W_OK | fs.constants.X_OK
: fs.constants.W_OK;
return { candidatePath: destinationPath, mode };
}
let candidatePath = path.dirname(destinationPath);
while (!fs.existsSync(candidatePath)) {
const parentPath = path.dirname(candidatePath);
if (parentPath === candidatePath) break;
candidatePath = parentPath;
}
return {
candidatePath,
mode: fs.constants.W_OK | fs.constants.X_OK,
};
}
function assertManagedDestinationsWritable(plan, dependencies) {
const accessSync = dependencies.accessSync || fs.accessSync;
const destinationPaths = [
...plan.operations.map(operation => operation.destinationPath),
...(plan.installStatePath ? [plan.installStatePath] : []),
];
const requirements = new Map();
for (const destinationPath of destinationPaths) {
const requirement = writableRequirement(destinationPath);
const existingMode = requirements.get(requirement.candidatePath) || 0;
requirements.set(requirement.candidatePath, existingMode | requirement.mode);
}
for (const [candidatePath, mode] of requirements) {
try {
accessSync(candidatePath, mode);
} catch (_error) {
const label = plan.target === 'kimi' ? 'Kimi' : 'Managed install';
throw new Error(
`${label} destination is not writable by the current user: ${candidatePath}. `
+ 'Fix the project ownership or permissions, then retry.'
);
}
}
}
function preflightManagedPlan(plan, dependencies = {}) {
if (!plan || !Array.isArray(plan.operations)) {
throw new Error('A managed install plan with operations is required.');
}
const ownership = readOwnedDestinations(plan, dependencies);
const operations = plan.operations.map(operation => {
assertSafeInstallOperation(plan, operation);
return {
destinationPath: operation.destinationPath,
kind: operation.kind,
classification: classifyManagedOperation(operation, ownership.destinations),
};
});
assertManagedDestinationsWritable(plan, dependencies);
return {
plan,
operations,
ownershipSnapshot: {
destinations: [...ownership.destinations],
stateFingerprint: ownership.stateFingerprint,
},
};
}
function applyPreflightedManagedPlan(entry) {
const preview = entry.preview && entry.preview.ownershipSnapshot
? entry.preview
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
const expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let operationIndex = 0;
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
return require('./install-executor').applyInstallPlan(preview.plan, {
beforeOperationWrite({ operation }) {
assertStateUnchanged();
const expected = preview.operations[operationIndex];
const currentClassification = classifyManagedOperation(operation, ownedDestinations);
const destination = canonicalPath(operation.destinationPath);
if (
!expected
|| expected.kind !== operation.kind
|| canonicalPath(expected.destinationPath) !== destination
|| expected.classification !== currentClassification
) {
throw new Error(
`Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.`
);
}
ownedDestinations.add(destination);
operationIndex += 1;
},
beforeInstallStateWrite: assertStateUnchanged,
});
}
function defaultDependencies(options = {}) {
return {
previewClaude: request => require('../setup').reconcileClaudePlugin(
{ dryRun: true, hooks: request.claudeHooks, scope: request.claudeScope }
),
previewCodex: () => require('./codex-plugin-setup').reconcileCodexPlugin({ dryRun: true }),
createManagedPlan: request => require('./install/runtime').createInstallPlanFromRequest(
require('./install/request').normalizeInstallRequest({
profileId: request.profile,
target: 'kimi',
}),
{
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd(),
sourceRoot: options.sourceRoot,
}
),
preflightManaged: preflightManagedPlan,
applyClaude: request => require('../setup').reconcileClaudePlugin(
{ dryRun: false, hooks: request.claudeHooks, scope: request.claudeScope }
),
applyCodex: () => require('./codex-plugin-setup').reconcileCodexPlugin({ dryRun: false }),
applyManaged: applyPreflightedManagedPlan,
};
}
async function createMultiHarnessPlan(request, injected = {}, options = {}) {
const dependencies = { ...defaultDependencies(options), ...injected };
let entries = [];
for (const id of request.harnesses) {
if (id === 'claude') {
entries = [...entries, { id, channel: 'native-plugin', preview: await dependencies.previewClaude(request) }];
} else if (id === 'codex') {
entries = [...entries, { id, channel: 'native-plugin', preview: await dependencies.previewCodex(request) }];
} else if (id === 'kimi') {
const managedPlan = await dependencies.createManagedPlan(request);
entries = [...entries, {
id,
channel: 'managed-project',
preview: await dependencies.preflightManaged(managedPlan),
}];
} else {
throw new Error(`Unsupported guided harness: ${id}`);
}
}
return { harnesses: entries, request };
}
async function applyMultiHarnessPlan(plan, injected = {}, options = {}) {
const dependencies = { ...defaultDependencies(options), ...injected };
if (plan.request.dryRun) {
return { status: 'preview', completed: [], retryHarnesses: [...plan.request.harnesses] };
}
let completed = [];
for (let index = 0; index < plan.harnesses.length; index += 1) {
const entry = plan.harnesses[index];
try {
let result;
if (entry.id === 'claude') result = await dependencies.applyClaude(plan.request, entry);
else if (entry.id === 'codex') result = await dependencies.applyCodex(plan.request, entry);
else if (entry.preview && entry.preview.plan) {
const latestPreview = dependencies.preflightManaged(entry.preview.plan);
result = await dependencies.applyManaged(
{ ...entry, preview: latestPreview },
plan.request
);
} else {
result = await dependencies.applyManaged(entry, plan.request);
}
completed = [...completed, { id: entry.id, result }];
} catch (error) {
return {
status: completed.length > 0 ? 'partial' : 'failed',
completed,
failure: { id: entry.id, message: error.message },
retryHarnesses: plan.harnesses.slice(index).map(item => item.id),
};
}
}
return { status: 'complete', completed, retryHarnesses: [] };
}
module.exports = {
VALID_CLAUDE_HOOKS,
VALID_CLAUDE_SCOPES,
VALID_PROFILES,
applyMultiHarnessPlan,
createMultiHarnessPlan,
normalizeGuidedInstallRequest,
preflightManagedPlan,
findJsonConflicts,
};
+6 -3
View File
@@ -43,7 +43,10 @@ const PACKAGE_MANAGERS = {
},
bun: {
name: 'bun',
lockFile: 'bun.lockb',
lockFile: 'bun.lock',
// Bun switched its default lockfile from the binary bun.lockb to the
// text-based bun.lock. Keep recognizing the legacy file too.
lockFileAliases: ['bun.lockb'],
installCmd: 'bun install',
runCmd: 'bun run',
execCmd: 'bunx',
@@ -92,9 +95,9 @@ function saveConfig(config) {
function detectFromLockFile(projectDir = process.cwd()) {
for (const pmName of DETECTION_PRIORITY) {
const pm = PACKAGE_MANAGERS[pmName];
const lockFilePath = path.join(projectDir, pm.lockFile);
const lockFileNames = [pm.lockFile, ...(pm.lockFileAliases || [])];
if (fs.existsSync(lockFilePath)) {
if (lockFileNames.some(lockFileName => fs.existsSync(path.join(projectDir, lockFileName)))) {
return pmName;
}
}
+42 -15
View File
@@ -10,14 +10,18 @@ const path = require('path');
* (a cloned/forked repo can ship a crafted `.cursor/ecc-install-state.json`).
* `repair`/`uninstall`/`auto-update` replay recorded operations, so every
* write/delete destination MUST be confined to the adapter-derived trusted
* root never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
* root - never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
*/
function safeRealpath(target) {
function pathEntryExists(target) {
try {
return fs.realpathSync(path.resolve(target));
} catch {
return path.resolve(target);
fs.lstatSync(target);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
@@ -29,7 +33,7 @@ function safeRealpath(target) {
function realpathNearestExisting(target) {
let current = path.resolve(target);
const tail = [];
while (!fs.existsSync(current)) {
while (!pathEntryExists(current)) {
const parent = path.dirname(current);
if (parent === current) {
break;
@@ -37,7 +41,7 @@ function realpathNearestExisting(target) {
tail.unshift(path.basename(current));
current = parent;
}
const real = safeRealpath(current);
const real = fs.realpathSync(current);
return tail.length > 0 ? path.join(real, ...tail) : real;
}
@@ -45,17 +49,33 @@ function realpathNearestExisting(target) {
* True when `target` resolves to `root` itself or a path beneath it, with
* symlinks resolved on both sides.
*/
function resolveContainment(target, root) {
const realRoot = realpathNearestExisting(root);
const realTarget = realpathNearestExisting(target);
const relativePath = path.relative(realRoot, realTarget);
const contained = relativePath === ''
|| (
relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath)
);
return {
contained,
realRoot,
realTarget
};
}
function isWithinRoot(target, root) {
if (!root) {
return false;
}
const realRoot = safeRealpath(root);
const realTarget = realpathNearestExisting(target);
if (realTarget === realRoot) {
return true;
try {
return resolveContainment(target, root).contained;
} catch {
return false;
}
const rel = path.relative(realRoot, realTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
/**
@@ -69,10 +89,17 @@ function assertWithinTrustedRoot(target, root, action = 'write') {
if (!root) {
throw new Error(`Refusing to ${action} '${target}': no trusted install root resolved.`);
}
if (!isWithinRoot(target, root)) {
let containment;
try {
containment = resolveContainment(target, root);
} catch {
containment = null;
}
if (!containment || !containment.contained) {
throw new Error(`Refusing to ${action} outside the install root: '${target}' is not within '${root}'.`);
}
return realpathNearestExisting(target);
return containment.realTarget;
}
module.exports = {
+299
View File
@@ -0,0 +1,299 @@
'use strict';
/**
* Minimal GitHub-flavored-markdown subset renderer for Plan Canvas.
* Renders .claude/plans/*.plan.md artifacts to HTML body content.
*
* Security model: the entire source line is HTML-escaped before any inline
* rule runs, so raw HTML in the markdown always displays as text. Link and
* image URLs are validated against an allowlist of protocols.
*/
// Placeholders live in the Unicode private-use area so escaped output can
// never collide with them. Pre-existing occurrences are stripped from input.
const TOKEN_OPEN = '\uE000';
const TOKEN_CLOSE = '\uE001';
const TOKEN_RE = new RegExp(TOKEN_OPEN + '(\\d+)' + TOKEN_CLOSE, 'g');
const STRIP_RE = new RegExp('[' + TOKEN_OPEN + TOKEN_CLOSE + ']', 'g');
const LIST_ITEM_RE = /^(\s*)([-*]|\d+\.)\s+(.*)$/;
const HR_RE = /^ {0,3}(-{3,}|\*{3,})\s*$/;
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function slugify(text) {
return String(text ?? '')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/[\s-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Strip whitespace/control characters so "Ja vaScript:" style tricks cannot
// hide a scheme, then classify against the allowlist.
function classifyUrl(rawUrl) {
const compact = String(rawUrl)
.split('')
.filter((ch) => ch.charCodeAt(0) > 32)
.join('')
.toLowerCase();
if (compact.startsWith('#')) return 'anchor';
if (compact.startsWith('//')) return 'blocked';
const scheme = compact.match(/^[a-z][a-z0-9+.-]*:/);
if (!scheme) return 'relative';
if (scheme[0] === 'http:' || scheme[0] === 'https:') return 'http';
if (scheme[0] === 'mailto:') return 'mailto';
return 'blocked';
}
function applyEmphasis(s) {
return s
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1<em>$2</em>');
}
function renderInline(rawText) {
const tokens = [];
const stash = (html) => {
tokens.push(html);
return TOKEN_OPEN + (tokens.length - 1) + TOKEN_CLOSE;
};
let s = escapeHtml(rawText);
// Code spans first: contents stay escaped and opt out of all other rules.
s = s.replace(/`([^`]+)`/g, (_m, code) => stash('<code>' + code + '</code>'));
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, src) => {
const kind = classifyUrl(src);
if (kind !== 'http' && kind !== 'relative') return alt;
return stash('<img src="' + src.trim() + '" alt="' + alt + '">');
});
s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, label, url) => {
const kind = classifyUrl(url);
const text = applyEmphasis(label);
if (kind === 'blocked') return text;
const extra = kind === 'http' ? ' target="_blank" rel="noopener"' : '';
return stash('<a href="' + url.trim() + '"' + extra + '>' + text + '</a>');
});
s = applyEmphasis(s);
// Stashed anchors may hold code-span tokens, so resolve until none remain.
while (s.includes(TOKEN_OPEN)) {
s = s.replace(TOKEN_RE, (_m, idx) => tokens[Number(idx)]);
}
return s;
}
function splitTableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
return s
.replace(/\\\|/g, TOKEN_OPEN)
.split('|')
.map((cell) => cell.split(TOKEN_OPEN).join('|').trim());
}
function isAlignmentRow(line) {
if (!line || !line.includes('|')) return false;
const cells = splitTableRow(line);
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
}
function cellAlign(spec) {
const left = spec.startsWith(':');
const right = spec.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return '';
}
function renderListItem(text) {
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
if (task) {
const checked = task[1].trim() ? ' checked' : '';
return '<li class="task"><input type="checkbox" disabled' + checked + '> ' +
renderInline(task[2]) + '</li>';
}
return '<li>' + renderInline(text) + '</li>';
}
function listTag(marker) {
return /^\d/.test(marker) ? 'ol' : 'ul';
}
function buildList(items, start, indent) {
const tag = listTag(items[start].marker);
const parts = [];
let i = start;
while (i < items.length && items[i].indent >= indent) {
if (items[i].indent > indent) {
// Deeper item: nest a sublist inside the previous <li>
const nested = buildList(items, i, items[i].indent);
if (parts.length > 0) {
const last = parts.pop();
parts.push(last.replace(/<\/li>$/, '\n' + nested.html + '\n</li>'));
} else {
parts.push('<li>\n' + nested.html + '\n</li>');
}
i = nested.end;
} else {
// A marker-type change at the same indent starts a new list
// (CommonMark); stop here so the caller renders the next run with
// its own tag instead of absorbing it into this one.
if (listTag(items[i].marker) !== tag) break;
parts.push(renderListItem(items[i].text));
i += 1;
}
}
return { html: '<' + tag + '>\n' + parts.join('\n') + '\n</' + tag + '>', end: i };
}
function buildListBlock(items) {
let lists = [];
let i = 0;
while (i < items.length) {
const list = buildList(items, i, items[i].indent);
lists = [...lists, list.html];
i = list.end;
}
return lists.join('\n');
}
function startsBlock(line, nextLine) {
return /^```/.test(line) ||
/^#{1,6}\s/.test(line) ||
HR_RE.test(line) ||
/^ {0,3}>/.test(line) ||
LIST_ITEM_RE.test(line) ||
(line.includes('|') && isAlignmentRow(nextLine || ''));
}
function renderMarkdown(text) {
if (!text) return '';
const lines = String(text)
.replace(STRIP_RE, '')
.replace(/\r\n?/g, '\n')
.split('\n');
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i += 1;
continue;
}
const fence = line.match(/^```(.*)$/);
if (fence) {
const lang = fence[1].trim().split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9-]/g, '');
const body = [];
i += 1;
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
body.push(lines[i]);
i += 1;
}
i += 1; // skip closing fence (or run off EOF)
if (lang === 'mermaid') {
// Mermaid reads the element's textContent, and the browser decodes
// character references there — so escaping keeps `-->`/`<` intact for
// the renderer while preventing HTML injection or a </pre> breakout.
out.push('<pre class="mermaid">' + escapeHtml(body.join('\n')) + '</pre>');
continue;
}
const cls = lang ? ' class="language-' + lang + '"' : '';
out.push('<pre><code' + cls + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
if (heading) {
const level = heading[1].length;
out.push('<h' + level + ' id="' + slugify(heading[2]) + '">' +
renderInline(heading[2]) + '</h' + level + '>');
i += 1;
continue;
}
// Horizontal rule (alignment rows never reach here: tables consume them)
if (HR_RE.test(line)) {
out.push('<hr>');
i += 1;
continue;
}
// Blockquote: strip one `>` level and recurse, which handles nesting
if (/^ {0,3}>/.test(line)) {
const inner = [];
while (i < lines.length && /^ {0,3}>/.test(lines[i])) {
inner.push(lines[i].replace(/^ {0,3}> ?/, ''));
i += 1;
}
out.push('<blockquote>\n' + renderMarkdown(inner.join('\n')) + '\n</blockquote>');
continue;
}
// Table: header row followed by an alignment row
if (line.includes('|') && isAlignmentRow(lines[i + 1] || '')) {
const aligns = splitTableRow(lines[i + 1]).map(cellAlign);
const row = (tag, cells) => '<tr>' + cells.map((cell, idx) => {
const style = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : '';
return '<' + tag + style + '>' + renderInline(cell) + '</' + tag + '>';
}).join('') + '</tr>';
const head = row('th', splitTableRow(line));
const body = [];
i += 2;
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
body.push(row('td', splitTableRow(lines[i])));
i += 1;
}
out.push('<table>\n<thead>\n' + head + '\n</thead>\n<tbody>\n' +
body.join('\n') + '\n</tbody>\n</table>');
continue;
}
if (LIST_ITEM_RE.test(line)) {
const items = [];
while (i < lines.length) {
const m = lines[i].match(LIST_ITEM_RE);
if (!m) break;
items.push({ indent: m[1].length, marker: m[2], text: m[3] });
i += 1;
}
// An outdent below the first item's indentation ends that list. Render
// the remaining run as a sibling list so malformed indentation cannot
// silently drop content or create an empty parent item.
out.push(buildListBlock(items));
continue;
}
// Paragraph: run of plain lines up to a blank line or block start
const para = [line.trim()];
i += 1;
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
para.push(lines[i].trim());
i += 1;
}
out.push('<p>' + renderInline(para.join('\n')) + '</p>');
}
return out.join('\n');
}
module.exports = { renderMarkdown, escapeHtml, slugify };
+237
View File
@@ -0,0 +1,237 @@
'use strict';
/**
* Plan Canvas artifact SDK — the script injected into the reviewed artifact.
*
* The artifact runs in a sandboxed iframe without allow-same-origin, so this
* script can only talk to the chrome via postMessage. It renders all of its
* own UI inside a shadow root so it never annotates itself and never leaks
* styles into the artifact.
*/
function artifactSdkJs() {
return `'use strict';
(() => {
if (window.parent === window) return; // only meaningful inside the canvas
if (window.__eccPlanCanvasSdk) return;
window.__eccPlanCanvasSdk = true;
let annotate = true;
let card = null;
const post = msg => window.parent.postMessage(msg, '*');
// --- shadow-root UI host --------------------------------------------
const host = document.createElement('div');
host.setAttribute('data-ecc-plan-canvas', 'ui');
host.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647';
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = \`
<style>
:host{all:initial}
.hl{position:fixed;pointer-events:none;border:1.5px solid #6885e8;background:rgba(104,133,232,0.12);border-radius:4px;display:none;z-index:2147483646;transition:all .06s ease-out}
.selhint{position:absolute;display:none;z-index:2147483647;background:#101218;color:#dfe2e9;border:1px solid #272c3e;border-radius:6px;padding:4px 10px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;cursor:pointer;box-shadow:0 8px 32px rgba(0,0,0,0.6)}
.selhint:hover{border-color:#6885e8}
.card{position:absolute;display:none;z-index:2147483647;width:300px;background:#101218;border:1px solid #272c3e;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.6);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#dfe2e9}
.card h4{margin:0;padding:10px 12px 0;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#80859a}
.card .snippet{padding:4px 12px 0;font:10.5px 'SF Mono','Fira Code',monospace;color:#4acbbe;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card textarea{display:block;width:calc(100% - 24px);margin:8px 12px;min-height:56px;resize:vertical;background:#13161e;border:1px solid #1d2130;border-radius:6px;color:#dfe2e9;font:12.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:7px 9px;outline:none;box-sizing:border-box}
.card textarea:focus{border-color:#6885e8}
.card .row{display:flex;justify-content:flex-end;gap:8px;padding:0 12px 12px}
.card button{border-radius:6px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:5px 12px;cursor:pointer}
.card .cancel{background:none;border:1px solid #1d2130;color:#80859a}
.card .cancel:hover{color:#dfe2e9;border-color:#272c3e}
.card .queue{background:#6885e8;border:1px solid #6885e8;color:#fff}
.card .queue:hover{background:#3d5ab8}
.card .keys{padding:0 12px 10px;font-size:9.5px;color:#4c5168}
</style>
<div class="hl"></div>
<button class="selhint" type="button">Annotate selection</button>
<div class="card">
<h4></h4>
<div class="snippet"></div>
<textarea placeholder="What should change here?"></textarea>
<div class="row">
<button class="cancel" type="button">Cancel</button>
<button class="queue" type="button">Queue</button>
</div>
<div class="keys">Enter to queue &middot; Cmd/Ctrl+Enter to queue &amp; send</div>
</div>\`;
const attach = () => document.body ? document.body.appendChild(host) : null;
if (document.body) attach();
else document.addEventListener('DOMContentLoaded', attach);
const hl = root.querySelector('.hl');
const selhint = root.querySelector('.selhint');
const cardEl = root.querySelector('.card');
const cardTitle = cardEl.querySelector('h4');
const cardSnippet = cardEl.querySelector('.snippet');
const cardText = cardEl.querySelector('textarea');
// --- selectors & context ---------------------------------------------
const esc = v => (window.CSS && CSS.escape) ? CSS.escape(v) : v.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');
function selectorFor(el) {
const parts = [];
let node = el;
for (let depth = 0; node && node.nodeType === 1 && depth < 6; depth++) {
if (node.id) { parts.unshift('#' + esc(node.id)); return parts.join(' > '); }
const tag = node.tagName.toLowerCase();
if (tag === 'body' || tag === 'html') { parts.unshift(tag); break; }
let nth = 1;
let sib = node;
while ((sib = sib.previousElementSibling)) if (sib.tagName === node.tagName) nth++;
parts.unshift(tag + ':nth-of-type(' + nth + ')');
node = node.parentElement;
}
return parts.join(' > ');
}
function snippetFor(el) {
return (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200);
}
const INTERACTIVE = new Set(['button', 'input', 'select', 'textarea', 'option', 'label', 'summary', 'a']);
function isInteractive(el) {
let node = el;
while (node && node.nodeType === 1) {
if (INTERACTIVE.has(node.tagName.toLowerCase()) || node.isContentEditable) return true;
node = node.parentElement;
}
return false;
}
const isOurs = el => el === host || host.contains(el);
// --- annotation card ---------------------------------------------------
function openCard(target) {
card = target;
cardTitle.textContent = target.kindLabel;
cardSnippet.textContent = target.anchor.snippet || target.anchor.selector;
cardText.value = '';
cardEl.style.display = 'block';
const x = Math.min(target.x, window.innerWidth - 320) + window.scrollX;
const y = target.y + 12 + window.scrollY;
cardEl.style.left = Math.max(8, x) + 'px';
cardEl.style.top = y + 'px';
cardText.focus();
}
function closeCard() {
card = null;
cardEl.style.display = 'none';
}
function queueCard(sendNow) {
if (!card) return;
const text = cardText.value.trim();
if (!text) { cardText.focus(); return; }
post({
type: sendNow ? 'pc:queue-and-send' : 'pc:queue',
item: { kind: 'annotation', text, anchor: card.anchor }
});
closeCard();
}
cardEl.querySelector('.cancel').addEventListener('click', closeCard);
cardEl.querySelector('.queue').addEventListener('click', () => queueCard(false));
cardText.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); queueCard(true); }
else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); queueCard(false); }
else if (e.key === 'Escape') closeCard();
});
// --- element hover / click ---------------------------------------------
document.addEventListener('mousemove', e => {
if (!annotate || card) { hl.style.display = 'none'; return; }
const el = e.target;
if (!el || isOurs(el) || el === document.body || el === document.documentElement || isInteractive(el)) {
hl.style.display = 'none';
return;
}
const rect = el.getBoundingClientRect();
hl.style.display = 'block';
hl.style.left = rect.left - 2 + 'px';
hl.style.top = rect.top - 2 + 'px';
hl.style.width = rect.width + 'px';
hl.style.height = rect.height + 'px';
}, true);
document.addEventListener('click', e => {
if (!annotate) return;
const el = e.target;
if (isOurs(el)) return;
if (card) { if (!cardEl.contains(e.composedPath()[0])) closeCard(); return; }
if (isInteractive(el)) return; // let controls behave natively
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return; // handled by selection flow
if (el === document.body || el === document.documentElement) return;
e.preventDefault();
e.stopPropagation();
hl.style.display = 'none';
openCard({
kindLabel: 'Annotate <' + el.tagName.toLowerCase() + '>',
anchor: { selector: selectorFor(el), tag: el.tagName.toLowerCase(), snippet: snippetFor(el) },
x: e.clientX,
y: e.clientY
});
}, true);
// --- text selection -------------------------------------------------------
document.addEventListener('mouseup', e => {
if (!annotate || card || isOurs(e.target)) return;
setTimeout(() => {
const selection = window.getSelection();
const text = selection ? String(selection).replace(/\\s+/g, ' ').trim() : '';
if (!text || !selection.rangeCount) { selhint.style.display = 'none'; return; }
const rect = selection.getRangeAt(0).getBoundingClientRect();
selhint.style.display = 'block';
selhint.style.left = rect.left + window.scrollX + 'px';
selhint.style.top = rect.bottom + 6 + window.scrollY + 'px';
selhint.onclick = () => {
selhint.style.display = 'none';
const anchorNode = selection.anchorNode;
const el = anchorNode && anchorNode.nodeType === 1 ? anchorNode : anchorNode && anchorNode.parentElement;
openCard({
kindLabel: 'Annotate selection',
anchor: {
selector: el ? selectorFor(el) : 'body',
tag: 'text',
snippet: text.slice(0, 200),
textRange: { text: text.slice(0, 1000) }
},
x: rect.left,
y: rect.bottom
});
};
}, 0);
}, true);
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) selhint.style.display = 'none';
});
// --- chrome bridge ---------------------------------------------------------
window.addEventListener('message', e => {
const msg = e.data || {};
if (msg.type === 'pc:set-mode') {
annotate = Boolean(msg.annotate);
if (!annotate) { hl.style.display = 'none'; selhint.style.display = 'none'; closeCard(); }
} else if (msg.type === 'pc:restore-scroll') {
window.scrollTo(msg.x || 0, msg.y || 0);
}
});
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
e.preventDefault();
post({ type: 'pc:toggle-mode' });
} else if (e.key === 'Escape' && card) closeCard();
}, true);
let scrollTimer = null;
window.addEventListener('scroll', () => {
if (scrollTimer) return;
scrollTimer = setTimeout(() => {
scrollTimer = null;
post({ type: 'pc:scroll', x: window.scrollX, y: window.scrollY });
}, 150);
}, { passive: true });
post({ type: 'pc:ready' });
})();`;
}
module.exports = { artifactSdkJs };
+634
View File
@@ -0,0 +1,634 @@
'use strict';
/**
* Plan Canvas loopback server.
*
* One detached process serves every open review session: the browser chrome,
* the rendered artifact, an SSE stream for live updates, and the long-poll
* endpoint agents block on. Sessions are keyed by canonical artifact path
* (see sessions.js).
*/
const { EventEmitter } = require('events');
const fs = require('fs');
const http = require('http');
const path = require('path');
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
const { renderMarkdown } = require('./markdown');
const { artifactSdkJs } = require('./sdk');
const {
canvasCss,
canvasClientJs,
renderCanvasHtml,
renderMarkdownArtifactHtml,
renderSessionListHtml
} = require('./ui');
const DEFAULT_PORT = 4517;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const MAX_BODY_BYTES = 1024 * 1024;
// How long the "agent is thinking" indicator survives without the agent
// checking back in, before presence decays to the honest queued/waiting.
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
// An explicit typing signal expires faster: it means "a reply is seconds away".
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
// Presence is push-based, so expiring states need a tick to re-broadcast on.
const DEFAULT_PRESENCE_SWEEP_MS = 5 * 1000;
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
const CONTENT_TYPES = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
};
function resolvePort(env = process.env) {
const value = Number.parseInt(env.ECC_PLAN_CANVAS_PORT || '', 10);
return Number.isInteger(value) && value >= 0 && value <= 65535 ? value : DEFAULT_PORT;
}
function resolveIdleTimeoutMs(env = process.env) {
const raw = String(env.ECC_PLAN_CANVAS_IDLE_MS || '').trim().toLowerCase();
if (raw === '0' || raw === 'off') return 0;
const value = Number.parseInt(raw, 10);
return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS;
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', chunk => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new Error('body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (chunks.length === 0) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('invalid JSON body'));
}
});
req.on('error', reject);
});
}
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(body);
}
function sendHtml(res, statusCode, html, { csp = true } = {}) {
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
if (csp) {
headers['content-security-policy'] =
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
}
res.writeHead(statusCode, headers);
res.end(html);
}
function createPlanCanvasServer({
store,
host = DEFAULT_HOST,
version = '0.0.0',
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
heartbeatMs = 15000,
thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
presenceSweepMs = DEFAULT_PRESENCE_SWEEP_MS,
onIdleShutdown = null,
log = () => {}
} = {}) {
if (!store) throw new Error('createPlanCanvasServer requires a session store');
const allowedHostnames = buildAllowedHostnames(host);
const wake = new EventEmitter();
wake.setMaxListeners(0);
const sseClients = new Map(); // key -> Set<res>
const awaitCounts = new Map(); // key -> active long-poll count
const workingKeys = new Map(); // key -> ms timestamp the agent took feedback
const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing
const watchers = new Map(); // key -> fs.FSWatcher
const lastPresence = new Map(); // key -> last broadcast state, for sweep diffing
let idleTimer = null;
let presenceSweep = null;
let closed = false;
// --- presence + SSE ---------------------------------------------------
/**
* Presence never claims more than the server actually knows:
*
* ended session is closed
* typing agent signalled it is composing a reply (self-expiring)
* thinking agent took the feedback and is working on it (self-expiring)
* listening an `await` long poll is parked on this session right now
* queued feedback is sitting undelivered with nobody listening
* waiting nothing queued, nobody listening
*
* `thinking` and `typing` expire on their own so a crashed or distracted
* agent decays to an honest `queued`/`waiting` instead of spinning forever.
* The old `working` pill had no expiry and no re-broadcast, so it stuck at
* "agent working" while nothing at all was listening.
*/
function presenceFor(key, now = Date.now()) {
const session = store.get(key);
if (!session || session.status === 'ended') return 'ended';
const typingAt = typingKeys.get(key);
if (typingAt !== undefined && now - typingAt < typingExpiryMs) return 'typing';
const workingAt = workingKeys.get(key);
if (workingAt !== undefined && now - workingAt < thinkingStaleMs) return 'thinking';
if ((awaitCounts.get(key) || 0) > 0) return 'listening';
return session.pendingFeedback && session.pendingFeedback.length > 0 ? 'queued' : 'waiting';
}
function broadcast(key, event, payload) {
const clients = sseClients.get(key);
if (!clients) return;
const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of clients) client.write(frameText);
}
function broadcastPresence(key) {
const state = presenceFor(key);
lastPresence.set(key, state);
broadcast(key, 'presence', { state });
}
// Re-broadcast only where an expiry actually changed the answer, so an
// untouched canvas sees the thinking bubble clear itself.
function sweepPresence() {
for (const key of sseClients.keys()) {
const state = presenceFor(key);
if (lastPresence.get(key) !== state) broadcastPresence(key);
}
}
function startPresenceSweep() {
if (presenceSweep || !presenceSweepMs) return;
presenceSweep = setInterval(sweepPresence, presenceSweepMs);
if (presenceSweep.unref) presenceSweep.unref();
}
// The agent is off working on this feedback batch; start the thinking clock.
function markThinking(key) {
workingKeys.set(key, Date.now());
typingKeys.delete(key);
}
// A reply landed (or the agent picked the session back up): stop pretending.
function clearAgentActivity(key) {
workingKeys.delete(key);
typingKeys.delete(key);
}
function connectionCount() {
let total = 0;
for (const clients of sseClients.values()) total += clients.size;
for (const count of awaitCounts.values()) total += count;
return total;
}
function armIdleTimer() {
if (!idleTimeoutMs || closed) return;
if (connectionCount() > 0) return;
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (connectionCount() === 0 && !closed) {
log('[plan-canvas] idle timeout reached, shutting down');
if (onIdleShutdown) onIdleShutdown();
}
}, idleTimeoutMs);
if (idleTimer.unref) idleTimer.unref();
}
function noteConnectionOpened() {
clearTimeout(idleTimer);
}
function noteConnectionClosed() {
armIdleTimer();
}
// --- artifact watching --------------------------------------------------
function watchSession(session) {
if (watchers.has(session.key)) return;
const dir = path.dirname(session.file);
const base = path.basename(session.file);
let debounce = null;
try {
const watcher = fs.watch(dir, (eventType, filename) => {
if (filename && filename !== base) return;
clearTimeout(debounce);
debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150);
});
watcher.on('error', () => watchers.delete(session.key));
watchers.set(session.key, watcher);
} catch {
// Watching is best-effort; manual reload still works.
}
}
function unwatchSession(key) {
const watcher = watchers.get(key);
if (watcher) {
watcher.close();
watchers.delete(key);
}
}
// --- session actions ------------------------------------------------------
function endSession(key, endedBy) {
const session = store.end(key, endedBy);
if (!session) return null;
clearAgentActivity(key);
wake.emit(`wake:${key}`);
broadcast(key, 'ended', { endedBy: session.endedBy });
broadcastPresence(key);
unwatchSession(key);
return session;
}
// --- request handlers -------------------------------------------------------
async function handleApi(req, res, url) {
const { pathname } = url;
if (req.method === 'POST' && pathname === '/api/sessions') {
const body = await readJsonBody(req);
if (!body.file || typeof body.file !== 'string') {
return sendJson(res, 400, { error: 'file is required' });
}
if (!fs.existsSync(path.resolve(body.file))) {
return sendJson(res, 404, { error: `artifact not found: ${body.file}` });
}
const { session, refused } = store.open(body.file, { reopen: Boolean(body.reopen) });
if (refused) {
return sendJson(res, 409, {
status: 'user-ended',
key: session.key,
next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.'
});
}
watchSession(session);
broadcastPresence(session.key);
return sendJson(res, 200, {
status: 'open',
key: session.key,
file: session.file,
url: `/canvas/${session.key}`
});
}
if (req.method === 'GET' && pathname === '/api/sessions') {
return sendJson(res, 200, { sessions: store.list() });
}
if (req.method === 'GET' && pathname === '/api/await') {
const keyParam = url.searchParams.get('key');
const file = url.searchParams.get('file');
if (keyParam && !/^[a-f0-9]{12}$/.test(keyParam)) return sendJson(res, 400, { error: 'invalid session key' });
if (!keyParam && !file) return sendJson(res, 400, { error: 'key or file query parameter is required' });
const session = keyParam ? store.get(keyParam) : store.findByFile(file);
if (!session) return sendJson(res, 200, { status: 'missing' });
const key = session.key;
const timeoutRaw = url.searchParams.get('timeoutMs');
const timeoutMs = timeoutRaw === null ? null : Math.max(0, Number.parseInt(timeoutRaw, 10) || 0);
const first = store.takeFeedback(key);
if (first.status !== 'waiting') {
if (first.status === 'feedback') markThinking(key);
broadcastPresence(key);
return sendJson(res, 200, first);
}
// Long poll: hold the request open until feedback or session end.
noteConnectionOpened();
awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
clearAgentActivity(key);
broadcastPresence(key);
let settled = false;
let heartbeat = null;
let waitTimer = null;
const finish = payload => {
if (settled) return;
settled = true;
cleanup();
if (payload) {
if (payload.status === 'feedback') markThinking(key);
res.end(JSON.stringify(payload));
}
broadcastPresence(key);
noteConnectionClosed();
};
const onWake = () => {
const result = store.takeFeedback(key);
if (result.status !== 'waiting') finish(result);
};
// Settle held polls on shutdown so server.close() can complete; the
// CLI tells agents to simply re-run await.
const onServerClose = () =>
finish({ status: 'waiting', note: 'canvas server is shutting down; re-run await' });
const cleanup = () => {
wake.removeListener(`wake:${key}`, onWake);
wake.removeListener('server-close', onServerClose);
clearInterval(heartbeat);
clearTimeout(waitTimer);
awaitCounts.set(key, Math.max(0, (awaitCounts.get(key) || 1) - 1));
};
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
// Leading whitespace keeps the connection visibly alive without
// corrupting the JSON payload written at the end.
res.write(' ');
heartbeat = setInterval(() => {
if (!settled) res.write(' ');
}, heartbeatMs);
if (timeoutMs !== null) {
waitTimer = setTimeout(() => finish({ status: 'waiting' }), timeoutMs);
}
wake.on(`wake:${key}`, onWake);
wake.once('server-close', onServerClose);
req.on('close', () => finish(null));
return undefined;
}
if (req.method === 'POST' && pathname === '/api/end') {
const body = await readJsonBody(req);
if (!body.file || typeof body.file !== 'string') {
return sendJson(res, 400, { error: 'file is required' });
}
const session = store.findByFile(body.file);
if (!session) return sendJson(res, 404, { error: 'no session for that file' });
endSession(session.key, 'agent');
return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
}
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
if (sessionMatch && req.method === 'POST') {
const [, key, action] = sessionMatch;
const session = store.get(key);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
if (action === 'feedback') {
const body = await readJsonBody(req);
const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) });
if (!result) return sendJson(res, 409, { error: 'session already ended' });
wake.emit(`wake:${key}`);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' });
// A parked `await` takes the batch synchronously on the wake above, so
// presence is already `thinking` by now; with nobody listening it
// reports `queued`. Either way the browser must be told, which the
// original handler never did, leaving a stale pill on screen.
broadcastPresence(key);
return sendJson(res, 200, {
status: 'queued',
accepted: result.accepted.length,
pending: result.pending,
presence: presenceFor(key)
});
}
if (action === 'end') {
endSession(key, 'user');
return sendJson(res, 200, { status: 'ended', endedBy: 'user' });
}
if (action === 'reply') {
const body = await readJsonBody(req);
if (!body.text || typeof body.text !== 'string') {
return sendJson(res, 400, { error: 'text is required' });
}
const entry = store.addAgentReply(key, body.text);
clearAgentActivity(key);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
broadcastPresence(key);
return sendJson(res, 200, { status: 'sent', at: entry.at });
}
// Agents drive the chat indicator explicitly: `thinking` while they work,
// `typing` right before a reply lands, `idle` to take the bubble down.
if (action === 'typing') {
const body = await readJsonBody(req);
const state = typeof body.state === 'string' ? body.state : 'typing';
if (!TYPING_STATES.has(state)) {
return sendJson(res, 400, { error: `state must be one of: ${[...TYPING_STATES].join(', ')}` });
}
if (state === 'idle') clearAgentActivity(key);
else if (state === 'typing') typingKeys.set(key, Date.now());
else markThinking(key);
broadcastPresence(key);
return sendJson(res, 200, { status: 'ok', presence: presenceFor(key) });
}
}
return sendJson(res, 404, { error: 'not found' });
}
function handleEvents(req, res, key) {
const session = store.get(key);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
noteConnectionOpened();
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
connection: 'keep-alive'
});
res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`);
res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`);
if (!sseClients.has(key)) sseClients.set(key, new Set());
sseClients.get(key).add(res);
lastPresence.set(key, presenceFor(key));
startPresenceSweep();
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
if (ping.unref) ping.unref();
req.on('close', () => {
clearInterval(ping);
const clients = sseClients.get(key);
if (clients) {
clients.delete(res);
if (clients.size === 0) {
sseClients.delete(key);
lastPresence.delete(key);
}
}
noteConnectionClosed();
});
}
function serveArtifact(res, key, assetPath) {
const session = store.get(key);
if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
if (!assetPath) {
let content;
try {
content = fs.readFileSync(session.file, 'utf8');
} catch {
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { csp: false });
}
const ext = path.extname(session.file).toLowerCase();
if (ext === '.md' || ext === '.markdown') {
const html = renderMarkdownArtifactHtml(renderMarkdown(content), {
title: path.basename(session.file),
sdkSrc: '/sdk.js'
});
return sendHtml(res, 200, html, { csp: false });
}
const sdkTag = '<script src="/sdk.js"></script>';
const injected = content.includes('</body>')
? content.replace('</body>', `${sdkTag}\n</body>`)
: `${content}\n${sdkTag}`;
return sendHtml(res, 200, injected, { csp: false });
}
// Sibling assets resolve relative to the artifact's directory and must
// stay confined to it.
const baseDir = path.dirname(session.file);
const resolved = path.resolve(baseDir, assetPath);
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
return sendJson(res, 403, { error: 'asset path escapes artifact directory' });
}
let data;
try {
data = fs.readFileSync(resolved);
} catch {
return sendJson(res, 404, { error: 'asset not found' });
}
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
return res.end(data);
}
const server = http.createServer((req, res) => {
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
return sendJson(res, 403, { error: 'forbidden host header' });
}
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
return sendJson(res, 403, { error: 'forbidden origin' });
}
const url = new URL(req.url, `http://${req.headers.host}`);
const { pathname } = url;
Promise.resolve()
.then(() => {
if (req.method === 'GET' && pathname === '/health') {
return sendJson(res, 200, { ok: true, app: 'ecc-plan-canvas', version });
}
if (req.method === 'POST' && pathname === '/shutdown') {
sendJson(res, 200, { status: 'stopping' });
setImmediate(() => {
if (onIdleShutdown) onIdleShutdown();
});
return undefined;
}
if (req.method === 'GET' && pathname === '/') {
return sendHtml(res, 200, renderSessionListHtml(store.list()));
}
if (req.method === 'GET' && pathname === '/canvas.css') {
res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-store' });
return res.end(canvasCss());
}
if (req.method === 'GET' && pathname === '/client.js') {
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
return res.end(canvasClientJs());
}
if (req.method === 'GET' && pathname === '/sdk.js') {
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
return res.end(artifactSdkJs());
}
const canvasMatch = pathname.match(/^\/canvas\/([a-f0-9]{12})$/);
if (req.method === 'GET' && canvasMatch) {
const session = store.get(canvasMatch[1]);
if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
return sendHtml(res, 200, renderCanvasHtml(session));
}
const eventsMatch = pathname.match(/^\/events\/([a-f0-9]{12})$/);
if (req.method === 'GET' && eventsMatch) {
return handleEvents(req, res, eventsMatch[1]);
}
const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/);
if (req.method === 'GET' && artifactMatch) {
const assetPath = decodeURIComponent(artifactMatch[2]);
return serveArtifact(res, artifactMatch[1], assetPath || null);
}
if (pathname.startsWith('/api/')) {
return handleApi(req, res, url);
}
return sendJson(res, 404, { error: 'not found' });
})
.catch(error => {
if (!res.headersSent) sendJson(res, 400, { error: error.message });
else res.end();
});
});
function close() {
closed = true;
clearTimeout(idleTimer);
clearInterval(presenceSweep);
presenceSweep = null;
lastPresence.clear();
for (const key of watchers.keys()) unwatchSession(key);
for (const clients of sseClients.values()) {
for (const client of clients) client.end();
}
sseClients.clear();
wake.emit('server-close');
return new Promise((resolve, reject) => {
server.close(error => (error ? reject(error) : resolve()));
// Browser keep-alive sockets would otherwise hold close() open.
if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
});
}
function listen(port = resolvePort()) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => {
armIdleTimer();
resolve({ port: server.address().port, host });
});
});
}
return { server, listen, close, presenceFor, sweepPresence, watchSession };
}
module.exports = {
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_THINKING_STALE_MS,
DEFAULT_TYPING_EXPIRY_MS,
createPlanCanvasServer,
resolveIdleTimeoutMs,
resolvePort
};
+269
View File
@@ -0,0 +1,269 @@
'use strict';
/**
* Plan Canvas session store.
*
* Sessions are keyed by the canonical artifact file path so agents never
* juggle opaque ids. State is persisted as JSON in the Plan Canvas state
* dir so queued human feedback survives a server restart.
*/
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const FEEDBACK_KINDS = new Set(['chat', 'annotation', 'verdict']);
const VERDICTS = new Set(['approve', 'request-changes']);
function resolveStateDir(env = process.env) {
const override = env.ECC_PLAN_CANVAS_STATE_DIR;
if (override && String(override).trim()) return path.resolve(String(override).trim());
return path.join(os.homedir(), '.claude', 'plan-canvas');
}
// Canonicalize so `./plan.md`, symlinks, and absolute paths all land on the
// same session.
function canonicalizeArtifactPath(filePath) {
const absolute = path.resolve(filePath);
try {
return fs.realpathSync(absolute);
} catch {
return absolute;
}
}
function sessionKeyFor(canonicalPath) {
return crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12);
}
function nowIso() {
return new Date().toISOString();
}
function sanitizeText(value, maxLength = 4000) {
if (typeof value !== 'string') return '';
return value.slice(0, maxLength);
}
// Normalize one browser-submitted feedback item into the shape delivered to
// the agent. Returns null for unusable input rather than throwing so a
// malformed item can never wedge the queue.
function normalizeFeedbackItem(raw, counter) {
if (!raw || typeof raw !== 'object') return null;
const kind = FEEDBACK_KINDS.has(raw.kind) ? raw.kind : null;
if (!kind) return null;
const item = {
id: `fb-${counter}`,
kind,
text: sanitizeText(raw.text),
at: nowIso()
};
if (kind === 'verdict') {
if (!VERDICTS.has(raw.verdict)) return null;
item.verdict = raw.verdict;
}
if (kind === 'annotation') {
const anchor = raw.anchor && typeof raw.anchor === 'object' ? raw.anchor : null;
if (!anchor || typeof anchor.selector !== 'string') return null;
item.anchor = {
selector: sanitizeText(anchor.selector, 500),
tag: sanitizeText(anchor.tag, 60),
snippet: sanitizeText(anchor.snippet, 400)
};
if (anchor.textRange && typeof anchor.textRange === 'object') {
item.anchor.textRange = {
text: sanitizeText(anchor.textRange.text, 1000)
};
}
if (!item.text) return null;
}
if (kind === 'chat' && !item.text) return null;
return item;
}
function createSessionStore({ stateDir = resolveStateDir() } = {}) {
const stateFile = path.join(stateDir, 'sessions.json');
let state = { sessions: {}, feedbackCounter: 0 };
function load() {
try {
const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
if (parsed && typeof parsed === 'object' && parsed.sessions) {
state = {
sessions: parsed.sessions,
feedbackCounter: Number(parsed.feedbackCounter) || 0
};
}
} catch {
// Missing or corrupt state starts fresh; queued feedback loss on a
// corrupt file beats refusing to start at all.
}
}
function persist() {
fs.mkdirSync(stateDir, { recursive: true });
const tmpFile = `${stateFile}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2));
fs.renameSync(tmpFile, stateFile);
}
load();
function get(key) {
return state.sessions[key] || null;
}
function findByFile(filePath) {
const canonical = canonicalizeArtifactPath(filePath);
return get(sessionKeyFor(canonical));
}
// Open (or resume) a session. A session the *user* ended from the browser
// is sticky: it refuses a plain reopen so agents do not pop the browser
// back up uninvited. Pass reopen:true only when the human asked.
function open(filePath, { reopen = false } = {}) {
const canonical = canonicalizeArtifactPath(filePath);
const key = sessionKeyFor(canonical);
const existing = state.sessions[key];
if (existing && existing.status === 'ended' && existing.endedBy === 'user' && !reopen) {
return { session: existing, refused: true };
}
const session = existing || {
key,
file: canonical,
chat: [],
pendingFeedback: [],
createdAt: nowIso()
};
session.status = 'open';
delete session.endedBy;
session.updatedAt = nowIso();
state.sessions[key] = session;
persist();
return { session, refused: false };
}
// Queue feedback from the browser. Chat-shaped items are mirrored into the
// session transcript immediately so the conversation panel stays coherent
// across reloads.
function queueFeedback(key, rawItems, { endSession = false } = {}) {
const session = get(key);
if (!session || session.status === 'ended') return null;
const accepted = [];
for (const raw of Array.isArray(rawItems) ? rawItems : []) {
state.feedbackCounter += 1;
const item = normalizeFeedbackItem(raw, state.feedbackCounter);
if (item) accepted.push(item);
}
session.pendingFeedback.push(...accepted);
for (const item of accepted) {
session.chat.push({ role: 'user', kind: item.kind, text: chatLineFor(item), at: item.at });
}
if (endSession) {
session.status = 'ended';
session.endedBy = 'user';
} else if (accepted.length > 0) {
session.status = 'feedback';
}
session.updatedAt = nowIso();
persist();
return { accepted, pending: session.pendingFeedback.length, session };
}
// Deliver-and-drain: feedback is handed to exactly one await call, after
// which the session flips back to open. An ended session keeps reporting
// ended (with attribution) so agents know to stop polling.
function takeFeedback(key) {
const session = get(key);
if (!session) return { status: 'missing' };
if (session.pendingFeedback.length > 0) {
const items = session.pendingFeedback;
session.pendingFeedback = [];
const result = { status: 'feedback', items };
if (session.status === 'ended') {
result.sessionEnded = true;
result.endedBy = session.endedBy;
} else {
session.status = 'open';
}
session.updatedAt = nowIso();
persist();
return result;
}
if (session.status === 'ended') {
return { status: 'ended', endedBy: session.endedBy };
}
return { status: 'waiting' };
}
function addAgentReply(key, text) {
const session = get(key);
if (!session) return null;
const entry = { role: 'agent', kind: 'chat', text: sanitizeText(text), at: nowIso() };
session.chat.push(entry);
session.updatedAt = nowIso();
persist();
return entry;
}
function end(key, endedBy) {
const session = get(key);
if (!session) return null;
session.status = 'ended';
session.endedBy = endedBy === 'user' ? 'user' : 'agent';
session.updatedAt = nowIso();
persist();
return session;
}
function list() {
return Object.values(state.sessions).map(session => ({
key: session.key,
file: session.file,
status: session.status,
endedBy: session.endedBy,
pending: session.pendingFeedback.length,
updatedAt: session.updatedAt
}));
}
function hasOpenSessions() {
return Object.values(state.sessions).some(session => session.status !== 'ended');
}
return {
stateDir,
stateFile,
open,
get,
findByFile,
queueFeedback,
takeFeedback,
addAgentReply,
end,
list,
hasOpenSessions
};
}
// One-line rendering of a feedback item for the conversation transcript.
function chatLineFor(item) {
if (item.kind === 'verdict') {
const label = item.verdict === 'approve' ? 'Approved the plan' : 'Requested changes';
return item.text ? `${label}: ${item.text}` : label;
}
if (item.kind === 'annotation') {
const where = item.anchor.snippet || item.anchor.selector;
return `[${where}] ${item.text}`;
}
return item.text;
}
module.exports = {
canonicalizeArtifactPath,
createSessionStore,
normalizeFeedbackItem,
resolveStateDir,
sessionKeyFor
};
+628
View File
@@ -0,0 +1,628 @@
'use strict';
/**
* Plan Canvas browser chrome: the editor shell that frames an artifact,
* plus the rendered-markdown artifact template.
*
* Visual language mirrors the ECC web dashboard (scripts/dashboard-web.js):
* same design tokens, dark-first with a light theme, accent→pink brand
* gradient. Everything is served inline — no CDNs, no external assets.
*/
const path = require('path');
const { escapeHtml } = require('./markdown');
// Pinned Mermaid ESM build, loaded in the browser only when an artifact
// actually contains a diagram. Override with a local/vendored URL (e.g. an
// air-gapped mirror) via ECC_PLAN_CANVAS_MERMAID_URL. If the fetch fails, the
// diagram source stays visible as a styled code block — nothing breaks.
const DEFAULT_MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs';
function mermaidUrl(env = process.env) {
const override = env.ECC_PLAN_CANVAS_MERMAID_URL;
return override && String(override).trim() ? String(override).trim() : DEFAULT_MERMAID_URL;
}
// Browser module that renders `<pre class="mermaid">` blocks, themed to match
// the ECC canvas. Kept import-only so a CDN failure degrades gracefully.
function mermaidLoaderScript(url) {
return `<script type="module">
try {
const mermaid = (await import(${JSON.stringify(url)})).default;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'dark',
fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
themeVariables: {
primaryColor: '#13161e', primaryBorderColor: '#6885e8', primaryTextColor: '#dfe2e9',
lineColor: '#80859a', secondaryColor: '#191d2a', tertiaryColor: '#101218',
background: '#080a0e', mainBkg: '#13161e', clusterBkg: '#0d0f14'
}
});
await mermaid.run({ querySelector: '.mermaid' });
} catch (err) {
document.querySelectorAll('.mermaid').forEach(el => el.classList.add('mermaid-unrendered'));
console.warn('Mermaid render skipped:', err && err.message);
}
</script>`;
}
// Design tokens shared by the chrome and the markdown artifact template.
const TOKENS_CSS = `
:root{
--bg:#080a0e; --bg2:#0d0f14; --bg3:#13161e; --bg4:#191d2a;
--surface:#101218; --surface-hover:#171a24; --border:#1d2130; --border-light:#272c3e;
--text:#dfe2e9; --text2:#80859a; --text3:#4c5168;
--accent:#6885e8; --accent-glow:rgba(104,133,232,0.15); --accent-dim:#3d5ab8;
--green:#4acb8a; --green-glow:rgba(74,203,138,0.15);
--orange:#eca85a; --orange-glow:rgba(236,168,90,0.15);
--pink:#e26a9e; --pink-glow:rgba(226,106,158,0.15);
--red:#e86060; --red-glow:rgba(232,96,96,0.15);
--teal:#4acbbe; --teal-glow:rgba(74,203,190,0.15);
--radius:8px; --radius-sm:5px;
--font:-apple-system,BlinkMacSystemFont,'SF Pro Display','Inter','Segoe UI',Roboto,sans-serif;
--mono:'SF Mono','Fira Code','JetBrains Mono','Cascadia Code',monospace;
--shadow:0 1px 2px rgba(0,0,0,0.4);
--shadow-lg:0 8px 32px rgba(0,0,0,0.6);
}
[data-theme="light"]{
--bg:#f4f5f7; --bg2:#ffffff; --bg3:#eaecef; --bg4:#dfe2e6;
--surface:#ffffff; --surface-hover:#f4f5f7; --border:#cdd1d9; --border-light:#dde1e8;
--text:#181b23; --text2:#585e6e; --text3:#9197a8;
--accent:#4560d0; --accent-glow:rgba(69,96,208,0.08); --accent-dim:#2f44a0;
--green:#16a34a; --green-glow:rgba(22,163,74,0.08);
--orange:#d97706; --orange-glow:rgba(217,119,6,0.08);
--pink:#c73877; --pink-glow:rgba(199,56,119,0.08);
--red:#dc2626; --red-glow:rgba(220,38,38,0.08);
--teal:#0d9488; --teal-glow:rgba(13,148,136,0.08);
--shadow:0 1px 2px rgba(0,0,0,0.04);
--shadow-lg:0 8px 32px rgba(0,0,0,0.08);
}
`;
function canvasCss() {
return `${TOKENS_CSS}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.4;overflow:hidden}
::selection{background:var(--accent);color:#fff}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
button{font-family:var(--font)}
.bar{display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;background:color-mix(in srgb,var(--bg2) 88%,transparent);border-bottom:1px solid var(--border);backdrop-filter:blur(16px)}
.brand{display:flex;align-items:center;gap:9px;min-width:0}
.brand .logo{width:26px;height:26px;flex:none;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff}
.brand .name{font-size:13.5px;font-weight:600;white-space:nowrap}
.brand .file{font-size:11.5px;color:var(--text2);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34vw}
.bar .spacer{flex:1}
.presence{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:99px;padding:3px 10px 3px 8px;white-space:nowrap}
.presence .dot{width:7px;height:7px;border-radius:99px;background:var(--text3)}
.presence[data-state="listening"] .dot{background:var(--green);box-shadow:0 0 0 3px var(--green-glow);animation:pulse 2s infinite}
.presence[data-state="thinking"] .dot,.presence[data-state="typing"] .dot{background:var(--accent);box-shadow:0 0 0 3px var(--accent-glow);animation:pulse 1.2s infinite}
.presence[data-state="queued"] .dot{background:var(--orange);box-shadow:0 0 0 3px var(--orange-glow)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
.toggle{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--text2);cursor:pointer;user-select:none}
.toggle .track{width:30px;height:17px;border-radius:99px;background:var(--bg4);border:1px solid var(--border);position:relative;transition:background .15s}
.toggle .knob{position:absolute;top:1px;left:1px;width:13px;height:13px;border-radius:99px;background:var(--text2);transition:transform .15s,background .15s}
.toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
.toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
.icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
.icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
.layout{display:flex;height:calc(100% - 52px)}
.frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
.frame iframe{width:100%;height:100%;border:0;background:#fff}
[data-theme] .frame iframe{background:var(--bg2)}
.panel{width:340px;flex:none;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--bg2)}
.panel h2{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:12px 14px 8px}
.verdict{display:flex;gap:8px;padding:0 14px 12px;border-bottom:1px solid var(--border)}
.verdict button{flex:1;height:30px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s}
.verdict .approve{border:1px solid var(--green);background:var(--green-glow);color:var(--green)}
.verdict .approve:hover{background:var(--green);color:#fff}
.verdict .changes{border:1px solid var(--orange);background:var(--orange-glow);color:var(--orange)}
.verdict .changes:hover{background:var(--orange);color:#fff}
.chat{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px}
.msg{max-width:92%;padding:7px 10px;border-radius:10px;font-size:12.5px;white-space:pre-wrap;word-break:break-word}
.msg.user{align-self:flex-end;background:var(--accent-glow);border:1px solid color-mix(in srgb,var(--accent) 35%,transparent);color:var(--text);border-bottom-right-radius:3px}
.msg.agent{align-self:flex-start;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-bottom-left-radius:3px}
.msg .meta{display:block;font-size:9.5px;color:var(--text3);margin-top:3px}
.msg.kind-annotation{border-left:2px solid var(--teal)}
.msg.kind-verdict{border-left:2px solid var(--green)}
.chat .empty{color:var(--text3);font-size:12px;text-align:center;margin-top:24px;line-height:1.6}
/* iMessage-style activity bubble: dots while the agent thinks or types. */
.typing{align-self:flex-start;display:none;align-items:center;gap:8px;background:var(--bg3);border:1px solid var(--border);border-bottom-left-radius:3px;border-radius:10px;padding:9px 12px}
.typing.show{display:flex}
.typing .dots{display:flex;align-items:center;gap:3px}
.typing .dots i{width:6px;height:6px;border-radius:99px;background:var(--text2);animation:typing-bounce 1.4s infinite ease-in-out both}
.typing .dots i:nth-child(1){animation-delay:-.32s}
.typing .dots i:nth-child(2){animation-delay:-.16s}
.typing .label{font-size:11px;color:var(--text3)}
@keyframes typing-bounce{0%,80%,100%{transform:translateY(0);opacity:.4}40%{transform:translateY(-4px);opacity:1}}
@media (prefers-reduced-motion:reduce){
.typing .dots i{animation:none;opacity:.7}
.presence .dot{animation:none}
}
/* A queued message nobody is listening for gets an explicit, honest note. */
.stalled{align-self:flex-start;display:none;gap:8px;background:var(--orange-glow);border:1px solid color-mix(in srgb,var(--orange) 35%,transparent);border-radius:10px;padding:8px 11px;font-size:11.5px;color:var(--text2);line-height:1.5}
.stalled.show{display:flex}
.queue{padding:8px 14px 0;display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
.pill{display:flex;align-items:flex-start;gap:8px;background:var(--bg3);border:1px solid var(--border);border-left:2px solid var(--teal);border-radius:6px;padding:6px 8px;font-size:11.5px}
.pill.kind-chat{border-left-color:var(--accent)}
.pill.kind-verdict{border-left-color:var(--green)}
.pill .where{color:var(--teal);font-family:var(--mono);font-size:10px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pill .body{flex:1;min-width:0;color:var(--text2)}
.pill .txt{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.pill button{border:none;background:none;color:var(--text3);cursor:pointer;font-size:13px;line-height:1;padding:1px}
.pill button:hover{color:var(--red)}
.composer{padding:10px 14px 14px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px}
.composer .hint{font-size:10px;color:var(--text3)}
.composer textarea{width:100%;min-height:60px;max-height:160px;resize:vertical;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-size:12.5px;font-family:var(--font);outline:none;transition:all .15s}
.composer textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.composer .row{display:flex;gap:8px;align-items:center}
.composer .send{flex:1;height:32px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;transition:all .12s}
.composer .send:hover{background:var(--accent-dim)}
.composer .send:disabled{opacity:.5;cursor:default}
.composer .status{font-size:10.5px;color:var(--text3)}
.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 80%,transparent);backdrop-filter:blur(6px);z-index:50}
.overlay.show{display:flex}
.overlay .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);padding:26px 32px;text-align:center;max-width:340px}
.overlay .card h3{font-size:14px;margin-bottom:6px}
.overlay .card p{font-size:12px;color:var(--text2);line-height:1.5}
`;
}
// Client logic for the chrome page (runs in the top window).
function canvasClientJs() {
return `'use strict';
(() => {
const boot = JSON.parse(document.getElementById('pc-session').textContent);
const key = boot.key;
const $ = id => document.getElementById(id);
const frame = $('artifact');
const chatLog = $('chatLog');
const queueEl = $('queue');
const input = $('chatInput');
const sendBtn = $('send');
const statusEl = $('sendStatus');
const presence = $('presence');
const QKEY = 'ecc-plan-canvas:queue:' + key;
let queue = [];
let lastScroll = { x: 0, y: 0 };
let ended = boot.status === 'ended';
let sending = false;
try { queue = JSON.parse(sessionStorage.getItem(QKEY) || '[]'); } catch { queue = []; }
// --- theme ---------------------------------------------------------
const themeKey = 'ecc-plan-canvas:theme';
function applyTheme(t) {
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
$('themeBtn').textContent = t === 'light' ? '\\u263E dark' : '\\u2600 light';
}
// Storage access throws outright when the browser blocks site data for this
// origin (loopback is a common trigger). Unguarded, that killed the whole
// client IIFE here, before the send button and Enter handlers bound below:
// every control rendered and stayed inert. sessionStorage is already guarded
// above and below; match it. See affaan-m/ECC#2702.
function readTheme() {
try { return localStorage.getItem(themeKey); } catch { return null; }
}
function writeTheme(v) {
try { localStorage.setItem(themeKey, v); } catch { /* site data blocked */ }
}
let theme = readTheme() || 'dark';
applyTheme(theme);
$('themeBtn').addEventListener('click', () => {
theme = theme === 'light' ? 'dark' : 'light';
writeTheme(theme);
applyTheme(theme);
});
// --- annotate mode -------------------------------------------------
let annotate = true;
function setAnnotate(on) {
annotate = on;
$('annotate').setAttribute('aria-pressed', String(on));
postToFrame({ type: 'pc:set-mode', annotate: on });
}
$('annotate').addEventListener('click', () => setAnnotate(!annotate));
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
e.preventDefault();
setAnnotate(!annotate);
}
}, true);
// --- iframe bridge --------------------------------------------------
function postToFrame(msg) {
if (frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
}
window.addEventListener('message', e => {
if (e.source !== frame.contentWindow) return;
const msg = e.data || {};
if (msg.type === 'pc:queue' && msg.item) addToQueue(msg.item);
else if (msg.type === 'pc:queue-and-send' && msg.item) { addToQueue(msg.item); send(); }
else if (msg.type === 'pc:scroll') lastScroll = { x: msg.x || 0, y: msg.y || 0 };
else if (msg.type === 'pc:toggle-mode') setAnnotate(!annotate);
else if (msg.type === 'pc:ready') {
postToFrame({ type: 'pc:set-mode', annotate });
postToFrame({ type: 'pc:restore-scroll', x: lastScroll.x, y: lastScroll.y });
}
});
// --- queue ----------------------------------------------------------
function persistQueue() { try { sessionStorage.setItem(QKEY, JSON.stringify(queue)); } catch { /* full */ } }
function addToQueue(item) { queue.push(item); persistQueue(); renderQueue(); }
function renderQueue() {
queueEl.innerHTML = '';
queue.forEach((item, i) => {
const pill = document.createElement('div');
pill.className = 'pill kind-' + item.kind;
const body = document.createElement('span');
body.className = 'body';
if (item.anchor) {
const where = document.createElement('span');
where.className = 'where';
where.textContent = item.anchor.snippet || item.anchor.selector;
body.appendChild(where);
}
const txt = document.createElement('span');
txt.className = 'txt';
txt.textContent = item.kind === 'verdict' ? (item.verdict === 'approve' ? 'Approve plan' : 'Request changes') + (item.text ? ': ' + item.text : '') : item.text;
body.appendChild(txt);
const rm = document.createElement('button');
rm.textContent = '\\u00D7';
rm.title = 'Remove';
rm.addEventListener('click', () => { queue.splice(i, 1); persistQueue(); renderQueue(); });
pill.append(body, rm);
queueEl.appendChild(pill);
});
}
renderQueue();
// --- activity indicators ---------------------------------------------
// Built once and re-appended on every chat render so the animation never
// restarts mid-thought.
const typingEl = document.createElement('div');
typingEl.className = 'typing';
typingEl.setAttribute('role', 'status');
typingEl.setAttribute('aria-live', 'polite');
const dots = document.createElement('span');
dots.className = 'dots';
dots.append(document.createElement('i'), document.createElement('i'), document.createElement('i'));
const typingLabel = document.createElement('span');
typingLabel.className = 'label';
typingEl.append(dots, typingLabel);
const stalledEl = document.createElement('div');
stalledEl.className = 'stalled';
stalledEl.setAttribute('role', 'status');
const TYPING_LABELS = { thinking: 'agent is thinking\\u2026', typing: 'agent is typing\\u2026' };
function renderActivity(state) {
const typingText = TYPING_LABELS[state];
typingEl.classList.toggle('show', Boolean(typingText));
if (typingText) typingLabel.textContent = typingText;
const stalled = state === 'queued';
stalledEl.classList.toggle('show', stalled);
if (stalled) {
stalledEl.textContent =
'Delivered to the queue. Your agent is not listening right now, so it picks this up the moment it checks in.';
}
if (typingText || stalled) scrollToEnd();
}
// --- chat -----------------------------------------------------------
function atBottom() {
return chatLog.scrollHeight - chatLog.scrollTop - chatLog.clientHeight < 40;
}
function scrollToEnd() { chatLog.scrollTop = chatLog.scrollHeight; }
function renderChat(entries) {
const pinned = atBottom();
chatLog.innerHTML = '';
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'Click anything in the plan to annotate it, or type below. Feedback goes straight to your agent.';
chatLog.appendChild(empty);
} else {
for (const entry of entries) {
const div = document.createElement('div');
div.className = 'msg ' + (entry.role === 'agent' ? 'agent' : 'user') + ' kind-' + (entry.kind || 'chat');
div.textContent = entry.text;
const meta = document.createElement('span');
meta.className = 'meta';
meta.textContent = (entry.role === 'agent' ? 'agent' : 'you') + ' \\u00B7 ' + new Date(entry.at).toLocaleTimeString();
div.appendChild(meta);
chatLog.appendChild(div);
}
}
// The indicators live at the tail of the log, so they survive re-render.
chatLog.appendChild(typingEl);
chatLog.appendChild(stalledEl);
if (pinned) scrollToEnd();
}
renderChat(boot.chat || []);
// --- send -----------------------------------------------------------
async function send(extraItems) {
if (ended || sending) return;
const items = queue.slice();
if (extraItems) items.push(...extraItems);
const text = input.value.trim();
if (text) items.push({ kind: 'chat', text });
if (!items.length) {
statusEl.textContent = 'Nothing to send yet - annotate the plan or type a message.';
return;
}
sending = true;
sendBtn.disabled = true;
statusEl.textContent = 'Sending\\u2026';
try {
const res = await fetch('/api/session/' + key + '/feedback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const body = await res.json().catch(() => ({}));
queue = [];
persistQueue();
renderQueue();
input.value = '';
// Say what actually happened: a parked agent takes the batch on the
// spot, otherwise it sits in the queue until the agent checks in.
statusEl.textContent = body.presence === 'thinking' || body.presence === 'typing'
? 'Delivered. Your agent has it.'
: 'Queued. Your agent picks this up the moment it checks in.';
if (body.presence) applyPresence(body.presence);
} catch (err) {
statusEl.textContent = 'Send failed (' + err.message + ') - is the canvas server still running?';
} finally {
sending = false;
sendBtn.disabled = ended;
}
}
sendBtn.addEventListener('click', () => send());
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
$('approve').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'approve' }]));
$('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
// --- session controls ------------------------------------------------
$('reloadBtn').addEventListener('click', reloadArtifact);
$('endBtn').addEventListener('click', async () => {
if (!window.confirm('End this review session?')) return;
try { await fetch('/api/session/' + key + '/end', { method: 'POST' }); } catch { /* server gone */ }
});
function reloadArtifact() {
const base = frame.getAttribute('data-artifact-src');
frame.src = base + '?t=' + Date.now();
}
function markEnded(endedBy) {
ended = true;
sendBtn.disabled = true;
input.disabled = true;
renderActivity('ended');
presence.setAttribute('data-state', 'ended');
presence.querySelector('.label').textContent = 'session ended';
$('endedOverlay').classList.add('show');
$('endedWho').textContent = endedBy === 'agent'
? 'Your agent closed this review.'
: 'You ended this review. Head back to your agent session.';
}
if (ended) markEnded(boot.endedBy);
// --- server events ----------------------------------------------------
const PRESENCE_LABELS = {
waiting: 'agent not connected',
listening: 'agent listening',
thinking: 'agent is thinking\\u2026',
typing: 'agent is typing\\u2026',
queued: 'queued for your agent'
};
function applyPresence(state) {
if (ended) return;
presence.setAttribute('data-state', state);
presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
renderActivity(state);
}
function connectEvents() {
const es = new EventSource('/events/' + key);
es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || []));
es.addEventListener('presence', e => applyPresence(JSON.parse(e.data).state));
es.addEventListener('reload', reloadArtifact);
es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
es.onerror = () => {
if (ended) return;
renderActivity('offline');
presence.setAttribute('data-state', 'waiting');
presence.querySelector('.label').textContent = 'canvas server offline';
};
}
connectEvents();
})();`;
}
// The chrome page: header bar, artifact iframe, conversation rail.
function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canvas.css' } = {}) {
const name = path.basename(session.file);
const bootstrap = JSON.stringify({
key: session.key,
file: session.file,
status: session.status,
endedBy: session.endedBy || null,
chat: session.chat
}).replace(/</g, '\\u003c');
const artifactSrc = `/artifact/${session.key}/`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(name)} · Plan Canvas</title>
<link rel="stylesheet" href="${cssPath}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%236885e8'/><stop offset='1' stop-color='%23e26a9e'/></linearGradient></defs><rect width='100' height='100' rx='22' fill='url(%23g)'/><text x='50' y='68' font-size='52' font-weight='700' font-family='sans-serif' fill='white' text-anchor='middle'>E</text></svg>">
</head>
<body>
<script id="pc-session" type="application/json">${bootstrap}</script>
<header class="bar">
<div class="brand">
<div class="logo">E</div>
<span class="name">Plan Canvas</span>
<span class="file" title="${escapeHtml(session.file)}">${escapeHtml(name)}</span>
</div>
<div class="spacer"></div>
<div id="presence" class="presence" data-state="waiting"><span class="dot"></span><span class="label">agent not connected</span></div>
<div id="annotate" class="toggle" role="switch" aria-pressed="true" title="Toggle annotate mode (Cmd/Ctrl+I)">
<span>Annotate</span><span class="track"><span class="knob"></span></span>
</div>
<button id="themeBtn" class="icon-btn" type="button">light</button>
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
</header>
<div class="layout">
<main class="frame">
<iframe id="artifact" title="Artifact under review" src="${artifactSrc}" data-artifact-src="${artifactSrc}" sandbox="allow-scripts allow-forms allow-popups"></iframe>
<div id="endedOverlay" class="overlay"><div class="card"><h3>Session ended</h3><p id="endedWho"></p></div></div>
</main>
<aside class="panel">
<h2>Plan verdict</h2>
<div class="verdict">
<button id="approve" class="approve" type="button">Approve plan</button>
<button id="changes" class="changes" type="button">Request changes</button>
</div>
<h2>Conversation</h2>
<div id="chatLog" class="chat"></div>
<div id="queue" class="queue"></div>
<div class="composer">
<textarea id="chatInput" placeholder="Message your agent&#10;Enter to send &middot; Shift+Enter for a new line"></textarea>
<div class="row">
<button id="send" class="send" type="button">Send to agent</button>
</div>
<div id="sendStatus" class="status"></div>
<div class="hint">Annotations queue up here until you send them together.</div>
</div>
</aside>
</div>
<script src="${clientPath}"></script>
</body>
</html>`;
}
// ECC-styled document template for rendered markdown plan artifacts.
function renderMarkdownArtifactHtml(bodyHtml, { title, sdkSrc }) {
const hasMermaid = bodyHtml.includes('class="mermaid"');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
${TOKENS_CSS}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.65;font-size:14.5px}
.doc{max-width:860px;margin:0 auto;padding:44px 36px 90px}
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.6em 0 .55em;letter-spacing:-.01em}
h1{font-size:26px;margin-top:.3em;padding-bottom:.45em;border-bottom:1px solid var(--border)}
h1:after{content:'';display:block;width:56px;height:3px;margin-top:14px;border-radius:2px;background:linear-gradient(90deg,var(--accent),var(--pink))}
h2{font-size:19px;padding-bottom:.3em;border-bottom:1px solid var(--border)}
h3{font-size:15.5px}
h4,h5,h6{font-size:13.5px;color:var(--text2);text-transform:uppercase;letter-spacing:.05em}
p,ul,ol,blockquote,table,pre{margin-bottom:.9em}
ul,ol{padding-left:1.5em}
li{margin:.25em 0}
li.task{list-style:none;margin-left:-1.3em}
li.task input{margin-right:.5em;accent-color:var(--accent)}
a{color:var(--accent);text-decoration:none;border-bottom:1px solid var(--accent-glow)}
a:hover{border-bottom-color:var(--accent)}
code{font-family:var(--mono);font-size:.88em;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:.12em .38em}
pre{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;overflow-x:auto}
pre code{background:none;border:none;padding:0;font-size:12.5px;line-height:1.55}
blockquote{border-left:3px solid var(--accent);background:var(--accent-glow);border-radius:0 var(--radius-sm) var(--radius-sm) 0;padding:8px 14px;color:var(--text2)}
table{width:100%;border-collapse:collapse;font-size:13px;display:block;overflow-x:auto}
th,td{text-align:left;padding:7px 12px;border:1px solid var(--border)}
th{background:var(--bg3);font-weight:600;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);white-space:nowrap}
tbody tr:hover{background:var(--surface-hover)}
hr{border:none;border-top:1px solid var(--border);margin:1.6em 0}
img{max-width:100%;border-radius:var(--radius-sm)}
pre.mermaid{font-family:var(--mono);font-size:12.5px;line-height:1.55;white-space:pre-wrap}
pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto}
pre.mermaid[data-processed] svg{max-width:100%;height:auto}
pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px}
</style>
</head>
<body>
<article class="doc">
${bodyHtml}
</article>
${hasMermaid ? mermaidLoaderScript(mermaidUrl()) : ''}
<script src="${sdkSrc}"></script>
</body>
</html>`;
}
// Landing page listing sessions (GET /).
function renderSessionListHtml(sessions) {
const rows = sessions.map(s => {
const status = s.status === 'ended' ? `ended by ${escapeHtml(s.endedBy || 'agent')}` : s.status;
const link = s.status === 'ended'
? escapeHtml(path.basename(s.file))
: `<a href="/canvas/${escapeHtml(s.key)}">${escapeHtml(path.basename(s.file))}</a>`;
return `<tr><td>${link}</td><td class="mono">${escapeHtml(s.file)}</td><td><span class="badge ${escapeHtml(s.status)}">${status}</span></td></tr>`;
}).join('\n');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Plan Canvas · sessions</title>
<style>
${TOKENS_CSS}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--font);background:var(--bg);color:var(--text);padding:40px;line-height:1.5}
.logo{width:30px;height:30px;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:7px;display:inline-flex;align-items:center;justify-content:center;font-weight:700;color:#fff;margin-right:10px;vertical-align:middle}
h1{font-size:18px;display:inline-block;vertical-align:middle}
table{margin-top:24px;border-collapse:collapse;width:100%;max-width:900px;font-size:13px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}
th{color:var(--text3);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
a{color:var(--accent);text-decoration:none}
.mono{font-family:var(--mono);font-size:11.5px;color:var(--text2)}
.badge{font-size:11px;padding:2px 8px;border-radius:99px;background:var(--bg3);border:1px solid var(--border);color:var(--text2)}
.badge.open,.badge.feedback{color:var(--green);border-color:var(--green);background:var(--green-glow)}
.empty{margin-top:24px;color:var(--text3);font-size:13px}
</style>
</head>
<body>
<span class="logo">E</span><h1>Plan Canvas sessions</h1>
${sessions.length ? `<table><thead><tr><th>Artifact</th><th>Path</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="empty">No sessions yet. Ask your agent to open a plan with the plan-canvas skill.</p>'}
</body>
</html>`;
}
module.exports = {
canvasCss,
canvasClientJs,
renderCanvasHtml,
renderMarkdownArtifactHtml,
renderSessionListHtml
};
+6 -3
View File
@@ -198,10 +198,13 @@ function getPythonDeps(projectDir) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-')) {
const name = trimmed
.split(/[>=<![;]/)[0]
.split(/[\s>=<!~@[;]/)[0]
.trim()
.toLowerCase();
if (name) deps.push(name);
// Bare VCS/URL requirement lines (e.g. `git+https://...#egg=pkg`)
// carry no leading package name; skip them instead of recording
// the URL fragment as a dependency name.
if (name && !name.startsWith('git+') && !name.includes('://')) deps.push(name);
}
});
}
@@ -220,7 +223,7 @@ function getPythonDeps(projectDir) {
block.match(/"([^"]+)"/g)?.forEach(m => {
const name = m
.replace(/"/g, '')
.split(/[>=<![;]/)[0]
.split(/[\s>=<!~@[;]/)[0]
.trim()
.toLowerCase();
if (name) deps.push(name);
+48 -26
View File
@@ -18,6 +18,17 @@ const PLUGIN_ROOT_SEGMENTS = [
['marketplaces', LEGACY_PLUGIN_SLUG],
];
// Artifacts that identify a COMPLETE ECC root when the caller gives no explicit
// probe. A real ECC root ships both the script tree AND ECC's skills; a partial
// install (scripts copied, skills not) must not qualify for skill-resolving
// callers, which build `skills/...` paths against the resolved root (#2544).
// Checking "skills/ exists" is not enough — a user's own ~/.claude/skills/ can
// be present with none of ECC's skills — so we probe for a sentinel skill that
// ships in every ECC root and is exactly what the failing skill commands need.
// If that skill is ever renamed, move this sentinel with it.
const DEFAULT_SCRIPT_PROBE = path.join('scripts', 'lib', 'utils.js');
const DEFAULT_SKILL_PROBE = path.join('skills', 'continuous-learning-v2');
/**
* Resolve the ECC source root directory.
*
@@ -31,8 +42,14 @@ const PLUGIN_ROOT_SEGMENTS = [
* @param {object} [options]
* @param {string} [options.homeDir] Override home directory (for testing)
* @param {string} [options.envRoot] Override CLAUDE_PLUGIN_ROOT (for testing)
* @param {string} [options.probe] Relative path used to verify a candidate root
* contains ECC scripts. Default: 'scripts/lib/utils.js'
* @param {string} [options.probe] Relative path used to verify a candidate
* root contains what the caller needs. When
* given, it is honored exactly (script
* consumers pass their own script path). When
* omitted, a candidate must contain BOTH the
* ECC script tree and a sentinel ECC skill,
* so a partial install (scripts without
* skills) is rejected for skill consumers.
* @returns {string} Resolved ECC root path
*/
function resolveEccRoot(options = {}) {
@@ -46,10 +63,20 @@ function resolveEccRoot(options = {}) {
const homeDir = options.homeDir || os.homedir();
const claudeDir = path.join(homeDir, '.claude');
const probe = options.probe || path.join('scripts', 'lib', 'utils.js');
// Decide whether a candidate directory is a usable ECC root. An explicit
// caller probe is honored exactly (script consumers know the artifact they
// need). With the default probe the caller is a skill consumer, so a
// candidate must contain both ECC's scripts and a sentinel ECC skill —
// otherwise a scripts-only ~/.claude short-circuits and every skill path
// resolves to a location that does not exist (#2544).
const isRoot = options.probe
? (dir) => fs.existsSync(path.join(dir, options.probe))
: (dir) => fs.existsSync(path.join(dir, DEFAULT_SCRIPT_PROBE))
&& fs.existsSync(path.join(dir, DEFAULT_SKILL_PROBE));
// Standard install — files are copied directly into ~/.claude/
if (fs.existsSync(path.join(claudeDir, probe))) {
if (isRoot(claudeDir)) {
return claudeDir;
}
@@ -60,7 +87,7 @@ function resolveEccRoot(options = {}) {
);
for (const candidate of legacyPluginRoots) {
if (fs.existsSync(path.join(candidate, probe))) {
if (isRoot(candidate)) {
return candidate;
}
}
@@ -86,7 +113,7 @@ function resolveEccRoot(options = {}) {
for (const verEntry of versionDirs) {
if (!verEntry.isDirectory()) continue;
const candidate = path.join(orgPath, verEntry.name);
if (fs.existsSync(path.join(candidate, probe))) {
if (isRoot(candidate)) {
return candidate;
}
}
@@ -100,32 +127,27 @@ function resolveEccRoot(options = {}) {
}
/**
* Compact inline version for embedding in command .md code blocks.
* Compact inline locator for embedding in hooks.json and command .md code blocks.
*
* This is the minified form of resolveEccRoot() suitable for use in
* node -e "..." scripts where require() is not available before the
* root is known.
* Earlier revisions inlined the *entire* resolveEccRoot() search (~700 chars,
* duplicated ~80×). That blob used a spread (`...s`) over nested array literals,
* which broke Windows hook execution due to shell quoting (#2368).
*
* This minified form contains no spread, no nested array literals, and no
* escaped double quotes, so it survives `node -e "..."` quoting on every shell.
* When CLAUDE_PLUGIN_ROOT is set (as Claude Code does for plugin hooks and
* commands) it is used directly. Otherwise the inline probes the same set of
* locations resolveEccRoot() knows about — ~/.claude, the exact plugin roots
* under ~/.claude/plugins/, and the versioned plugin cache — only far enough to
* load the committed resolve-ecc-root module, then delegates the authoritative
* decision to resolveEccRoot(). This keeps discovery behaviour identical to the
* old inline while centralising the real logic in one tested module.
*
* Usage in commands:
* const _r = <paste INLINE_RESOLVE>;
* const sm = require(_r + '/scripts/lib/session-manager');
*/
function inlineSingleQuote(value) {
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}
function inlineArray(values) {
return `[${values.map(inlineSingleQuote).join(',')}]`;
}
function inlineNestedArray(values) {
return `[${values.map(inlineArray).join(',')}]`;
}
const INLINE_PLUGIN_ROOT_SEGMENTS = inlineNestedArray(PLUGIN_ROOT_SEGMENTS);
const INLINE_PLUGIN_CACHE_SLUGS = inlineArray(PLUGIN_CACHE_SLUGS);
const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of ${INLINE_PLUGIN_ROOT_SEGMENTS}){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ${INLINE_PLUGIN_CACHE_SLUGS}){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})()`;
const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})()`;
module.exports = {
resolveEccRoot,
+5
View File
@@ -9,6 +9,7 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
// ── Caches (per-process, cleared on next hook invocation) ───────────
@@ -58,8 +59,12 @@ const FORMATTER_PACKAGES = {
function findProjectRoot(startDir) {
if (projectRootCache.has(startDir)) return projectRootCache.get(startDir);
const homeDir = os.homedir();
let dir = startDir;
while (dir !== path.dirname(dir)) {
// Stop before checking the home directory to avoid treating global
// dotfiles (e.g. ~/.prettierrc) as a project root marker.
if (dir === homeDir) break;
for (const marker of PROJECT_ROOT_MARKERS) {
if (fs.existsSync(path.join(dir, marker))) {
projectRootCache.set(startDir, dir);
+14 -2
View File
@@ -26,6 +26,18 @@ const {
// "2026-02-01-ChezMoi_2-session.tmp"
const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_-]*))?-session\.tmp$/;
/**
* Resolve a file's creation time, preferring birthtime but falling back to
* ctime when birthtime is unavailable. Some filesystems (e.g. overlayfs in
* containers) report birthtime as epoch 0; a Date object is always truthy, so
* `birthtime || ctime` would never fall back. Compare on milliseconds instead.
* @param {import('fs').Stats} stats
* @returns {Date}
*/
function resolveCreatedTime(stats) {
return stats.birthtimeMs > 0 ? stats.birthtime : stats.ctime;
}
/**
* Parse session filename to extract metadata
* @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp")
@@ -116,7 +128,7 @@ function getSessionCandidates(options = {}) {
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
createdTime: resolveCreatedTime(stats)
});
}
}
@@ -151,7 +163,7 @@ function buildSessionRecord(sessionPath, metadata) {
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
createdTime: resolveCreatedTime(stats)
};
}
+20 -4
View File
@@ -55,8 +55,12 @@ function extractCommandSubstitutions(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === '`') {
break;
@@ -85,8 +89,12 @@ function extractCommandSubstitutions(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
@@ -213,8 +221,12 @@ function extractSubshellGroups(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
@@ -374,8 +386,12 @@ function extractBraceGroups(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const { spawn } = require('child_process');
const CLEAR_LINE = '\r\x1b[2K';
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const FRAME_INTERVAL_MS = 80;
function runAnimator(label, options = {}) {
const output = options.output || process.stdout;
const schedule = options.schedule || setInterval;
const clearSchedule = options.clearSchedule || clearInterval;
const onDisconnect = options.onDisconnect
|| (handler => process.on('disconnect', handler));
const exit = options.exit || (code => process.exit(code));
let frameIndex = 1;
const timer = schedule(() => {
output.write(`\r${FRAMES[frameIndex]} ${label}`);
frameIndex = (frameIndex + 1) % FRAMES.length;
}, FRAME_INTERVAL_MS);
onDisconnect(() => {
clearSchedule(timer);
exit(0);
});
}
function startTerminalSpinner(label, options = {}) {
const output = options.output || process.stdout;
const spawnProcess = options.spawnProcess || spawn;
const onAnimatorError = options.onAnimatorError;
output.write(`${FRAMES[0]} ${label}`);
let animator;
try {
animator = spawnProcess(
process.execPath,
[__filename, '--animate', label],
{
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
}
);
animator.on?.('error', error => {
// Preserve a stable visible fallback if the child cannot animate.
output.write(`\r${FRAMES[0]} ${label}`);
onAnimatorError?.(error);
});
} catch {
// The first frame still provides visible progress if animation cannot start.
}
let stopped = false;
return {
stop() {
if (stopped) return;
stopped = true;
animator?.once?.('close', () => {
// A child can render between kill() and close; clear that final frame.
output.write(CLEAR_LINE);
});
animator?.kill();
output.write(CLEAR_LINE);
},
};
}
// c8 ignore next 3 -- exercised as the independently instrumented child process.
if (require.main === module && process.argv[2] === '--animate') {
runAnimator(process.argv[3] || 'Working...');
}
module.exports = {
CLEAR_LINE,
FRAMES,
runAnimator,
startTerminalSpinner,
};
+146
View File
@@ -0,0 +1,146 @@
'use strict';
const { version: ECC_VERSION } = require('../../package.json');
const COMMUNITY_LINKS = Object.freeze({
github: 'https://github.com/affaan-m/ECC',
discord: 'https://discord.gg/36yGMHGFbR',
documentation: 'https://github.com/affaan-m/ECC#readme',
githubApp: 'https://github.com/apps/ecc-tools',
});
const SUCCESS_ACTIONS = Object.freeze([
'installed',
'updated',
'migrated',
'resumed',
'already-migrated',
'configured',
]);
const SUCCESS_MESSAGES = Object.freeze({
installed: 'Welcome to ECC!',
updated: 'ECC is updated — thank you for using ECC!',
migrated: 'ECC is configured — thank you for using ECC!',
resumed: 'ECC is configured — thank you for using ECC!',
'already-migrated': 'ECC is configured — thank you for using ECC!',
configured: 'ECC is configured — thank you for using ECC!',
});
// CFonts' default "block" face: https://github.com/dominikwilkowski/cfonts
const ECC_WORDMARK = Object.freeze([
' ███████╗ ██████╗ ██████╗',
' ██╔════╝ ██╔════╝ ██╔════╝',
' █████╗ ██║ ██║',
' ██╔══╝ ██║ ██║',
' ███████╗ ╚██████╗ ╚██████╗',
' ╚══════╝ ╚═════╝ ╚═════╝',
]);
const ECC_GRADIENT = Object.freeze({
start: Object.freeze({ red: 215, green: 151, blue: 107 }),
end: Object.freeze({ red: 100, green: 131, blue: 160 }),
});
const ECC_VERSION_PATTERN = /^[0-9]+(?:\.[0-9]+){2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const WORDMARK_START_COLUMN = Math.min(...ECC_WORDMARK.map(line => line.search(/\S/)));
const WORDMARK_END_COLUMN = Math.max(
...ECC_WORDMARK.map(line => line.trimEnd().length - 1)
);
function colorize(value, code, enabled) {
return enabled ? `\x1b[${code}m${value}\x1b[0m` : value;
}
function interpolateChannel(start, end, ratio) {
return Math.round(start + ((end - start) * ratio));
}
function gradientColorAt(column) {
const span = WORDMARK_END_COLUMN - WORDMARK_START_COLUMN;
const ratio = span === 0 ? 0 : (column - WORDMARK_START_COLUMN) / span;
return {
red: interpolateChannel(ECC_GRADIENT.start.red, ECC_GRADIENT.end.red, ratio),
green: interpolateChannel(ECC_GRADIENT.start.green, ECC_GRADIENT.end.green, ratio),
blue: interpolateChannel(ECC_GRADIENT.start.blue, ECC_GRADIENT.end.blue, ratio),
};
}
function renderWordmark(color) {
if (!color) return ECC_WORDMARK.join('\n');
return ECC_WORDMARK.map(line => (
[...line].map((character, column) => {
if (character === ' ') return character;
const value = gradientColorAt(column);
return `\x1b[38;2;${value.red};${value.green};${value.blue}m${character}`;
}).join('') + '\x1b[0m'
)).join('\n');
}
function renderCommunityLinks() {
const rows = Object.freeze([
`GitHub: ${COMMUNITY_LINKS.github}`,
`Discord: ${COMMUNITY_LINKS.discord}`,
`Documentation: ${COMMUNITY_LINKS.documentation}`,
`GitHub App: ${COMMUNITY_LINKS.githubApp}`,
]);
const contentWidth = Math.max(...rows.map(row => row.length));
const border = '─'.repeat(contentWidth + 2);
return [
`${border}`,
...rows.map(row => `${row.padEnd(contentWidth)}`),
`${border}`,
];
}
function renderTerminalWelcome(options = {}) {
const color = options.color === true;
const installedVersion = options.version || ECC_VERSION;
if (!ECC_VERSION_PATTERN.test(installedVersion)) {
throw new Error(`Invalid ECC version: ${installedVersion}`);
}
const graphic = renderWordmark(color);
const successMessage = SUCCESS_MESSAGES[options.action] || SUCCESS_MESSAGES.installed;
const welcomeMessage = colorize(successMessage, '1;35', color);
const version = colorize(`v${installedVersion}`, '2', color);
const versionLine = color ? `\x1b[1G ${version}` : ` ${version}`;
return [
'',
graphic,
'',
` ${welcomeMessage}`,
versionLine,
'',
...renderCommunityLinks(),
'',
].join('\n');
}
function showTerminalWelcome(options = {}) {
const {
action,
dryRun = false,
env = process.env,
interactive = false,
json = false,
output = process.stdout,
} = options;
const shouldShow = (
interactive
&& output.isTTY === true
&& !dryRun
&& !json
&& SUCCESS_ACTIONS.includes(action)
);
if (!shouldShow) return false;
const color = env.NO_COLOR === undefined && env.TERM !== 'dumb';
output.write(renderTerminalWelcome({ action, color }));
return true;
}
module.exports = {
COMMUNITY_LINKS,
ECC_VERSION_PATTERN,
renderTerminalWelcome,
showTerminalWelcome,
};
+40 -3
View File
@@ -28,6 +28,33 @@ const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024;
const MAX_TOKEN_SETTING = 10000000;
const LARGE_WINDOW_MODEL_MARKER = '[1m]';
// Known large-window model families whose ids carry no `[1m]` marker (#2461).
// Matched boundary-aware against the model id — covers dated/region-prefixed
// variants (e.g. `us.anthropic.claude-fable-5-20260115-v1:0`) without matching
// hypothetical smaller tiers sharing the prefix (e.g. `claude-fable-5-mini`).
// Checked in order, first match wins. Best-effort and expected to lag new
// releases; the env override remains the escape hatch for unlisted models.
const KNOWN_MODEL_WINDOW_TOKENS = [
['claude-opus-5', LARGE_CONTEXT_WINDOW_TOKENS],
['claude-fable-5', LARGE_CONTEXT_WINDOW_TOKENS],
['claude-mythos-5', LARGE_CONTEXT_WINDOW_TOKENS]
];
/**
* True when `model` contains `familyId` ending at a token boundary: end of id,
* a delimiter (`[`, `:`, `.`), or a dated/versioned suffix (`-20260115`).
* Alphanumeric continuations and letter suffixes (`-mini`) are different
* models, possibly with smaller windows, and must not match.
*/
function isKnownModelFamilyMatch(model, familyId) {
const start = model.indexOf(familyId);
if (start === -1) {
return false;
}
const rest = model.slice(start + familyId.length);
return !/^[A-Za-z0-9]/.test(rest) && !/^-[A-Za-z]/.test(rest);
}
/**
* Read the trailing `tailBytes` of a file as UTF-8.
* Returns null when the file is missing or unreadable.
@@ -132,9 +159,10 @@ function readLatestContextTokens(transcriptPath, options = {}) {
/**
* Detect the context window size for a turn.
* 1M when the model id carries the `[1m]` marker, or when the observed token
* count already exceeds the standard 200k window (covers logs that drop the
* suffix); otherwise the standard 200k window.
* 1M when the model id carries the `[1m]` marker, matches a known large-window
* model family, or when the observed token count already exceeds the standard
* 200k window (covers logs that drop the suffix); otherwise the standard 200k
* window.
*/
function resolveContextWindowTokens(tokens, model) {
// Explicit window override wins: 400k models (e.g. Opus 4.x) match neither the
@@ -150,6 +178,15 @@ function resolveContextWindowTokens(tokens, model) {
return LARGE_CONTEXT_WINDOW_TOKENS;
}
// Large-window model families without a [1m] marker fall through the checks
// above and would be misreported against the 200k default (#2461).
if (typeof model === 'string') {
const known = KNOWN_MODEL_WINDOW_TOKENS.find(([familyId]) => isKnownModelFamilyMatch(model, familyId));
if (known) {
return known[1];
}
}
if (Number.isFinite(tokens) && tokens > STANDARD_CONTEXT_WINDOW_TOKENS) {
return LARGE_CONTEXT_WINDOW_TOKENS;
}
+33 -5
View File
@@ -284,6 +284,7 @@ async function readStdinJson(options = {}) {
return new Promise((resolve) => {
let data = '';
let settled = false;
let overflowed = false;
const timer = setTimeout(() => {
if (!settled) {
@@ -293,7 +294,12 @@ async function readStdinJson(options = {}) {
process.stdin.removeAllListeners('end');
process.stdin.removeAllListeners('error');
if (process.stdin.unref) process.stdin.unref();
// Resolve with whatever we have so far rather than hanging
// Oversized input is always rejected. Otherwise, resolve with whatever
// arrived before the timeout rather than hanging.
if (overflowed) {
resolve({});
return;
}
try {
resolve(data.trim() ? JSON.parse(data) : {});
} catch {
@@ -304,15 +310,34 @@ async function readStdinJson(options = {}) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < maxSize) {
data += chunk;
if (settled) return;
if (overflowed) return;
// Mark oversized input as rejected and discard the buffered prefix.
// Continue consuming the stream without retaining later chunks so a
// finite parent can finish writing without EPIPE. Resolution happens at
// EOF or the existing timeout, which also bounds never-closing writers.
if (data.length + chunk.length > maxSize) {
overflowed = true;
data = '';
process.stderr.write(
`[readStdinJson] stdin exceeded ${maxSize} bytes; input truncated and treated as empty\n`
);
return;
}
data += chunk;
});
process.stdin.on('end', () => {
if (settled) return;
if (settled) {
clearTimeout(timer);
return;
}
settled = true;
clearTimeout(timer);
if (overflowed) {
resolve({});
return;
}
try {
resolve(data.trim() ? JSON.parse(data) : {});
} catch {
@@ -323,7 +348,10 @@ async function readStdinJson(options = {}) {
});
process.stdin.on('error', () => {
if (settled) return;
if (settled) {
clearTimeout(timer);
return;
}
settled = true;
clearTimeout(timer);
// Resolve with empty object so hooks don't crash on stdin errors