moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
/**
* EventAdapter — the per-tool seam that lets the four tool-agnostic
* lifecycle handler bodies (handlers-core.ts) read fields out of an
* opaque host payload without hardcoding key names.
*
* This generalizes what the frozen Wave 1 claude-code handlers do inline
* via `pickStringFromObject` multi-key fallbacks (e.g. stop.ts reads
* `response | assistant_message | transcript`). A tool package supplies
* one `EventAdapter`; the shared handler factories consume it.
*/
import type { ShimSource } from '@waggle/hive-mind-shim-core';
/** Canonical lifecycle actions, tool-agnostic. */
export type Lifecycle = 'session-start' | 'user-prompt-submit' | 'stop' | 'pre-compact';
/**
* Context handed to async field extractors. Cursor delivers the completed
* turn via a `transcript_path` to read off disk rather than inline, so
* `extractResponse` may need a file reader.
*/
export interface ExtractContext {
readFile?: (path: string) => Promise<string>;
}
export interface EventAdapter {
/** Tool id used for HookEvent.source + logger names. */
readonly source: ShimSource;
/**
* Map each canonical lifecycle name to the tool's native event key.
* `undefined` ⇒ the tool has no native event for that lifecycle
* (degraded; documented per spec §6.1).
*/
readonly eventName: Record<Lifecycle, string | undefined>;
/** Extract the working directory from the opaque payload. */
extractCwd(payload: unknown): string | undefined;
/** Extract the session/conversation id from the opaque payload. */
extractSessionId(payload: unknown): string | undefined;
/** Extract the user prompt text (UserPromptSubmit). */
extractPrompt(payload: unknown): string | undefined;
/**
* Extract the completed assistant turn (Stop). Async because some tools
* (cursor) deliver it via a `transcript_path` read off disk rather than
* inline. Must fail open — return undefined rather than throw.
*/
extractResponse(
payload: unknown,
ctx: ExtractContext,
): Promise<string | undefined> | string | undefined;
/** Extract a parent frame id to link a Stop frame to its prompt, if any. */
extractParent(payload: unknown): string | undefined;
/**
* Per-tool shape of the SessionStart inject response. `undefined` ⇒ the
* tool cannot inject context (save-only); the handler then emits nothing.
*/
formatInject?(additionalContext: string): unknown;
}

View File

