feat: add native Factory Droid hook integration

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 10:52:47 -04:00
1 parent 66c5ab00a9
commit 3999ed0f44
11 files changed
+1476 -2

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ import { copilotPlugin } from './plugins/copilot.js';
import { lettaPlugin } from './plugins/letta.js';
import { codexPlugin } from './plugins/codex.js';
import { clinePlugin } from './plugins/cline.js';
import { droidPlugin } from './plugins/droid.js';
import { kimiPlugin } from './plugins/kimi.js';
import { grokPlugin } from './plugins/grok.js';
@@ -34,6 +35,7 @@ const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
['letta', lettaPlugin],
['codex', codexPlugin],
['cline', clinePlugin],
['droid', droidPlugin],
['kimi', kimiPlugin],
['grok', grokPlugin],
]);
+283
View File
@@ -0,0 +1,283 @@
import {
applyEdits,
modify,
parse,
parseTree,
printParseErrorCode,
type FormattingOptions,
type JSONPath,
type Node,
type ParseError,
} from 'jsonc-parser';
import {
TOOL_EVENTS,
type DroidGroup,
type DroidHookSettings,
type JsonObject,
type ToolEvent,
hasOwn,
isObject,
managedRemovals,
readHookSettings,
} from './droid-contract.js';
export const OWNED_FILE_MARKER = '// Managed by Elydora';
export type DroidDocumentKind = 'hooks' | 'legacy' | 'settings';
export interface DroidDocument {
readonly kind: DroidDocumentKind;
readonly filePath: string;
readonly exists: boolean;
readonly raw: string;
readonly root: JsonObject;
readonly hooks: DroidHookSettings;
readonly basePath: JSONPath;
readonly hasHooksContainer: boolean;
readonly ownedFile: boolean;
}
export interface DroidSources {
readonly rootPath: string;
readonly primary?: DroidDocument;
readonly settings: DroidDocument;
}
export interface InstallationTargets {
readonly targets: ReadonlyMap<ToolEvent, DroidDocument>;
readonly createdRoot?: DroidDocument;
}
export interface RenderedDocument {
readonly document: DroidDocument;
readonly changed: boolean;
readonly next?: string;
}
interface DocumentOptions {
readonly exists: boolean;
readonly filePath: string;
readonly kind: DroidDocumentKind;
readonly raw: string;
}
function rejectDuplicateKeys(node: Node | undefined, label: string, path: string[] = []): void {
if (!node) return;
if (node.type === 'object') {
const keys = new Set<string>();
for (const property of node.children ?? []) {
const key = String(property.children?.[0]?.value);
const location = [...path, key];
if (keys.has(key)) throw new Error(`${label} contains duplicate field "${location.join('.')}"`);
keys.add(key);
rejectDuplicateKeys(property.children?.[1], label, location);
}
return;
}
if (node.type === 'array') {
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, path);
}
}
function parseJsoncObject(raw: string, label: string): JsonObject {
const errors: ParseError[] = [];
const value: unknown = parse(raw, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (errors.length > 0) {
const details = errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join(', ');
throw new Error(`Failed to parse ${label}: ${details}`);
}
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
rejectDuplicateKeys(parseTree(raw, [], {
allowTrailingComma: true,
disallowComments: false,
}), label);
return value;
}
function labelFor(kind: DroidDocumentKind, filePath: string): string {
if (kind === 'settings') return `Factory Droid settings at ${filePath}`;
if (kind === 'legacy') return `Factory Droid legacy hooks at ${filePath}`;
return `Factory Droid hooks at ${filePath}`;
}
export function parseDocument(options: DocumentOptions): DroidDocument {
const label = labelFor(options.kind, options.filePath);
const root = parseJsoncObject(options.raw, label);
if (options.kind === 'settings') {
const hasHooksContainer = hasOwn(root, 'hooks');
const hooks = hasHooksContainer
? readHookSettings(root.hooks, `${label} field "hooks"`)
: {};
return {
...options,
root,
hooks,
basePath: ['hooks'],
hasHooksContainer,
ownedFile: false,
};
}
return {
...options,
root,
hooks: readHookSettings(root, label),
basePath: [],
hasHooksContainer: true,
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
};
}
export function createSettingsDocument(filePath: string): DroidDocument {
return parseDocument({ exists: false, filePath, kind: 'settings', raw: '{}\n' });
}
export function createOwnedHookDocument(filePath: string): DroidDocument {
return parseDocument({
exists: false,
filePath,
kind: 'hooks',
raw: `${OWNED_FILE_MARKER}\n{}\n`,
});
}
function eventPath(document: DroidDocument, event: string): JSONPath {
return [...document.basePath, event];
}
function formatting(raw: string): FormattingOptions {
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
const insertSpaces = !indentation?.includes('\t');
return {
eol: raw.includes('\r\n') ? '\r\n' : '\n',
insertSpaces,
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
};
}
function change(
raw: string,
path: JSONPath,
value: unknown,
isArrayInsertion = false,
): string {
return applyEdits(raw, modify(raw, path, value, {
formattingOptions: formatting(raw),
isArrayInsertion,
}));
}
function currentDocument(document: DroidDocument, raw: string): DroidDocument {
return parseDocument({
exists: document.exists,
filePath: document.filePath,
kind: document.kind,
raw,
});
}
function removeManagedEntries(
document: DroidDocument,
raw: string,
agentId?: string,
): string {
const removals = managedRemovals(document.hooks, agentId);
for (const event of TOOL_EVENTS) {
const eventRemovals = removals
.filter((removal) => removal.event === event)
.sort((left, right) => right.groupIndex - left.groupIndex);
if (eventRemovals.length === 0) continue;
for (const removal of eventRemovals) {
const groupPath = [...eventPath(document, event), removal.groupIndex];
if (removal.removeGroup) {
raw = change(raw, groupPath, undefined);
continue;
}
for (const handlerIndex of [...removal.handlerIndexes].sort((left, right) => right - left)) {
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
}
}
const current = currentDocument(document, raw);
if ((current.hooks[event] ?? []).length === 0) {
raw = change(raw, eventPath(document, event), undefined);
}
}
return raw;
}
function appendGroup(
document: DroidDocument,
raw: string,
event: ToolEvent,
group: DroidGroup,
): string {
const current = currentDocument(document, raw);
if (hasOwn(current.hooks, event)) {
const groups = current.hooks[event] ?? [];
return change(raw, [...eventPath(document, event), groups.length], group, true);
}
return change(raw, eventPath(document, event), [group]);
}
function hookFileIsEmpty(document: DroidDocument, raw: string): boolean {
if (document.kind === 'settings') return false;
return Object.keys(currentDocument(document, raw).hooks).length === 0;
}
export function renderDocument(
document: DroidDocument,
agentId: string | undefined,
additions: ReadonlyMap<ToolEvent, DroidGroup>,
): RenderedDocument {
let raw = removeManagedEntries(document, document.raw, agentId);
for (const event of TOOL_EVENTS) {
const group = additions.get(event);
if (group) raw = appendGroup(document, raw, event, group);
}
currentDocument(document, raw);
if (additions.size === 0 && document.ownedFile && hookFileIsEmpty(document, raw)) {
return { document, changed: true, next: undefined };
}
return { document, changed: raw !== document.raw, next: raw };
}
function eventTarget(
event: ToolEvent,
sources: DroidSources,
createdRoot: () => DroidDocument,
): DroidDocument {
if (sources.primary && hasOwn(sources.primary.hooks, event)) return sources.primary;
if (sources.settings.hasHooksContainer && hasOwn(sources.settings.hooks, event)) {
return sources.settings;
}
if (sources.primary) return sources.primary;
if (sources.settings.hasHooksContainer) return sources.settings;
return createdRoot();
}
export function installationTargets(sources: DroidSources): InstallationTargets {
let root: DroidDocument | undefined;
const createdRoot = (): DroidDocument => {
root ??= createOwnedHookDocument(sources.rootPath);
return root;
};
const targets = new Map<ToolEvent, DroidDocument>();
for (const event of TOOL_EVENTS) targets.set(event, eventTarget(event, sources, createdRoot));
return { targets, createdRoot: root };
}
export function additionsFor(
document: DroidDocument,
targets: ReadonlyMap<ToolEvent, DroidDocument>,
groups: ReadonlyMap<ToolEvent, DroidGroup>,
): ReadonlyMap<ToolEvent, DroidGroup> {
const additions = new Map<ToolEvent, DroidGroup>();
for (const event of TOOL_EVENTS) {
if (targets.get(event) === document) additions.set(event, groups.get(event)!);
}
return additions;
}
+296
View File
@@ -0,0 +1,296 @@
import os from 'node:os';
import path from 'node:path';
export const AGENT_KEY = 'droid';
export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10;
export const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'] as const;
const EVENT_NAMES = new Set([
'PreToolUse',
'PostToolUse',
'Notification',
'UserPromptSubmit',
'Stop',
'SubagentStop',
'PreCompact',
'SessionStart',
'SessionEnd',
]);
const FLAG_NAMES = new Set(['hooksDisabled', 'showHookOutput']);
const HANDLER_KEYS = ['command', 'timeout', 'type'];
const GROUP_KEYS = ['hooks', 'matcher'];
export type JsonObject = Record<string, unknown>;
export type ToolEvent = typeof TOOL_EVENTS[number];
export interface DroidHandler extends JsonObject {
readonly type: 'command';
readonly command: string;
readonly timeout?: number;
}
export interface DroidGroup extends JsonObject {
readonly matcher?: string;
readonly commandRegex?: string;
readonly hooks: DroidHandler[];
}
export interface DroidHookSettings extends JsonObject {
readonly PreToolUse?: DroidGroup[];
readonly PostToolUse?: DroidGroup[];
readonly hooksDisabled?: boolean;
readonly showHookOutput?: boolean;
}
export interface RuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedRemoval {
readonly event: string;
readonly groupIndex: number;
readonly handlerIndexes: number[];
readonly removeGroup: boolean;
}
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
export function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function hasOwn(value: JsonObject, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quoteWindows(value: string): string {
return `"${value.replaceAll('"', '\\"')}"`;
}
export function buildCommand(scriptPath: string): string {
const quote = process.platform === 'win32' ? quoteWindows : quotePosix;
return `${quote(process.execPath)} ${quote(scriptPath)}`;
}
export function buildGroup(scriptPath: string): DroidGroup {
return {
matcher: '*',
hooks: [{
type: 'command',
command: buildCommand(scriptPath),
timeout: HOOK_TIMEOUT_SECONDS,
}],
};
}
function validateRegex(value: string, label: string, allowWildcard: boolean): void {
if (allowWildcard && (value === '*' || value === '')) return;
try {
new RegExp(value);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${label} must be a valid regular expression: ${message}`);
}
}
function validateHandler(
value: unknown,
label: string,
groupIndex: number,
handlerIndex: number,
): DroidHandler {
const location = `${label}[${groupIndex}].hooks[${handlerIndex}]`;
if (!isObject(value)) throw new Error(`${location} must be an object`);
if (value.type !== 'command') throw new Error(`${location} type must be "command"`);
if (typeof value.command !== 'string') throw new Error(`${location} command must be a string`);
if (value.timeout !== undefined
&& (typeof value.timeout !== 'number'
|| !Number.isFinite(value.timeout))) {
throw new Error(`${location} timeout must be a finite number`);
}
return value as DroidHandler;
}
function validateGroup(value: unknown, label: string, groupIndex: number): DroidGroup {
const location = `${label}[${groupIndex}]`;
if (!isObject(value)) throw new Error(`${location} must be an object`);
if (value.matcher !== undefined) {
if (typeof value.matcher !== 'string') throw new Error(`${location} matcher must be a string`);
validateRegex(value.matcher, `${location} matcher`, true);
}
if (value.commandRegex !== undefined) {
if (typeof value.commandRegex !== 'string') {
throw new Error(`${location} commandRegex must be a string`);
}
validateRegex(value.commandRegex, `${location} commandRegex`, false);
}
if (!Array.isArray(value.hooks)) throw new Error(`${location} must contain a hooks array`);
const handlers = value.hooks.map(
(handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex),
);
return { ...value, hooks: handlers } as DroidGroup;
}
export function readHookSettings(value: unknown, label: string): DroidHookSettings {
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
const settings: DroidHookSettings = { ...value };
for (const [key, item] of Object.entries(value)) {
if (FLAG_NAMES.has(key)) {
if (typeof item !== 'boolean') throw new Error(`${label} field "${key}" must be a boolean`);
continue;
}
if (!EVENT_NAMES.has(key)) throw new Error(`${label} contains unsupported field "${key}"`);
if (!Array.isArray(item)) throw new Error(`${label} field "${key}" must be an array`);
settings[key] = item.map(
(group, groupIndex) => validateGroup(group, `${label} field "${key}"`, groupIndex),
);
}
return settings;
}
function readWindowsArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== '"') return undefined;
let value = '';
for (let index = start + 1; index < command.length; index += 1) {
if (command[index] === '\\' && command[index + 1] === '"') {
value += '"';
index += 1;
continue;
}
if (command[index] === '"') return { value, next: index + 1 };
value += command[index];
}
return undefined;
}
const POSIX_APOSTROPHE = `'"'"'`;
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
let value = '';
for (let index = start + 1; index < command.length;) {
if (command.startsWith(POSIX_APOSTROPHE, index)) {
value += "'";
index += POSIX_APOSTROPHE.length;
continue;
}
if (command[index] === "'") return { value, next: index + 1 };
value += command[index];
index += 1;
}
return undefined;
}
function parseGeneratedCommand(command: string): readonly [string, string] | undefined {
const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument;
const executable = readArgument(command, 0);
if (!executable || command[executable.next] !== ' ') return undefined;
const script = readArgument(command, executable.next + 1);
if (!script || script.next !== command.length || !executable.value || !script.value) return undefined;
return [executable.value, script.value];
}
function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === 'win32'
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}
function sameAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
export function managedAgentId(handler: DroidHandler, scriptName: string): string | undefined {
if (Object.keys(handler).sort().join('\0') !== HANDLER_KEYS.join('\0')
|| handler.type !== 'command'
|| handler.timeout !== HOOK_TIMEOUT_SECONDS) return undefined;
const parsed = parseGeneratedCommand(handler.command);
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
const scriptPath = parsed[1];
if (path.basename(scriptPath) !== scriptName) return undefined;
const agentDirectory = path.dirname(scriptPath);
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined;
const agentId = path.basename(agentDirectory);
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
}
function exactOwnedGroup(group: DroidGroup, managedIndexes: number[]): boolean {
return Object.keys(group).sort().join('\0') === GROUP_KEYS.join('\0')
&& group.matcher === '*'
&& managedIndexes.length > 0
&& managedIndexes.length === group.hooks.length;
}
export function managedRemovals(
settings: DroidHookSettings,
agentId?: string,
): ManagedRemoval[] {
const removals: ManagedRemoval[] = [];
for (const [event, scriptName] of [
['PreToolUse', GUARD_SCRIPT],
['PostToolUse', AUDIT_SCRIPT],
] as const) {
const groups = settings[event] ?? [];
groups.forEach((group, groupIndex) => {
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
const managedId = managedAgentId(handler, scriptName);
return managedId && (agentId === undefined || sameAgentId(managedId, agentId))
? [handlerIndex]
: [];
});
if (handlerIndexes.length > 0) {
removals.push({
event,
groupIndex,
handlerIndexes,
removeGroup: exactOwnedGroup(group, handlerIndexes),
});
}
});
}
return removals;
}
function managedIds(groups: DroidGroup[], scriptName: string): Set<string> {
const ids = new Set<string>();
for (const group of groups) {
for (const handler of group.hooks) {
const agentId = managedAgentId(handler, scriptName);
if (agentId) ids.add(agentId);
}
}
return ids;
}
export function runtimeContracts(settings: DroidHookSettings): RuntimeContract[] {
const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT);
const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), '.elydora');
return [...guards]
.filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId)))
.map((agentId) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}
export function mergeHookSettings(
primary: DroidHookSettings | undefined,
fallback: DroidHookSettings | undefined,
): DroidHookSettings {
return { ...(fallback ?? {}), ...(primary ?? {}) };
}
+310
View File
@@ -0,0 +1,310 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
AGENT_KEY,
type JsonObject,
type RuntimeContract,
isObject,
} from './droid-contract.js';
import {
type DroidSources,
type RenderedDocument,
createSettingsDocument,
parseDocument,
} from './droid-config.js';
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly original?: string;
readonly next?: string;
}
interface StagedChange {
readonly change: FileChange;
readonly tempPath?: string;
readonly rollbackPath?: string;
committed: boolean;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function hasErrorCode(error: unknown, code: string): boolean {
return isObject(error) && error.code === code;
}
function factoryPaths(): {
readonly root: string;
readonly legacy: string;
readonly settings: string;
} {
const directory = path.join(os.homedir(), '.factory');
return {
root: path.join(directory, 'hooks.json'),
legacy: path.join(directory, 'hooks', 'hooks.json'),
settings: path.join(directory, 'settings.json'),
};
}
async function readOptional(filePath: string, label: string): Promise<string | undefined> {
try {
return await fsp.readFile(filePath, 'utf-8');
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return undefined;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
export async function readSources(): Promise<DroidSources> {
const paths = factoryPaths();
const [rootRaw, settingsRaw] = await Promise.all([
readOptional(paths.root, 'Factory Droid hooks'),
readOptional(paths.settings, 'Factory Droid settings'),
]);
let primary;
if (rootRaw !== undefined) {
primary = parseDocument({
exists: true,
filePath: paths.root,
kind: 'hooks',
raw: rootRaw,
});
} else {
const legacyRaw = await readOptional(paths.legacy, 'Factory Droid legacy hooks');
if (legacyRaw !== undefined) {
primary = parseDocument({
exists: true,
filePath: paths.legacy,
kind: 'legacy',
raw: legacyRaw,
});
}
}
const settings = settingsRaw === undefined
? createSettingsDocument(paths.settings)
: parseDocument({
exists: true,
filePath: paths.settings,
kind: 'settings',
raw: settingsRaw,
});
return { rootPath: paths.root, primary, settings };
}
function labelFor(rendered: RenderedDocument): string {
if (rendered.document.kind === 'settings') return 'Factory Droid settings';
if (rendered.document.kind === 'legacy') return 'Factory Droid legacy hooks';
return 'Factory Droid hooks';
}
function fileChange(rendered: RenderedDocument): FileChange | undefined {
if (!rendered.changed) return undefined;
return {
filePath: rendered.document.filePath,
label: labelFor(rendered),
original: rendered.document.exists ? rendered.document.raw : undefined,
next: rendered.next,
};
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasErrorCode(error, 'ENOENT')) throw error;
}
}
async function writeExclusive(filePath: string, contents: string, label: string): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', 0o600);
await handle.writeFile(contents, 'utf-8');
await handle.sync();
await handle.close();
} catch (error) {
const failures = [asError(error)];
if (handle) {
try {
await handle.close();
} catch (closeError) {
failures.push(asError(closeError));
}
}
try {
await unlinkOptional(filePath);
} catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) {
throw new AggregateError(failures, `Stage ${label} at ${filePath}`);
}
throw new Error(`Stage ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: failures[0],
});
}
}
async function assertUnchanged(change: FileChange): Promise<void> {
const current = await readOptional(change.filePath, change.label);
if (current !== change.original) {
throw new Error(`${change.label} changed during installation: ${change.filePath}`);
}
}
async function stage(change: FileChange): Promise<StagedChange> {
await assertUnchanged(change);
const directory = path.dirname(change.filePath);
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
const token = randomUUID();
const tempPath = change.next === undefined
? undefined
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
const rollbackPath = change.original === undefined
? undefined
: path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`);
try {
if (tempPath) await writeExclusive(tempPath, change.next!, change.label);
if (rollbackPath && change.next !== undefined) {
await writeExclusive(rollbackPath, change.original!, `${change.label} rollback`);
}
} catch (error) {
const failures = [asError(error)];
for (const stagedPath of [tempPath, rollbackPath]) {
if (!stagedPath) continue;
try {
await unlinkOptional(stagedPath);
} catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
throw error;
}
return { change, tempPath, rollbackPath, committed: false };
}
async function commit(staged: StagedChange): Promise<void> {
await assertUnchanged(staged.change);
if (staged.change.next === undefined) {
if (!staged.rollbackPath) throw new Error(`Missing rollback path for ${staged.change.label}`);
await fsp.rename(staged.change.filePath, staged.rollbackPath);
} else {
if (!staged.tempPath) throw new Error(`Missing staged file for ${staged.change.label}`);
await fsp.rename(staged.tempPath, staged.change.filePath);
}
staged.committed = true;
}
async function rollback(staged: StagedChange): Promise<void> {
if (!staged.committed) return;
if (staged.change.original === undefined) {
await unlinkOptional(staged.change.filePath);
return;
}
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
await fsp.rename(staged.rollbackPath, staged.change.filePath);
}
async function cleanup(staged: StagedChange): Promise<void> {
const paths = [staged.tempPath, staged.rollbackPath].filter(
(filePath): filePath is string => filePath !== undefined,
);
await Promise.all(paths.map(unlinkOptional));
}
export async function writeDocuments(rendered: RenderedDocument[]): Promise<void> {
const changes = rendered.flatMap((item) => {
const next = fileChange(item);
return next ? [next] : [];
});
if (changes.length === 0) return;
const staged: StagedChange[] = [];
try {
for (const change of changes) staged.push(await stage(change));
for (const item of staged) await commit(item);
} catch (error) {
const failures = [asError(error)];
for (const item of [...staged].reverse()) {
try {
await rollback(item);
} catch (rollbackError) {
failures.push(asError(rollbackError));
}
}
for (const item of staged) {
try {
await cleanup(item);
} catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1) throw new AggregateError(failures, 'Write Factory Droid hook sources');
throw new Error(`Write Factory Droid hook sources: ${errorMessage(error)}`, {
cause: failures[0],
});
}
await Promise.all(staged.map(cleanup));
}
export async function regularFileExists(filePath: string, label: string): Promise<boolean> {
try {
return (await fsp.stat(filePath)).isFile();
} catch (error) {
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
export async function requireRuntime(filePath: string, label: string): Promise<void> {
if (!filePath) throw new Error(`${label} path is required`);
if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`);
}
async function readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
const raw = await readOptional(filePath, 'Elydora runtime config');
if (raw === undefined) return undefined;
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!isObject(value)) throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`);
return value;
}
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
for (const contract of contracts) {
const agentDirectory = path.dirname(contract.guardPath);
const runtimeConfig = await readRuntimeConfig(path.join(agentDirectory, 'config.json'));
if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue;
const files = await Promise.all([
regularFileExists(contract.guardPath, 'Elydora guard runtime'),
regularFileExists(contract.auditPath, 'Elydora audit runtime'),
]);
if (files.every(Boolean)) return true;
}
return false;
}
export function displayConfigPath(sources: DroidSources): string {
if (sources.primary) return sources.primary.filePath;
if (sources.settings.exists && sources.settings.hasHooksContainer) return sources.settings.filePath;
return sources.rootPath;
}
+95
View File
@@ -0,0 +1,95 @@
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
TOOL_EVENTS,
buildGroup,
mergeHookSettings,
runtimeContracts,
type DroidGroup,
type ToolEvent,
} from './droid-contract.js';
import {
additionsFor,
installationTargets,
renderDocument,
type DroidDocument,
} from './droid-config.js';
import {
displayConfigPath,
readSources,
requireRuntime,
runtimeFilesExist,
writeDocuments,
} from './droid-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function uniqueDocuments(documents: Array<DroidDocument | undefined>): DroidDocument[] {
const unique = new Map<string, DroidDocument>();
for (const document of documents) {
if (document) unique.set(document.filePath, document);
}
return [...unique.values()];
}
export const droidPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
if (!config.agentId) throw new Error('agentId is required');
const sources = await readSources();
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
const selected = installationTargets(sources);
const groups = new Map<ToolEvent, DroidGroup>([
['PreToolUse', buildGroup(config.guardScriptPath)],
['PostToolUse', buildGroup(config.hookScriptPath)],
]);
const documents = uniqueDocuments([
sources.primary,
sources.settings.hasHooksContainer ? sources.settings : undefined,
...TOOL_EVENTS.map((event) => selected.targets.get(event)),
]);
const rendered = documents.map((document) => renderDocument(
document,
undefined,
additionsFor(document, selected.targets, groups),
));
await writeDocuments(rendered);
const locations = TOOL_EVENTS
.map((event) => `${event}: ${selected.targets.get(event)!.filePath}`)
.join(', ');
console.log(` Factory Droid: ${locations}`);
console.log(' Factory Droid: run /hooks to review the Elydora hook changes.');
},
async uninstall(agentId?: string): Promise<void> {
const sources = await readSources();
const documents = uniqueDocuments([
sources.primary,
sources.settings.hasHooksContainer ? sources.settings : undefined,
]);
const rendered = documents.map((document) => renderDocument(
document,
agentId,
new Map(),
));
await writeDocuments(rendered);
},
async status(): Promise<PluginStatus> {
const sources = await readSources();
const effective = mergeHookSettings(sources.primary?.hooks, sources.settings.hooks);
const contracts = runtimeContracts(effective);
const hookConfigured = effective.hooksDisabled !== true && contracts.length > 0;
const hookScriptExists = hookConfigured ? await runtimeFilesExist(contracts) : false;
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath: displayConfigPath(sources),
};
},
};
+1
View File
@@ -16,6 +16,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }],
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }],
]);