@@ -0,0 +1,526 @@
/**
* Tool-agnostic lifecycle handler bodies + `HookHandler` factories.
*
* The four canonical lifecycle actions (recall+inject, save-temporary,
* summarize+save-important, compact_memory) mirror the frozen Wave 1
* claude-code hook bodies (`hooks/session-start.ts`, `user-prompt-submit.ts`,
* `stop.ts`, `pre-compact.ts`) but read fields through a per-tool
* `EventAdapter` instead of hardcoded key lists.
*
* Two drive paths share ONE set of bodies:
* 1. The stdin-JSON / exit-0 tools (codex, codex-desktop, cursor, hermes)
* use the `make*Handler` factories, which return `HookHandler` objects
* consumed by `runHook` (hook-shared.ts).
* 2. OpenClaw's in-process TypeScript handlers can't use `runHook`
* (in-process, not subprocess); `makeOpenclawHandler` wraps the SAME
* bodies for the in-process path.
*
* The bodies therefore take an ALREADY-EXTRACTED payload (not the raw host
* event) so both paths can drive them.
*
* Immutability: bodies build fresh HookFrame / event objects; they never
* mutate the bridge, adapter, or incoming payload.
*/
import {
classifyImportance,
encodeFrame,
maybeEmitDiscovery,
summarizeTurn,
type HookEvent,
type MemoryHit,
} from '@waggle/hive-mind-shim-core';
import type { EventAdapter, ExtractContext, Lifecycle } from './event-adapter.js';
import { recallPersonalAndWorkspace } from '@waggle/hive-mind-shim-core';
import type { HookContext, HookHandler } from './hook-shared.js';
// ── Extracted payloads (the shared bodies operate on these) ────────────
export interface SessionStartExtracted {
cwd: string;
sessionId: string | undefined;
recallLimit: number;
}
export interface UserPromptExtracted {
prompt: string;
cwd: string;
sessionId: string;
}
export interface StopExtracted {
cwd: string;
sessionId: string;
response: string;
parent: string | undefined;
}
export interface PreCompactExtracted {
scope: string | undefined;
}
// ── Defaults (mirror the CC bodies) ────────────────────────────────────
const DEFAULT_RECALL_LIMIT = 20;
const PER_HIT_CONTENT_BUDGET = 240;
const DEFAULT_SUMMARY_BUDGET_CHARS = 400;
export interface SessionStartOpts {
recallLimit?: number;
}
export interface StopOpts {
summaryBudgetChars?: number;
}
function formatHitsForContext(hits: readonly MemoryHit[]): string {
if (hits.length === 0) {
return 'hive-mind: no recalled frames for this workspace yet.';
}
const lines: string[] = [`hive-mind: top ${hits.length} recalled frames`];
for (const h of hits) {
const from = h.from && h.from !== 'personal' ? ` [${h.from}]` : '';
const content = h.content.length > PER_HIT_CONTENT_BUDGET
? h.content.slice(0, PER_HIT_CONTENT_BUDGET) + '…'
: h.content;
lines.push(`- (${h.importance})${from} ${h.created_at}: ${content}`);
}
return lines.join('\n');
}
/** Default SessionStart inject shape — the CC hookSpecificOutput convention. */
function defaultFormatInject(source: string, additionalContext: string): unknown {
return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
source,
additionalContext,
},
};
}
// ── Shared handler bodies (drive-path agnostic) ────────────────────────
/**
* SessionStart body — recall top-N frames from personal memory and format
* them for injection. Returns the tool-shaped inject object, or undefined
* when the adapter declares no inject seam (`formatInject` absent).
*/
export async function runSessionStartBody(
a: EventAdapter,
payload: SessionStartExtracted,
ctx: HookContext,
): Promise<unknown> {
ctx.logger.debug('recall starting', { limit: payload.recallLimit });
const hits = await recallPersonalAndWorkspace(ctx.bridge, '', {
limit: payload.recallLimit,
});
ctx.logger.debug('recall complete', { hits: hits.length });
const text = formatHitsForContext(hits);
if (a.formatInject) return a.formatInject(text);
return defaultFormatInject(a.source, text);
}
/**
* UserPromptSubmit body — persist the prompt as a temporary frame. No
* stdout output; pure side-effect on the .mind file.
*/
export async function runUserPromptBody(
a: EventAdapter,
payload: UserPromptExtracted,
ctx: HookContext,
): Promise<undefined> {
if (!payload.prompt) {
ctx.logger.debug('no prompt in payload, skipping save');
return undefined;
}
const event: HookEvent = {
eventType: 'user-prompt-submit',
source: a.source,
cwd: payload.cwd,
timestamp_iso: new Date().toISOString(),
payload: {
content: payload.prompt,
session_id: payload.sessionId,
},
};
const frame = encodeFrame(event, { importance: 'temporary' });
const result = await ctx.bridge.saveMemory(frame);
ctx.logger.debug('prompt frame saved', { id: result.id, scope: frame.scope });
return undefined;
}
/**
* Stop body — summarize the completed turn deterministically, classify
* importance, save an important/critical frame, and (opt-in via
* WAGGLE_SIGNAL_EMIT) emit a discovery signal. Mirrors CC stop.ts:46-108.
*/
export async function runStopBody(
a: EventAdapter,
payload: StopExtracted,
ctx: HookContext,
opts: StopOpts = {},
): Promise<undefined> {
if (!payload.response) {
ctx.logger.debug('no response in payload, skipping save');
return undefined;
}
const summary = summarizeTurn(payload.response, {
maxChars: opts.summaryBudgetChars ?? DEFAULT_SUMMARY_BUDGET_CHARS,
});
const rawImportance = classifyImportance(summary, { eventType: 'stop' });
const importance = rawImportance === 'critical' ? 'critical' : 'important';
const event: HookEvent = {
eventType: 'stop',
source: a.source,
cwd: payload.cwd,
timestamp_iso: new Date().toISOString(),
payload: {
content: summary,
session_id: payload.sessionId,
},
};
const encodeOpts: { importance: typeof importance; parent?: string } = { importance };
if (payload.parent !== undefined) encodeOpts.parent = payload.parent;
const frame = encodeFrame(event, encodeOpts);
const result = await ctx.bridge.saveMemory(frame);
ctx.logger.debug('stop frame saved', {
id: result.id,
importance: frame.importance,
bytes: summary.length,
});
// Opt-in v2 signal emission. Off by default so OSS consumers see no
// behavior change; flip WAGGLE_SIGNAL_EMIT to broadcast high/critical
// stops to the local Waggle sidecar. Fails open (never throws).
const emitFlag = process.env.WAGGLE_SIGNAL_EMIT;
if (emitFlag && emitFlag !== '0' && emitFlag.toLowerCase() !== 'false') {
const emitImportance =
rawImportance === 'critical' ? 'critical'
: rawImportance === 'important' ? 'high'
: rawImportance === 'normal' ? 'normal'
: 'low';
const emitted = await maybeEmitDiscovery(
'stop',
emitImportance,
{
tool: a.source,
sessionId: payload.sessionId,
topic: summary.slice(0, 160),
summary,
frameId: result.id,
memoryWorkspace: result.workspace,
cwd: payload.cwd,
},
{ senderId: `${a.source}-hook` },
);
if (emitted) ctx.logger.debug('stop signal emitted', { id: emitted.id });
}
return undefined;
}
/**
* PreCompact body — trigger upstream frame compaction maintenance before
* the host truncates context. Mirrors CC pre-compact.ts.
*/
export async function runPreCompactBody(
_a: EventAdapter,
payload: PreCompactExtracted,
ctx: HookContext,
): Promise<undefined> {
const result = await ctx.bridge.cleanupFrames();
ctx.logger.debug('cleanup_frames done', { pruned: result.pruned, scope: payload.scope });
return undefined;
}
// ── Extraction (raw host payload → extracted payload) ──────────────────
function extractSessionStart(a: EventAdapter, raw: unknown, recallLimit: number): SessionStartExtracted {
return {
cwd: a.extractCwd(raw) ?? process.cwd(),
sessionId: a.extractSessionId(raw),
recallLimit,
};
}
function resolveRecallLimit(raw: unknown, fallback: number): number {
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const val = obj['recall_limit'] ?? obj['recallLimit'];
if (typeof val === 'number' && val > 0) return Math.floor(val);
}
return fallback;
}
function extractUserPrompt(a: EventAdapter, raw: unknown): UserPromptExtracted {
return {
prompt: a.extractPrompt(raw) ?? '',
cwd: a.extractCwd(raw) ?? process.cwd(),
sessionId: a.extractSessionId(raw) ?? 'default',
};
}
async function extractStop(a: EventAdapter, raw: unknown, ctx: ExtractContext): Promise<StopExtracted> {
const response = (await a.extractResponse(raw, ctx)) ?? '';
return {
cwd: a.extractCwd(raw) ?? process.cwd(),
sessionId: a.extractSessionId(raw) ?? 'default',
response,
parent: a.extractParent(raw),
};
}
function extractPreCompact(a: EventAdapter, raw: unknown): PreCompactExtracted {
return { scope: a.extractSessionId(raw) };
}
// ── HookHandler factories (stdin-JSON / exit-0 drive path) ─────────────
/**
* SessionStart `HookHandler` for the `runHook` subprocess path.
* `recallLimit` resolves from the payload (`recall_limit`/`recallLimit`)
* else `opts.recallLimit` else 20.
*/
export function makeSessionStartHandler(
a: EventAdapter,
opts: SessionStartOpts = {},
): HookHandler<SessionStartExtracted, unknown> {
const fallbackLimit = opts.recallLimit ?? DEFAULT_RECALL_LIMIT;
return {
parse(raw): SessionStartExtracted {
const limit = resolveRecallLimit(raw, fallbackLimit);
return extractSessionStart(a, raw, limit);
},
run(payload, ctx): Promise<unknown> {
return runSessionStartBody(a, payload, ctx) as Promise<unknown>;
},
};
}
export function makeUserPromptSubmitHandler(
a: EventAdapter,
): HookHandler<UserPromptExtracted, undefined> {
return {
parse(raw): UserPromptExtracted {
return extractUserPrompt(a, raw);
},
run(payload, ctx): Promise<undefined> {
return runUserPromptBody(a, payload, ctx);
},
};
}
/**
* Stop's `HookHandler.parse` is synchronous, but Stop extraction can be
* async (cursor reads the completed turn off `transcript_path`). So the
* parsed value carries the raw payload, and `run` performs the async
* response extraction. A typed wrapper avoids any intersection abuse.
*/
export interface StopParsed {
raw: unknown;
}
export function makeStopHandler(
a: EventAdapter,
opts: StopOpts = {},
): HookHandler<StopParsed, undefined> {
return {
parse(raw): StopParsed {
return { raw };
},
async run(payload, ctx): Promise<undefined> {
const resolved = await extractStop(a, payload.raw, {});
return runStopBody(a, resolved, ctx, opts);
},
};
}
export function makePreCompactHandler(
a: EventAdapter,
): HookHandler<PreCompactExtracted, undefined> {
return {
parse(raw): PreCompactExtracted {
return extractPreCompact(a, raw);
},
run(payload, ctx): Promise<undefined> {
return runPreCompactBody(a, payload, ctx);
},
};
}
// ── OpenClaw in-process handler factory (spec §5.5) ────────────────────
/**
* Minimal shape of OpenClaw's `InternalHookEvent` the in-process handler
* needs. The gateway delivers `(type, action)` plus an opaque `context`
* carrying cwd / prompt / response / session id. Matching is on the
* `(type, action)` pair, NOT the joined string (e.g. PreCompact fires on
* action `'compact:before'`, not `'session:compact:before'`).
*/
export interface InternalHookEventLike {
type: string;
action: string;
sessionKey?: string;
context?: unknown;
timestamp?: unknown;
messages?: unknown;
}
/**
* Map an OpenClaw `(type, action)` pair to a canonical lifecycle, using
* the adapter's eventName map (each value is the OpenClaw `type:action`
* native key, e.g. 'agent:bootstrap', and PreCompact is matched specially
* on action 'compact:before').
*/
function lifecycleForOpenclawEvent(
a: EventAdapter,
ev: InternalHookEventLike,
): Lifecycle | undefined {
const joined = `${ev.type}:${ev.action}`;
for (const lc of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as Lifecycle[]) {
const native = a.eventName[lc];
if (native === undefined) continue;
if (native === joined) return lc;
// PreCompact ONLY: the runtime action is 'compact:before' while the
// HOOK.md events[] entry is 'session:compact:before'. Accept a match on
// the action suffix exclusively for pre-compact — applying it to every
// lifecycle would mis-map an unrelated type whose action suffix collides
// (e.g. type='gateway' action='sent' must NOT map to stop's 'message:sent').
if (lc === 'pre-compact' && native.endsWith(`:${ev.action}`) && ev.type !== '') return lc;
}
return undefined;
}
export interface OpenclawHandlerOptions {
/** Stop debounce window (ms) for the 0..N `message:sent` per turn. */
stopDebounceMs?: number;
/** Stop summary budget. */
summaryBudgetChars?: number;
/** SessionStart recall limit. */
recallLimit?: number;
}
export interface OpenclawHandler {
/**
* In-process entrypoint. Receives an already-extracted payload object
* (the OpenClaw `handler.ts` adapter extracts `event.context` into the
* shared extracted-payload shapes before calling) plus the lifecycle.
* Shells to hive-mind-cli via the supplied bridge. FAIL-OPEN: wraps the
* body in try/catch and NEVER throws — the returned promise always
* resolves.
*/
handle(input: OpenclawHandlerInput, ctx: HookContext): Promise<void>;
}
/**
* The in-process driver input: an OpenClaw event-shaped object plus the
* already-extracted lifecycle payload. The shared bodies run on the
* extracted payload; the event is carried for lifecycle resolution +
* debounce keying.
*/
export interface OpenclawHandlerInput {
event: InternalHookEventLike;
/** Already-extracted payload, shaped per the resolved lifecycle. */
extracted:
| SessionStartExtracted
| UserPromptExtracted
| StopExtracted
| PreCompactExtracted;
}
/**
* Build an OpenClaw in-process handler that drives the SAME shared bodies
* the subprocess path uses. The host (`triggerInternalHook`) already wraps
* each handler in try/catch, but per spec §7.3 invariant 1 the openclaw
* fail-open contract is "the default-exported handler must not throw" — so
* we wrap the body here too and never rely on the host's catch as the only
* safety net.
*/
export function makeOpenclawHandler(
a: EventAdapter,
opts: OpenclawHandlerOptions = {},
): OpenclawHandler {
// Debounce state for message:sent (0..N per turn): keep only the last
// outbound payload of a turn by deferring the save behind a short timer.
const stopTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; resolve: () => void }>();
const debounceMs = opts.stopDebounceMs ?? 0;
async function dispatch(input: OpenclawHandlerInput, ctx: HookContext): Promise<void> {
const lifecycle = lifecycleForOpenclawEvent(a, input.event);
if (lifecycle === undefined) {
ctx.logger.debug('openclaw event ignored (no lifecycle match)', {
type: input.event.type,
action: input.event.action,
});
return;
}
switch (lifecycle) {
case 'session-start':
// Recall+inject: the OpenClaw adapter mutates bootstrapFiles with
// the returned text; the body returns it.
await runSessionStartBody(a, input.extracted as SessionStartExtracted, ctx);
return;
case 'user-prompt-submit':
await runUserPromptBody(a, input.extracted as UserPromptExtracted, ctx);
return;
case 'stop': {
const stopPayload = input.extracted as StopExtracted;
if (debounceMs <= 0) {
await runStopBody(a, stopPayload, ctx, { summaryBudgetChars: opts.summaryBudgetChars });
return;
}
// Debounce: keep only the last message:sent of a turn.
const key = input.event.sessionKey ?? stopPayload.sessionId;
const existing = stopTimers.get(key);
if (existing) {
// Supersede the prior message:sent of this turn (last-writer-wins).
// Cancel its pending save AND resolve its awaiting handle() so the
// host's sequential await of the superseded dispatch never hangs
// (fail-open invariant §7.3.1 — a hook must never block the host).
clearTimeout(existing.timer);
existing.resolve();
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
stopTimers.delete(key);
// FAIL-OPEN §7.3.1: this save is DETACHED (fires after dispatch's
// try/catch has returned), so a rejection here would escape as an
// unhandled rejection. Catch it explicitly before resolving.
void runStopBody(a, stopPayload, ctx, {
summaryBudgetChars: opts.summaryBudgetChars,
})
.catch((err) => {
ctx.logger.warn('openclaw debounced stop save failed open', {
error: err instanceof Error ? err.message : String(err),
});
})
.finally(() => resolve());
}, debounceMs);
stopTimers.set(key, { timer, resolve });
});
return;
}
case 'pre-compact':
await runPreCompactBody(a, input.extracted as PreCompactExtracted, ctx);
return;
}
}
return {
async handle(input, ctx): Promise<void> {
try {
await dispatch(input, ctx);
} catch (err) {
// FAIL-OPEN: swallow — never throw to the host event loop.
ctx.logger.warn('openclaw hook failed open', {
type: input.event.type,
action: input.event.action,
error: err instanceof Error ? err.message : String(err),
});
}
},
};
}

View File

@@ -0,0 +1,171 @@
/**
* Shared subprocess plumbing for the stdin-JSON / exit-0 lifecycle hook
* scripts (codex, codex-desktop, cursor, hermes).
*
* Re-authored from the frozen Wave 1 `hooks/_shared.ts` so the five new
* packages import `runHook`/`parseHookArgs` from `hooks-core` and never
* reach across the frozen claude-code package boundary. The logger name
* prefix is generic (derived from `opts.name`) rather than hardcoded to
* `claude-code-hooks`.
*
* Hook scripts run as short-lived Node subprocesses spawned by the host
* tool. The contract:
* - stdin : the host writes a JSON event payload (may be empty on
* some events).
* - stdout : structured JSON for hooks that influence host behaviour
* (SessionStart context injection, etc.).
* - stderr : structured logger output (never blocks the host).
* - exit code : 0 always — silent capture must not break the host.
*
* On any internal error, hooks log the error and exit 0. The `runHook`
* helper wraps the supplied hook body with this fail-open contract.
*/
import {
createCliBridge,
createLogger,
type CliBridge,
type CliBridgeOptions,
type Logger,
} from '@waggle/hive-mind-shim-core';
export interface HookContext {
bridge: CliBridge;
logger: Logger;
}
export interface HookRunOptions {
/** Component name for the logger (e.g. 'session-start'). */
name: string;
/**
* Logger name prefix; defaults to 'hive-mind-hooks'. Consumers can set
* their own tool-scoped prefix (e.g. 'codex-hooks') so log lines read
* like the reference package.
*/
loggerPrefix?: string;
/** Stdin reader override for tests. */
readStdin?: () => Promise<string>;
/** Stdout writer override for tests. */
writeStdout?: (s: string) => void;
/** Override exit; tests provide a no-op so they don't terminate vitest. */
exit?: (code: number) => void;
/** Logger override. */
logger?: Logger;
/** Bridge override (tests inject a mock with a fake spawnImpl). */
bridge?: CliBridge;
/** Override argv for tests; defaults to `process.argv.slice(2)`. */
argv?: readonly string[];
}
const DEFAULT_LOGGER_PREFIX = 'hive-mind-hooks';
/**
* Parse `--cli-path <value>` from argv. Used by hook scripts to thread
* the install-time-pinned CLI binary path into createCliBridge so a
* single hook script works on POSIX (where `hive-mind-cli` is on PATH)
* and on Windows (where the npm `.cmd` shim cannot be exec'd directly).
*/
export function parseHookArgs(argv: readonly string[]): { cliPath?: string } {
const idx = argv.indexOf('--cli-path');
if (idx >= 0 && idx + 1 < argv.length) {
const value = argv[idx + 1];
if (typeof value === 'string' && value.length > 0) return { cliPath: value };
}
return {};
}
export interface HookHandler<TPayload = unknown, TStdoutPayload = unknown> {
parse(raw: unknown): TPayload;
run(payload: TPayload, ctx: HookContext): Promise<TStdoutPayload | undefined>;
}
const STDIN_READ_TIMEOUT_MS = 2000;
export async function readStdinAsString(
timeoutMs: number = STDIN_READ_TIMEOUT_MS,
): Promise<string> {
if (process.stdin.isTTY) return '';
return new Promise<string>((resolve) => {
const chunks: Buffer[] = [];
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks).toString('utf-8'));
}, timeoutMs);
process.stdin.on('data', (c: Buffer) => chunks.push(c));
process.stdin.on('end', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf-8'));
});
process.stdin.on('error', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf-8'));
});
});
}
export function safeJsonParse(raw: string): unknown {
if (!raw || raw.trim().length === 0) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
export async function runHook<TPayload, TStdoutPayload>(
handler: HookHandler<TPayload, TStdoutPayload>,
opts: HookRunOptions,
): Promise<void> {
const prefix = opts.loggerPrefix ?? DEFAULT_LOGGER_PREFIX;
const logger = opts.logger ?? createLogger({ name: `${prefix}/${opts.name}` });
const writeStdout = opts.writeStdout ?? ((s: string) => process.stdout.write(s));
const exit = opts.exit ?? ((c: number): void => { process.exit(c); });
const reader = opts.readStdin ?? readStdinAsString;
const argv = opts.argv ?? process.argv.slice(2);
const argvFlags = parseHookArgs(argv);
const bridgeOpts: CliBridgeOptions = { logger };
if (argvFlags.cliPath !== undefined) bridgeOpts.cli_path = argvFlags.cliPath;
const bridge = opts.bridge ?? createCliBridge(bridgeOpts);
try {
const raw = await reader();
const parsed = safeJsonParse(raw);
const payload = handler.parse(parsed);
const out = await handler.run(payload, { bridge, logger });
if (out !== undefined) {
writeStdout(JSON.stringify(out) + '\n');
}
exit(0);
} catch (err) {
logger.warn('hook failed open', {
hook: opts.name,
error: err instanceof Error ? err.message : String(err),
});
exit(0);
}
}
/**
* Best-effort accessor for nested string fields on opaque payloads.
* Returns undefined when the key path doesn't resolve to a non-empty string.
*/
export function pickStringField(payload: unknown, ...keys: string[]): string | undefined {
if (!payload || typeof payload !== 'object') return undefined;
const obj = payload as Record<string, unknown>;
for (const key of keys) {
const value = obj[key];
if (typeof value === 'string' && value.length > 0) return value;
}
return undefined;
}
export function pickStringFromObject(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

View File

@@ -0,0 +1,29 @@
/**
* @waggle/hive-mind-hooks-core — public barrel.
*
* Tool-agnostic foundation for the hive-mind lifecycle-hook ports
* (codex / codex-desktop / cursor / hermes / openclaw). Generalizes the
* reversible-install primitives, the EventAdapter-parameterized lifecycle
* handler bodies, and the jsonRegister merge helper proven by the Wave-1
* @waggle/hive-mind-hooks-claude-code reference. Codec-agnostic: YAML/JSON5
* parsers live in the consumer packages, not here.
*/
// Per-tool event mapping + field extraction contract.
export * from './event-adapter.js';
// Filesystem path + command-string helpers (backup paths, cli-path quoting).
export * from './paths-core.js';
// Reversible-install primitives (byte-identical backup, pointer round-trip,
// create-if-missing + verified restore/uninstall).
export * from './install-core.js';
// Additive, marker-tagged, immutable JSON config merge for JSON-config tools.
export * from './json-register.js';
// Shared lifecycle handler bodies + factories (stdin/exit-0 + openclaw in-process).
export * from './handlers-core.js';
// Fail-open hook runner + stdin/argv helpers (re-authored from the CC _shared.ts).
export * from './hook-shared.js';

View File

@@ -0,0 +1,190 @@
/**
* Tool-agnostic reversible-install primitives.
*
* Generalizes the frozen Wave 1 claude-code install/uninstall logic with
* a CREATE-IF-MISSING mode the CC reference lacks: CC assumes the config
* file must pre-exist and throws if absent (install.ts:85-90). Codex /
* cursor / hermes / openclaw config files are optional and may not exist
* on a fresh machine, so install must be able to create a minimal
* skeleton and uninstall must DELETE-IF-WE-CREATED-IT vs
* RESTORE-BACKUP-IF-IT-EXISTED.
*
* Immutability contract: helpers here never mutate their inputs; the
* pointer object passed to `writePointer` is serialized as-is, and
* `restoreFromBackup` reads the pointer and acts on it without mutation.
*/
import { readFile, writeFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { backupPathFor } from './paths-core.js';
/**
* Pointer file recording what an install did, so uninstall is exact.
* Superset of the CC pointer: adds `created_by_us` (true when the config
* file did not pre-exist) so uninstall can delete vs restore.
*/
export interface InstallPointer {
version: string;
installed_at: string;
config_path: string;
/** null ⇔ created_by_us=true (no backup written — nothing to restore). */
settings_backup: string | null;
/** true if the config file did NOT pre-exist and we created it. */
created_by_us: boolean;
/** null for in-process tools (openclaw) that ship no hook-script dir. */
hooks_dir: string | null;
installed_hooks: readonly string[];
cli_path: string | null;
/** per-tool: e.g. openclaw hook dir names, hermes registered event keys. */
extra?: Record<string, unknown>;
}
export interface BackupResult {
/** Path the byte-identical backup was written to, or null if absent. */
backupPath: string | null;
/** Whether the config file existed at backup time. */
preExisted: boolean;
}
/**
* Write a byte-identical timestamped backup of an existing config file.
*
* preExisted=false → no backup written; caller records
* created_by_us=true in the pointer.
* preExisted=true → backup written with the EXACT original bytes
* (CC install.ts:104-106 idiom), so uninstall can
* restore the true pre-install state byte-for-byte
* even when the config codec round-trip is lossy
* (hermes YAML, openclaw JSON5).
*/
export async function backupByteIdentical(
configPath: string,
isoTimestamp: string,
): Promise<BackupResult> {
if (!existsSync(configPath)) {
return { backupPath: null, preExisted: false };
}
const originalBytes = await readFile(configPath);
const backupPath = backupPathFor(configPath, isoTimestamp);
await writeFile(backupPath, originalBytes);
return { backupPath, preExisted: true };
}
export async function writePointer(pointerPath: string, pointer: InstallPointer): Promise<void> {
await writeFile(pointerPath, JSON.stringify(pointer, null, 2) + '\n', 'utf-8');
}
function isInstallPointer(value: unknown): value is InstallPointer {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
typeof v['config_path'] === 'string' &&
typeof v['created_by_us'] === 'boolean' &&
(v['settings_backup'] === null || typeof v['settings_backup'] === 'string') &&
Array.isArray(v['installed_hooks'])
);
}
/** Read + validate an install pointer. Throws if absent or malformed. */
export async function readPointer(pointerPath: string): Promise<InstallPointer> {
if (!existsSync(pointerPath)) {
throw new Error(`no install pointer found at ${pointerPath}.`);
}
const raw = await readFile(pointerPath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(
`install pointer at ${pointerPath} is malformed: ` +
(err instanceof Error ? err.message : String(err)),
);
}
if (!isInstallPointer(parsed)) {
throw new Error(`install pointer at ${pointerPath} is malformed`);
}
return parsed;
}
export interface RestoreResult {
/** The backup we restored from, or null when we deleted a file we created. */
restoredFrom: string | null;
/** True when created_by_us=true and we removed the config file we created. */
createdRemoved: boolean;
/** True when the backup file was cleaned up after a successful restore. */
backupRemoved: boolean;
}
/**
* Round-trip-verified restore. Mirrors CC uninstall.ts:71-89.
*
* created_by_us=false → restore the backup byte-identically: write the
* backup bytes over configPath, re-read, assert
* byte equality, and REFUSE to delete the backup
* unless the readback matches.
* created_by_us=true → DELETE the config file we created (never orphan
* it); restoredFrom=null.
*
* Never mutates the pointer.
*/
export async function restoreFromBackup(args: {
configPath: string;
pointer: InstallPointer;
/** Clean up the backup file after a verified restore. Default true. */
cleanupBackup?: boolean;
/**
* Test seam: override fs read/write to exercise the round-trip
* verification-failure branch (a write→read mismatch is otherwise
* impossible to provoke on a real filesystem). Defaults to node:fs/promises.
*/
io?: {
readFile?: (p: string) => Promise<Buffer>;
writeFile?: (p: string, data: Buffer) => Promise<void>;
};
}): Promise<RestoreResult> {
const { configPath, pointer } = args;
const cleanup = args.cleanupBackup ?? true;
const rf = args.io?.readFile ?? ((p: string): Promise<Buffer> => readFile(p));
const wf = args.io?.writeFile ?? ((p: string, data: Buffer): Promise<void> => writeFile(p, data));
// Branch A: we created the config file — delete it, never orphan it.
if (pointer.created_by_us) {
if (existsSync(configPath)) {
await unlink(configPath);
}
return { restoredFrom: null, createdRemoved: true, backupRemoved: false };
}
// Branch B: config pre-existed — restore the byte-identical backup.
if (pointer.settings_backup === null) {
throw new Error(
`pointer for ${configPath} has created_by_us=false but no settings_backup; ` +
`cannot restore.`,
);
}
if (!existsSync(pointer.settings_backup)) {
throw new Error(
`backup file referenced by the pointer is missing: ${pointer.settings_backup}`,
);
}
const backupBytes = await rf(pointer.settings_backup);
await wf(configPath, backupBytes);
// Round-trip verification: read what we just wrote and compare bytes.
const verifyBytes = await rf(configPath);
if (!backupBytes.equals(verifyBytes)) {
throw new Error(
`uninstall verification failed: ${configPath} content differs from backup ` +
`${pointer.settings_backup}. Backup was NOT removed; restore manually if needed.`,
);
}
let backupRemoved = false;
if (cleanup) {
await unlink(pointer.settings_backup);
backupRemoved = true;
}
return { restoredFrom: pointer.settings_backup, createdRemoved: false, backupRemoved };
}

View File

@@ -0,0 +1,142 @@
/**
* Generalized additive JSON-config register/unregister helper.
*
* Generalizes the frozen Wave 1 claude-code `mergeHiveHooks`
* (settings-merger.ts:72-101) for any JSON-config tool whose event keys
* map to ARRAYS of hook groups (codex `{matcher,hooks:[]}`, cursor
* `{command,type,timeout}`). Additive merge + marker tag + dedup /
* replace-in-place.
*
* Immutability contract: every function returns a NEW object and never
* mutates its input (mirrors the CC settings-merger contract). Used by
* codex, codex-desktop (via codex), and cursor. Hermes (YAML) and
* OpenClaw (JSON5 + dirs) have bespoke codecs and do NOT use this.
*/
import type { Lifecycle } from './event-adapter.js';
/** Base marker; per-tool suffix appended, e.g. '@hive-mind/codex-hooks'. */
export const HIVE_MIND_MARKER_BASE = '@hive-mind';
export interface JsonRegisterEntry {
lifecycle: Lifecycle;
command: string;
timeout: number;
}
export interface JsonRegisterSpec {
/** Top-level object key holding the per-event map (e.g. 'hooks'). */
hooksKey: string;
/** Canonical lifecycle → tool event-key map (from EventAdapter.eventName). */
eventName: Record<Lifecycle, string | undefined>;
/**
* Build the tool-shaped group object for one hook entry. Must stamp the
* marker so `isHiveGroup` can later detect our own entries for
* replace/remove (codex uses {matcher,hooks:[...]}; cursor uses a flat
* {command,type,timeout}).
*/
buildGroup(lifecycle: Lifecycle, command: string, timeout: number): Record<string, unknown>;
/** Reads the marker off a group to detect our own entries. */
isHiveGroup(group: unknown): boolean;
/**
* Reads the command string out of a group so dedup can match by
* (eventKey, command). Returns undefined when the group has no command.
*/
groupCommand(group: unknown): string | undefined;
/** Optional skeleton seed (e.g. cursor needs {version:1}). */
ensureSkeleton?(root: Record<string, unknown>): Record<string, unknown>;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function asGroupArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
}
/**
* Returns a NEW config object with hive-mind hook entries registered
* under each tool event key. Existing non-hive entries are preserved
* verbatim. If a hive entry for the same (eventKey, command) already
* exists it is replaced in place rather than duplicated — supports
* re-running install for upgrades.
*/
export function jsonRegister(
config: Record<string, unknown> | undefined,
entries: readonly JsonRegisterEntry[],
spec: JsonRegisterSpec,
): Record<string, unknown> {
// Shallow-copy the root, then apply the optional skeleton seed.
let next: Record<string, unknown> = config ? { ...config } : {};
if (spec.ensureSkeleton) next = spec.ensureSkeleton(next);
const existingHooks = asRecord(next[spec.hooksKey]);
const nextHooks: Record<string, unknown> = existingHooks ? { ...existingHooks } : {};
for (const entry of entries) {
const eventKey = spec.eventName[entry.lifecycle];
if (eventKey === undefined) continue; // tool has no native event for this lifecycle
const existingArr = asGroupArray(nextHooks[eventKey]).slice();
const newGroup = spec.buildGroup(entry.lifecycle, entry.command, entry.timeout);
let replaced = false;
for (let i = 0; i < existingArr.length; i += 1) {
const g = existingArr[i];
if (spec.isHiveGroup(g) && spec.groupCommand(g) === entry.command) {
existingArr[i] = newGroup;
replaced = true;
break;
}
}
if (!replaced) existingArr.push(newGroup);
nextHooks[eventKey] = existingArr;
}
next[spec.hooksKey] = nextHooks;
return next;
}
/**
* Returns a NEW config object with all marker-tagged hive groups stripped
* from every event array. Non-hive entries are preserved verbatim. Empty
* event arrays are left in place (minimal-touch). Never mutates input.
*/
export function jsonUnregister(
config: Record<string, unknown> | undefined,
spec: JsonRegisterSpec,
): Record<string, unknown> {
const next: Record<string, unknown> = config ? { ...config } : {};
const existingHooks = asRecord(next[spec.hooksKey]);
if (!existingHooks) return next;
const nextHooks: Record<string, unknown> = {};
for (const [eventKey, value] of Object.entries(existingHooks)) {
const arr = asGroupArray(value);
if (arr.length === 0) {
nextHooks[eventKey] = value;
continue;
}
nextHooks[eventKey] = arr.filter((g) => !spec.isHiveGroup(g));
}
next[spec.hooksKey] = nextHooks;
return next;
}
/** True iff at least one event array carries a marker-tagged hive group. */
export function hasHiveEntries(
config: Record<string, unknown> | undefined,
spec: JsonRegisterSpec,
): boolean {
if (!config) return false;
const hooks = asRecord(config[spec.hooksKey]);
if (!hooks) return false;
for (const value of Object.values(hooks)) {
if (asGroupArray(value).some((g) => spec.isHiveGroup(g))) return true;
}
return false;
}

View File

@@ -0,0 +1,81 @@
/**
* Tool-agnostic filesystem path helpers shared by every hook port.
*
* Mirrors the frozen Wave 1 claude-code `paths.ts` idioms (Windows-safe
* backup paths + `--cli-path` quoting) but parameterized so each tool
* package can plug in its own config root, pointer name, and hook script
* basenames.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
/**
* Filesystem-safe backup path for a config file at `configPath`.
* Mirrors claude-code paths.ts:86 — replaces `:` and `.` in the ISO
* timestamp with `-` so the filename is valid on Windows.
*/
export function backupPathFor(configPath: string, isoTimestamp: string): string {
const stamp = isoTimestamp.replace(/[:.]/g, '-');
return `${configPath}.hive-mind-backup.${stamp}`;
}
/**
* Build the host-tool hook command string for a compiled hook script.
* Mirrors claude-code paths.ts:74-84 — quotes paths so spaces in the
* user's home dir (Windows: "C:\Users\Marko Markovic\") don't fragment
* the command, and routes through `node` so `.js` scripts run
* cross-platform without a shell launching an npm `.cmd` bin shim.
*
* @param scriptPath absolute path to the compiled `dist/hooks/<event>.js`.
* @param cliPath optional install-time-pinned hive-mind-cli path,
* threaded as `--cli-path "<value>"`.
*/
export function hookCommandFor(scriptPath: string, cliPath?: string): string {
const cliFlag = cliPath && cliPath.length > 0 ? ` --cli-path "${cliPath}"` : '';
return `${hookNodeCommand()} "${scriptPath}"${cliFlag}`;
}
function hookNodeCommand(): string {
const configured = process.env.WAGGLE_HOOK_NODE_PATH?.trim();
if (!configured) return 'node';
if (configured.includes('"')) throw new Error('WAGGLE_HOOK_NODE_PATH cannot contain double quotes');
return `"${configured}"`;
}
/**
* Reject `--cli-path` values containing embedded double-quotes — they
* would break the `--cli-path "<value>"` quoting in the generated hook
* command. Lifted verbatim from claude-code install.ts:142-154.
*
* Returns undefined for undefined / empty / whitespace-only input.
* Throws on a double-quote so the caller fails loudly at install time.
*/
export function normalizeCliPath(input: string | undefined): string | undefined {
if (input === undefined) return undefined;
const trimmed = input.trim();
if (trimmed.length === 0) return undefined;
if (trimmed.includes('"')) {
throw new Error(
`--cli-path value must not contain double-quote characters; got: ${trimmed.slice(0, 80)}`,
);
}
return trimmed;
}
/**
* Resolve a package's compiled `dist/hooks/` directory from the caller's
* `import.meta.url`. The compiled install module lives at
* `<pkg>/dist/<file>.js`; dirname gives `<pkg>/dist/`, join gives
* `<pkg>/dist/hooks/`. Resolved once more so the path is absolute and
* platform-normalized.
*/
export function hooksDirFromModuleUrl(moduleUrl: string): string {
const dir = dirname(fileURLToPath(moduleUrl));
return resolve(dir, 'hooks');
}
/** Absolute path to a compiled hook script `<hooksDir>/<basename>.js`. */
export function hookScriptPath(hooksDir: string, basename: string): string {
return join(hooksDir, `${basename}.js`);
}