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,19 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 Egzakta Group d.o.o. · waggle-os.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt

View File

@@ -0,0 +1,104 @@
# @waggle/hive-mind-hooks-openclaw
Silent-capture shim that wires **OpenClaw** (`openclaw/openclaw`) gateway
lifecycle hooks into [hive-mind](https://github.com/marolinik/hive-mind)
frames. Every OpenClaw conversation deterministically captures
bootstrap / inbound-message / outbound-message events into your personal memory
via `hive-mind-cli` — the same every-turn pattern proven by
`@waggle/hive-mind-hooks-claude-code`, built on the shared
`@waggle/hive-mind-hooks-core` foundation.
OpenClaw is the exception in the hook portfolio: its hooks are **in-process
TypeScript**, not stdin-JSON subprocesses. A hook is a **directory**
`~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}` that the gateway discovers
and dynamically `import()`s, running the default export inside its own Node
event loop. So the installer writes that managed directory and **minimally
touches** the JSON5 config (`~/.openclaw/openclaw.json`) — it does **not**
re-serialize the whole config (that would destroy your comments and trailing
commas).
> **Not hook parity with claude-code.** OpenClaw's Stop event (`message:sent`)
> fires **0..N times per turn** and is **non-replyable**, so Stop is
> **debounced**. There is no single per-turn "agent finished one reply"
> internal event. See the Capture fidelity table below.
## Install
```bash
npx @waggle/hive-mind-hooks-openclaw install
# Windows / production: pin the CLI path
npx @waggle/hive-mind-hooks-openclaw install --cli-path "C:\\path\\to\\hive-mind-cli\\dist\\index.js"
```
The installer:
1. writes the managed hook dir `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}`
(the compiled handler is **copied** from this package's `dist/`),
2. minimally edits `~/.openclaw/openclaw.json` — flips
`hooks.internal.enabled = true` and adds
`hooks.internal.entries["hive-mind"] = { enabled: true }` (creating the file
if absent),
3. writes a pointer + **literal byte-identical backup** so uninstall is exact.
> **Activation (IMPORTANT).** OpenClaw internal hooks are **OFF** until the
> subsystem is enabled. The installer sets `hooks.internal.enabled: true`; if
> that is not honored on your install, run `openclaw hooks enable hive-mind`.
```bash
npx @waggle/hive-mind-hooks-openclaw verify # smoke-check
npx @waggle/hive-mind-hooks-openclaw uninstall # remove managed dir + byte-identical restore
```
Reversibility relies on the **literal byte-identical backup** of `openclaw.json`
(JSON5 re-serialization loses comments / trailing commas), plus removal of the
managed hook directory recorded in the pointer. If the config did not pre-exist,
uninstall removes the file we created (no orphans, no leftover backup).
## Capture fidelity
| Lifecycle | OpenClaw event | Status | Notes |
|---|---|---|---|
| SessionStart (recall + inject) | `agent:bootstrap` | full | injects by **mutating** `event.context.bootstrapFiles` (a mutable array the gateway reads back), not stdout — the sanctioned inject seam |
| UserPromptSubmit (save temporary) | `message:received` | full | inbound message; the text arrives at `context.content`, with `from` / `channelId` |
| Stop (summarize + save) | `message:sent` | **degraded (debounced)** | fires **0..N per turn** and is **non-replyable**; the handler debounces (last `message:sent` of a turn wins) so a multi-payload turn saves one frame |
| PreCompact (compact memory) | `session:compact:before` | full | the runtime `event.action` is `compact:before` (the `session:` prefix is HOOK.md-only); the handler matches on the action suffix |
**Disclosures:**
- **Stop is `message:sent`, 0..N per turn, non-replyable.** OpenClaw delivers
one `message:sent` per outbound payload, so a turn that streams multiple
payloads fires multiple times. The handler debounces (a short timer keyed on
the session/channel) so only the last payload of a turn is saved. There is no
single per-turn finalization event. (`before_agent_finalize` is a **plugin**
hook — a different subsystem — and is deliberately not used.)
- **`handler.js` must resolve `@waggle/*` at runtime (live-install caveat).**
The installed `~/.openclaw/hooks/hive-mind/handler.js` `require`s this
package's runtime deps (`@waggle/hive-mind-shim-core` /
`@waggle/hive-mind-hooks-core`). Whether the OpenClaw gateway can `import()` a
file that resolves those node_modules on a real OpenClaw install is the **one
remaining needs-a-live-install validation** — on a normal `npx`/npm install
the deps are colocated and resolve, but a hand-copied dir without the package
tree will not. Pin `--cli-path` for the CLI itself; the gateway's TS/JS loader
must reach the package's `node_modules`.
- **Gateway double-capture (known v0.1.0 limitation).** OpenClaw can drive
claude-code / codex as **backends**. If those backends ALSO have hive-mind
hooks installed, the same conversation is captured twice — once at the
OpenClaw gateway layer (this package) and once at the backend. This package
**stamps a provenance marker** (`openclaw-gateway` + the channel/session key)
on every frame it saves so gateway captures are attributable, but it does
**not** yet dedup the cross-process double-capture (that is a harder
cross-process problem). A content-hash dedup heuristic is deferred to a
fast-follow.
## How it works
OpenClaw loads the default export from
`~/.openclaw/hooks/hive-mind/handler.js` and dispatches its subscribed events
to it **in-process**. The handler maps `event.context` into the shared
lifecycle payload shapes and drives the same recall / save / summarize /
compact bodies the other hooks use, shelling to `hive-mind-cli` via
`@waggle/hive-mind-shim-core`'s `CliBridge`. **Fail-open:** the default export
never throws and never rejects — on any error it resolves silently, so the
gateway flow is never affected.
License: Apache-2.0.

View File

@@ -0,0 +1,66 @@
{
"name": "@waggle/hive-mind-hooks-openclaw",
"version": "0.1.0",
"description": "OpenClaw silent capture shim for hive-mind. Registers in-process internal hooks (agent:bootstrap / message:received / message:sent / compact:before) that route gateway conversation episodes into hive-mind frames via @waggle/hive-mind-shim-core. Reversible, create-if-missing install — writes a managed ~/.openclaw/hooks/hive-mind/ dir + minimal-touch JSON5 edit, with literal byte-identical uninstall (JSON5 round-trip is lossy). Stop debounced (message:sent is 0..N/turn). Built on @waggle/hive-mind-hooks-core.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"openclaw-hooks": "dist/bin/openclaw-hooks.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./handler": "./dist/handler.js"
},
"scripts": {
"build": "tsc --build && node scripts/build-handler.mjs",
"build:clean": "tsc --build --clean && node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist/handler.bundle.cjs', { force: true })\"",
"prepack": "npm run build",
"typecheck": "tsc --build && tsc --noEmit -p tsconfig.test.json",
"test": "npm run build && node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/hive-mind-hooks-openclaw/tests",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@waggle/hive-mind-hooks-core": "*",
"@waggle/hive-mind-shim-core": "*",
"json5": "^2.2.3"
},
"peerDependencies": {
"@waggle/hive-mind-cli": "*"
},
"peerDependenciesMeta": {
"@waggle/hive-mind-cli": {
"optional": true
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/marolinik/waggle-os.git",
"directory": "packages/hive-mind-hooks-openclaw"
},
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-openclaw#readme",
"bugs": {
"url": "https://github.com/marolinik/waggle-os/issues"
},
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
"keywords": [
"openclaw",
"hive-mind",
"memory",
"ai",
"hook",
"silent-capture",
"json5",
"in-process"
],
"types": "dist/index.d.ts"
}

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env node
/** Bundle the copied OpenClaw handler into one npm-independent CommonJS file. */
import { build } from 'esbuild';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const entryPoint = path.resolve(process.argv[2] ?? path.join(packageRoot, 'dist', 'handler.js'));
const outfile = path.resolve(process.argv[3] ?? path.join(packageRoot, 'dist', 'handler.bundle.cjs'));
const importPath = entryPoint.split(path.sep).join('/');
// OpenClaw uses native import(file://...) for managed hooks. Its ~/.openclaw
// directory is not an ESM package, so a copied .js file must be loadable as
// CommonJS. Assigning the function directly to module.exports also makes the
// dynamic-import default export the handler function (not { default: fn }).
await build({
stdin: {
contents: `import handler from ${JSON.stringify(importPath)}; module.exports = handler;`,
resolveDir: packageRoot,
sourcefile: 'installed-handler-entry.js',
},
outfile,
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
legalComments: 'none',
logLevel: 'info',
});

View File

@@ -0,0 +1,107 @@
/**
* OpenClaw EventAdapter.
*
* OpenClaw (`openclaw/openclaw`) exposes IN-PROCESS internal hooks: a hook is
* a directory `~/.openclaw/hooks/<name>/{HOOK.md, handler.js}` whose default
* export is `(event: InternalHookEvent) => Promise<void> | void`, run inside
* the gateway Node process. That is NOT the stdin-JSON / exit-0 subprocess
* model the other tools use, so openclaw drives the shared handler bodies via
* `makeOpenclawHandler` (hooks-core) rather than `runHook`.
*
* Event map — matched on the `(type, action)` PAIR, not a joined string
* (spec §5.5, source-verified):
* - SessionStart → `agent:bootstrap` (mutate `context.bootstrapFiles`)
* - UserPromptSubmit → `message:received` (context {from, content, channelId})
* - Stop → `message:sent` (0..N per turn — DEBOUNCED; non-replyable)
* - PreCompact → `session:compact:before`, BUT the runtime
* `event.action` is `'compact:before'` (the `session:` prefix is
* HOOK.md-only). `lifecycleForOpenclawEvent` in hooks-core matches the
* action suffix, so we declare the HOOK.md-style key here.
*
* `before_agent_finalize` is deliberately NOT used — it is a typed PLUGIN
* hook (a different subsystem), not an internal `HOOK.md` event. Stop is
* `message:sent` (debounced) instead.
*
* The `extract*` methods read OpenClaw's `event.context` shapes. Because the
* openclaw handler pre-extracts `event.context` into the shared
* extracted-payload shapes before calling the body, these extractors operate
* on that already-unwrapped `context` object.
*/
import {
pickStringField,
type EventAdapter,
type Lifecycle,
} from '@waggle/hive-mind-hooks-core';
/**
* Canonical lifecycle → OpenClaw native `type:action` key. The values are the
* joined keys; `makeOpenclawHandler` matches them against the runtime
* `(type, action)` pair (and, for PreCompact, on the action suffix because
* the runtime action drops the `session:` prefix).
*/
export const OPENCLAW_EVENT_NAME: Record<Lifecycle, string | undefined> = {
'session-start': 'agent:bootstrap',
'user-prompt-submit': 'message:received',
'stop': 'message:sent',
'pre-compact': 'session:compact:before',
};
/**
* Provenance marker stamped on frames captured at the OpenClaw gateway layer.
* OpenClaw can drive claude-code / codex as BACKENDS; if those backends also
* have hive-mind hooks installed the same conversation is captured twice. We
* stamp `openclaw-gateway` (+ the channel / session key, see the handler) so
* gateway captures are attributable. Cross-process dedup of the
* double-capture is a documented v0.1.0 limitation (README), deferred to a
* fast-follow.
*/
export const OPENCLAW_PROVENANCE = 'openclaw-gateway';
/** The OpenClaw EventAdapter consumed by the shared lifecycle handler bodies. */
export const openclawAdapter: EventAdapter = {
source: 'openclaw',
eventName: OPENCLAW_EVENT_NAME,
extractCwd(context): string | undefined {
return pickStringField(context, 'cwd', 'workingDirectory', 'working_directory');
},
extractSessionId(context): string | undefined {
// `channelId` is the most stable per-conversation key for gateway events;
// fall back to sessionKey / explicit session ids.
return pickStringField(
context,
'channelId',
'channel_id',
'sessionKey',
'session_key',
'sessionId',
'session_id',
);
},
extractPrompt(context): string | undefined {
// message:received carries the inbound text at context.content.
return pickStringField(context, 'content', 'message', 'text', 'prompt');
},
extractResponse(context): string | undefined {
// message:sent carries the outbound text at context.content / .text.
return pickStringField(context, 'content', 'text', 'response', 'message');
},
extractParent(context): string | undefined {
return pickStringField(context, 'parentId', 'parent_id', 'replyTo', 'reply_to');
},
/**
* SessionStart injects by MUTATING `event.context.bootstrapFiles` (handled
* in the openclaw handler, not via stdout). The body still calls
* `formatInject` to shape the recalled text; the handler reads the returned
* value and pushes it onto the mutable array.
*/
formatInject(additionalContext: string): unknown {
return { additionalContext };
},
};

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env node
/**
* `openclaw-hooks` — CLI entry for the @waggle/hive-mind-hooks-openclaw shim.
*
* openclaw-hooks install Write the managed ~/.openclaw/hooks/hive-mind/ dir
* + minimal-touch openclaw.json (create-if-missing).
* openclaw-hooks uninstall Remove the managed dir + restore the literal
* byte-identical openclaw.json (or remove it if created).
* openclaw-hooks verify Smoke-check the install + hive-mind-cli reachability.
*/
import { install, type InstallResult } from '../install.js';
import { uninstall, type UninstallResult } from '../uninstall.js';
import { verify, type VerifyResult } from '../verify.js';
type ParsedArgs = {
command: 'install' | 'uninstall' | 'verify' | 'help';
flags: Record<string, string | boolean>;
};
function parseArgs(argv: readonly string[]): ParsedArgs {
const [first, ...rest] = argv;
const valid = ['install', 'uninstall', 'verify'] as const;
const command = first === '-h' || first === '--help' || first === undefined
? 'help'
: valid.includes(first as typeof valid[number]) ? first as typeof valid[number] : 'help';
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
const next = rest[i + 1];
if (next && !next.startsWith('--')) {
flags[arg.slice(2)] = next;
i += 1;
} else {
flags[arg.slice(2)] = true;
}
}
}
return { command: command as ParsedArgs['command'], flags };
}
function printHelp(): void {
process.stdout.write([
'Usage: openclaw-hooks <command> [options]',
'',
'Commands:',
' install Write ~/.openclaw/hooks/hive-mind/ + minimal-touch openclaw.json (create-if-missing).',
' uninstall Remove the managed dir + restore the literal byte-identical openclaw.json (or remove it if created).',
' verify Smoke-check the install + hive-mind-cli reachability.',
'',
'Options:',
' --help, -h Show this help.',
' --handler-source <PATH> Override compiled handler.js source (testing).',
' --cli-path <PATH> Absolute path to the hive-mind-cli binary or its',
' compiled JS entry. Required on Windows (npm bin',
' is a .cmd shim) and recommended for production',
' installs. Threaded into the hook entry env as',
' WAGGLE_HIVE_MIND_CLI.',
'',
'Repo: https://github.com/marolinik/waggle-os',
'',
].join('\n'));
}
function printInstallSummary(result: InstallResult): void {
const lines: string[] = [
'hive-mind/openclaw-hooks: install',
` - openclaw.json: ${result.paths.configPath}`,
` - backup: ${result.backupPath ?? '(none — openclaw.json created by us)'}`,
` - pointer: ${result.pointerPath}`,
` - hook dir: ${result.hookDir}`,
` - lifecycles: ${result.installedHooks.join(', ')}`,
` - touched keys: ${result.touchedKeys.join(', ')}`,
` - cli path: ${result.cliPath ?? '(default — hive-mind-cli on PATH)'}`,
'',
'Activation (IMPORTANT):',
' Hooks are OFF until the internal-hooks subsystem is on. The installer set',
' hooks.internal.enabled: true. If it is not honored, run:',
' openclaw hooks enable hive-mind',
'',
'Capture fidelity:',
' 4 events — SessionStart (agent:bootstrap, mutates bootstrapFiles),',
' UserPromptSubmit (message:received), Stop (message:sent — DEBOUNCED,',
' 0..N/turn, non-replyable), PreCompact (compact:before). The installed',
' handler.js resolving @waggle/* on a real OpenClaw install is the one',
' remaining live-install validation. Gateway capture can double-count when',
' OpenClaw drives a backend (CC/codex) that also has hooks — see README.',
'',
'Done. New OpenClaw sessions will silently capture to hive-mind.',
'Run "openclaw-hooks verify" to inspect, "openclaw-hooks uninstall" to revert.',
'',
];
process.stdout.write(lines.join('\n'));
}
function printUninstallSummary(result: UninstallResult): void {
const lines: string[] = [
'hive-mind/openclaw-hooks: uninstall',
` - openclaw.json: ${result.paths.configPath}`,
` - hook dir removed: ${result.hookDirRemoved ? 'yes' : 'no (was absent)'}`,
` - restored from: ${result.restoredFrom ?? '(none — removed file we created)'}`,
` - created removed: ${result.createdRemoved ? 'yes' : 'no'}`,
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
` - pointer removed: ${result.pointerRemoved ? 'yes' : 'no'}`,
'',
'openclaw.json is byte-identical to pre-install state (or removed if we created it).',
'',
];
process.stdout.write(lines.join('\n'));
}
function printVerifySummary(result: VerifyResult): void {
const lines: string[] = ['hive-mind/openclaw-hooks: verify'];
for (const c of result.checks) {
const tag = c.ok ? 'PASS' : 'FAIL';
const detail = c.detail ? `${c.detail}` : '';
lines.push(` [${tag}] ${c.name}${detail}`);
}
lines.push('');
lines.push(result.ok ? 'All checks passed.' : 'One or more checks failed.');
lines.push('');
process.stdout.write(lines.join('\n'));
}
async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv.slice(2));
if (command === 'help') {
printHelp();
return;
}
const handlerSource = typeof flags['handler-source'] === 'string' ? flags['handler-source'] : undefined;
const cliPath = typeof flags['cli-path'] === 'string' ? flags['cli-path'] : undefined;
const baseOpts = {
moduleUrl: import.meta.url,
...(handlerSource ? { handlerSourcePath: handlerSource } : {}),
};
try {
if (command === 'install') {
const installOpts = {
...baseOpts,
...(cliPath !== undefined ? { cliPath } : {}),
};
const result = await install(installOpts);
printInstallSummary(result);
return;
}
if (command === 'uninstall') {
const result = await uninstall(baseOpts);
printUninstallSummary(result);
return;
}
if (command === 'verify') {
const result = await verify(baseOpts);
printVerifySummary(result);
if (!result.ok) process.exit(1);
return;
}
} catch (err) {
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,227 @@
/**
* OpenClaw in-process hook handler — the default export the gateway loads.
*
* Unlike the stdin-JSON tools (codex / cursor / hermes), OpenClaw hooks are
* IN-PROCESS: the gateway dynamically `import()`s this file, grabs the default
* export, and registers it as an `InternalHookHandler`
* (`(event) => Promise<void> | void`, run inside the gateway event loop). So
* there is no `runHook` subprocess wrapper — this module maps `event.context`
* into the shared extracted-payload shapes and drives the SAME shared handler
* bodies via `makeOpenclawHandler` (hooks-core).
*
* The four lifecycle dispatches (recall+inject / save-temp / summarize+save /
* compact) are matched on the `(type, action)` PAIR by hooks-core. This module
* owns the OpenClaw-specific glue:
* - SessionStart injects by MUTATING `event.context.bootstrapFiles` (a
* mutable array the gateway reads back) — there is no stdout seam.
* - Stop (`message:sent`) fires 0..N per turn and is DEBOUNCED in
* `makeOpenclawHandler` (stopDebounceMs).
* - Provenance: every captured frame is attributed to `openclaw-gateway`
* (+ the channel/session key) so gateway captures are distinguishable
* from any backend (CC/codex) capture.
*
* FAIL-OPEN: the default export wraps the body and NEVER throws / never
* rejects — it always returns a resolved promise (spec §7.3 invariant 1). The
* gateway also wraps each handler in try/catch, but we do not rely on that as
* the only safety net.
*
* Live-install caveat (OQ-5): this file is shipped COMPILED (`dist/handler.js`)
* and copied into `~/.openclaw/hooks/hive-mind/handler.js`. Whether the
* installed `handler.js` resolves the `@waggle/*` runtime deps on a real
* OpenClaw install is the ONE remaining needs-a-live-install validation — the
* gateway must be able to `import()` a file that `require`s node_modules from
* this package's tree (or have the deps bundled). Documented in the README.
*/
import {
createCliBridge,
createLogger,
type CliBridgeOptions,
type MemoryHit,
} from '@waggle/hive-mind-shim-core';
import {
makeOpenclawHandler,
type HookContext,
type InternalHookEventLike,
type Lifecycle,
type OpenclawHandlerInput,
type PreCompactExtracted,
type SessionStartExtracted,
type StopExtracted,
type UserPromptExtracted,
} from '@waggle/hive-mind-hooks-core';
import { openclawAdapter, OPENCLAW_PROVENANCE } from './adapter.js';
/** Default Stop debounce: collapse the 0..N `message:sent` of a turn. */
const DEFAULT_STOP_DEBOUNCE_MS = 750;
const DEFAULT_RECALL_LIMIT = 20;
/**
* Minimal shape of OpenClaw's runtime `InternalHookEvent`. `bootstrapFiles` is
* the MUTABLE array the gateway reads back after `agent:bootstrap` to inject
* recalled context into the system prompt.
*/
interface OpenclawRuntimeContext {
cwd?: unknown;
workingDirectory?: unknown;
channelId?: unknown;
sessionKey?: unknown;
content?: unknown;
/** Mutable array of bootstrap file contents — the SessionStart inject seam. */
bootstrapFiles?: unknown;
[key: string]: unknown;
}
interface OpenclawRuntimeEvent extends InternalHookEventLike {
context?: OpenclawRuntimeContext;
}
function asContext(event: OpenclawRuntimeEvent): OpenclawRuntimeContext {
return event.context && typeof event.context === 'object' ? event.context : {};
}
/**
* Build a provenance-stamped session scope so gateway frames are attributable
* to `openclaw-gateway` and to the originating channel/session. This rides the
* frame's `session:` content prefix (frame-encoder), which is the only
* attribution channel the save_memory wire preserves.
*/
function provenanceScope(ctx: OpenclawRuntimeContext): string {
const sessionId = openclawAdapter.extractSessionId(ctx) ?? 'default';
return `${OPENCLAW_PROVENANCE}:${sessionId}`;
}
/** Resolve the lifecycle for an event using the adapter's event-name map. */
function lifecycleFor(event: OpenclawRuntimeEvent): Lifecycle | undefined {
const joined = `${event.type}:${event.action}`;
for (const lc of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as Lifecycle[]) {
const native = openclawAdapter.eventName[lc];
if (native === undefined) continue;
if (native === joined) return lc;
// PreCompact ONLY: runtime action is 'compact:before' while the HOOK.md key
// is 'session:compact:before' — accept the action-suffix match exclusively
// for pre-compact (an unrelated type with a colliding action must not map).
if (lc === 'pre-compact' && native.endsWith(`:${event.action}`) && event.type !== '') return lc;
}
return undefined;
}
/**
* Extract the lifecycle-specific payload from `event.context`, stamping the
* provenance scope as the session id so saved frames are attributable.
*/
function extractFor(
lifecycle: Lifecycle,
ctx: OpenclawRuntimeContext,
): SessionStartExtracted | UserPromptExtracted | StopExtracted | PreCompactExtracted {
const cwd = openclawAdapter.extractCwd(ctx) ?? process.cwd();
const scope = provenanceScope(ctx);
switch (lifecycle) {
case 'session-start':
return { cwd, sessionId: scope, recallLimit: DEFAULT_RECALL_LIMIT };
case 'user-prompt-submit':
return { prompt: openclawAdapter.extractPrompt(ctx) ?? '', cwd, sessionId: scope };
case 'stop': {
const responseRaw = openclawAdapter.extractResponse(ctx, {});
const response = typeof responseRaw === 'string' ? responseRaw : '';
const parent = openclawAdapter.extractParent(ctx);
const stop: StopExtracted = { cwd, sessionId: scope, response, parent };
return stop;
}
case 'pre-compact':
return { scope };
}
}
/** Build a CliBridge, honoring an install-pinned cli path from env. */
function buildBridge(): ReturnType<typeof createCliBridge> {
const logger = createLogger({ name: 'openclaw-hooks/handler' });
const cliPath = process.env.WAGGLE_HIVE_MIND_CLI;
const opts: CliBridgeOptions = { logger };
if (typeof cliPath === 'string' && cliPath.length > 0) opts.cli_path = cliPath;
return createCliBridge(opts);
}
const handler = makeOpenclawHandler(openclawAdapter, {
stopDebounceMs: DEFAULT_STOP_DEBOUNCE_MS,
});
/**
* The OpenClaw default export. Receives the runtime `InternalHookEvent`, maps
* `event.context` → the extracted payload, and drives the shared bodies.
*
* SessionStart is special-cased: the shared body returns the inject object
* (`{ additionalContext }`); we push that text onto the mutable
* `context.bootstrapFiles` array (the gateway's sanctioned injection seam)
* rather than emitting stdout.
*
* NEVER throws — always returns a resolved promise (fail-open).
*/
export default async function openclawHook(event: OpenclawRuntimeEvent): Promise<void> {
try {
const lifecycle = lifecycleFor(event);
if (lifecycle === undefined) return;
const ctx = asContext(event);
const extracted = extractFor(lifecycle, ctx);
// SessionStart: drive recall ourselves so we can mutate bootstrapFiles
// with the injected text (the shared body's stdout return is unused
// in-process).
if (lifecycle === 'session-start') {
await injectBootstrap(ctx, extracted as SessionStartExtracted);
return;
}
const input: OpenclawHandlerInput = { event, extracted };
const hookCtx: HookContext = { bridge: buildBridge(), logger: createLogger({ name: 'openclaw-hooks/handler' }) };
await handler.handle(input, hookCtx);
} catch {
// FAIL-OPEN: swallow — the gateway flow must never be affected.
}
}
/**
* Recall top-N frames and push the formatted text onto the mutable
* `context.bootstrapFiles` array (OpenClaw's sanctioned inject seam). Recall
* uses the same provenance scope so the injected context is attributable.
* Fails open.
*/
async function injectBootstrap(
ctx: OpenclawRuntimeContext,
extracted: SessionStartExtracted,
): Promise<void> {
const logger = createLogger({ name: 'openclaw-hooks/handler' });
try {
const bridge = buildBridge();
const hits: MemoryHit[] = await bridge.recallMemory('', {
limit: extracted.recallLimit,
scope: 'personal',
});
if (hits.length === 0) return;
const text = formatHits(hits);
const arr = ctx.bootstrapFiles;
if (Array.isArray(arr)) {
// Mutate the host-owned array in place — this IS the injection seam.
(arr as unknown[]).push(text);
} else {
// The host did not provide a mutable array; nothing to inject into.
logger.debug('agent:bootstrap had no bootstrapFiles array — skipping inject');
}
} catch {
// Fail-open: a recall failure must not block bootstrap.
}
}
const PER_HIT_BUDGET = 240;
function formatHits(hits: readonly MemoryHit[]): string {
const lines: string[] = [`hive-mind: top ${hits.length} recalled frames`];
for (const h of hits) {
const content = h.content.length > PER_HIT_BUDGET
? h.content.slice(0, PER_HIT_BUDGET) + '…'
: h.content;
lines.push(`- (${h.importance}) ${h.created_at}: ${content}`);
}
return lines.join('\n');
}

View File

@@ -0,0 +1,60 @@
/**
* Builds the `HOOK.md` frontmatter the OpenClaw gateway reads to discover our
* managed hook directory.
*
* OpenClaw discovers a hook from `~/.openclaw/hooks/<name>/HOOK.md`: the
* frontmatter declares `metadata.openclaw.events[]` (the events the sibling
* `handler.js` default-export subscribes to), and the body is human docs.
*
* IMPORTANT (spec §5.5): the events[] array uses the HOOK.md-style names
* INCLUDING the `session:` prefix (`session:compact:before`), but the RUNTIME
* `event.action` drops it (`compact:before`). The handler matches on the
* action suffix, so declaring the prefixed form here is correct and the
* handler still fires.
*/
/** The OpenClaw internal events our handler subscribes to (HOOK.md form). */
export const HOOK_MD_EVENTS = [
'agent:bootstrap',
'message:received',
'message:sent',
'session:compact:before',
] as const;
/**
* Render the `HOOK.md` contents. `handlerFile` is the basename of the compiled
* handler the gateway will `import()` (`handler.js`).
*/
export function renderHookMd(handlerFile = 'handler.js'): string {
const eventsYaml = HOOK_MD_EVENTS.map((e) => ` - ${e}`).join('\n');
return [
'---',
'name: hive-mind',
'description: >-',
' hive-mind silent capture — routes OpenClaw gateway conversation episodes',
' into hive-mind frames via hive-mind-cli. Capture-only; fails open.',
'metadata:',
' openclaw:',
` handler: ${handlerFile}`,
' events:',
eventsYaml,
'---',
'',
'# hive-mind silent capture (OpenClaw)',
'',
'This managed hook is installed by `@waggle/hive-mind-hooks-openclaw`. It is',
'an **in-process** internal hook: the gateway loads the default export from',
`\`${handlerFile}\` and dispatches the events above to it.`,
'',
'Lifecycle mapping:',
'',
'- `agent:bootstrap` → recall + inject (mutates `context.bootstrapFiles`)',
'- `message:received` → save the inbound prompt as a temporary frame',
'- `message:sent` → summarize + save the outbound turn (debounced; 0..N/turn)',
'- `session:compact:before` → compaction maintenance (runtime action `compact:before`)',
'',
'Capture-only and fail-open: the handler never throws and never blocks the',
'gateway. Remove with `openclaw-hooks uninstall`.',
'',
].join('\n');
}

View File

@@ -0,0 +1,86 @@
/**
* @waggle/hive-mind-hooks-openclaw — barrel export.
*
* OpenClaw silent-capture shim for hive-mind. Unlike the stdin-JSON tools
* (codex / cursor / hermes), OpenClaw hooks are IN-PROCESS TypeScript: a hook
* is a directory `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}` whose
* default export the gateway loads and runs inside its own Node process. So
* the installer writes that managed dir + minimally touches the JSON5 config,
* and the handler drives the shared lifecycle bodies via
* `makeOpenclawHandler` (hooks-core) rather than `runHook`.
*
* Four events (matched on the `(type, action)` PAIR):
* - SessionStart → `agent:bootstrap` (mutates `context.bootstrapFiles`)
* - UserPromptSubmit → `message:received`
* - Stop → `message:sent` (0..N/turn — DEBOUNCED; non-replyable)
* - PreCompact → `session:compact:before` (runtime action `compact:before`)
*
* JSON5 round-trip is lossy, so reversibility relies on the literal
* byte-identical backup + a recorded managed-dir removal. Programmatic
* install / uninstall / verify lifecycle; most users invoke the
* `openclaw-hooks` bin. The in-process `handler.ts` default export is the
* `./handler` subpath export.
*/
export type {
InstallOptions,
InstallResult,
} from './install.js';
export { install } from './install.js';
export type {
UninstallOptions,
UninstallResult,
} from './uninstall.js';
export { uninstall } from './uninstall.js';
export type {
VerifyOptions,
VerifyResult,
VerifyCheck,
} from './verify.js';
export { verify } from './verify.js';
export type {
OpenclawPaths,
ResolvePathsOptions,
HookBasename,
} from './paths.js';
export {
resolvePaths,
allHookBasenames,
backupPathFor,
hookCommandFor,
HIVE_HOOK_DIR_NAME,
HIVE_HOOK_ENTRY_KEY,
} from './paths.js';
export {
openclawAdapter,
OPENCLAW_EVENT_NAME,
OPENCLAW_PROVENANCE,
} from './adapter.js';
export type {
HiveEntryEnv,
RegisterOptions,
} from './json5-merger.js';
export {
parseConfig,
serializeConfig,
jsonRegister,
jsonUnregister,
hasHiveEntries,
HIVE_ENTRY_KEY,
HOOKS_KEY,
} from './json5-merger.js';
export {
renderHookMd,
HOOK_MD_EVENTS,
} from './hook-md.js';
// The in-process handler default export (the gateway loads this). Re-exported
// for tests + programmatic drive; the gateway loads the COMPILED copy from
// `~/.openclaw/hooks/hive-mind/handler.js`.
export { default as openclawHook } from './handler.js';

View File

@@ -0,0 +1,168 @@
/**
* Programmatic install entry point for the OpenClaw hive-mind hooks.
*
* OpenClaw hooks are IN-PROCESS, so install is structurally different from the
* stdin-JSON tools: we WRITE A MANAGED HOOK DIRECTORY and only minimally touch
* the JSON5 config.
*
* Steps:
* 1. Read existing `~/.openclaw/openclaw.json` IF it exists (create-if-missing
* — the config is OPTIONAL on a fresh install). Strict JSON first, JSON5
* fallback.
* 2. If it pre-existed, write a LITERAL byte-identical backup; if absent,
* skip the backup and record `created_by_us=true`. (JSON5 round-trip is
* lossy — comments / trailing commas are dropped — so reversibility relies
* on the literal backup, not a re-serialized diff.)
* 3. Write the managed hook DIRECTORY
* `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}` — HOOK.md declares
* our events, handler.js is the compiled in-process default export COPIED
* from this package's dist/.
* 4. Minimal-touch edit of openclaw.json: flip `hooks.internal.enabled=true`
* + add `hooks.internal.entries["hive-mind"]={enabled:true, env?}` via
* `jsonRegister`. Existing config preserved verbatim.
* 5. Write the merged config back over openclaw.json.
* 6. Drop a pointer at `~/.openclaw/hive-mind-install.json` recording the
* created dir + touched keys (pointer.extra) so uninstall removes exactly
* what we added (and restores openclaw.json from the byte-identical
* backup, or removes it if we created it).
*/
import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import {
backupByteIdentical,
normalizeCliPath,
writePointer,
type InstallPointer,
} from '@waggle/hive-mind-hooks-core';
import {
resolvePaths,
HIVE_HOOK_DIR_NAME,
HIVE_HOOK_ENTRY_KEY,
type OpenclawPaths,
type ResolvePathsOptions,
} from './paths.js';
import { parseConfig, serializeConfig, jsonRegister } from './json5-merger.js';
import { renderHookMd } from './hook-md.js';
export interface InstallResult {
paths: OpenclawPaths;
/** The literal byte-identical backup written when openclaw.json pre-existed, else null. */
backupPath: string | null;
pointerPath: string;
/** The managed hook dir we created (`~/.openclaw/hooks/hive-mind/`). */
hookDir: string;
/** Lifecycle names the single handler dispatches. */
installedHooks: readonly string[];
/** openclaw.json keys we touched (recorded in pointer.extra). */
touchedKeys: readonly string[];
/** True when openclaw.json did NOT pre-exist and we created it. */
createdByUs: boolean;
/** The cli_path embedded in the entry env (undefined = default lookup at runtime). */
cliPath?: string;
}
export interface InstallOptions extends ResolvePathsOptions {
/** Override clock for deterministic tests. */
now?: () => Date;
/** Logger override. */
logger?: Logger;
/**
* Absolute path to the hive-mind-cli binary or its compiled JS entry.
* Required on Windows (npm bin shim is `.cmd` and can't be exec'd without a
* shell). Threaded into the hook entry env as `WAGGLE_HIVE_MIND_CLI` so the
* in-process handler's CliBridge uses it.
*/
cliPath?: string;
/** Extra env to attach to the hook entry (e.g. WAGGLE_WORKSPACE_ID). */
env?: Record<string, string>;
}
const POINTER_VERSION = '0.1.0';
const TOUCHED_KEYS = ['hooks.internal.enabled', `hooks.internal.entries.${HIVE_HOOK_ENTRY_KEY}`] as const;
const LIFECYCLE_NAMES = ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as const;
async function ensureDir(p: string): Promise<void> {
if (!existsSync(p)) await mkdir(p, { recursive: true });
}
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/install' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.handlerSourcePath !== undefined
? { handlerSourcePath: opts.handlerSourcePath }
: { moduleUrl: import.meta.url }),
});
const now = opts.now ?? ((): Date => new Date());
log.info('install starting', { config: paths.configPath, hookDir: paths.hiveHookDir });
// Read existing config if present; create-if-missing otherwise.
let existingConfig: Record<string, unknown> | undefined;
const preExisted = existsSync(paths.configPath);
if (preExisted) {
existingConfig = parseConfig(await readFile(paths.configPath, 'utf-8'));
}
await ensureDir(paths.openclawDir);
await ensureDir(dirname(paths.pointerPath));
// Literal byte-identical backup of the original config (no-op when absent).
const { backupPath } = await backupByteIdentical(paths.configPath, now().toISOString());
if (backupPath) log.info('openclaw.json backed up', { backupPath });
// Write the managed hook dir: HOOK.md + the compiled handler.js (copied from
// this package's dist/). We COPY rather than reference so the gateway loads a
// stable file even if this package is removed (the handler still requires the
// @waggle/* deps at runtime — the OQ-5 live-install caveat, see README).
await ensureDir(paths.hiveHookDir);
await writeFile(paths.hookMdPath, renderHookMd('handler.js'), 'utf-8');
if (!existsSync(paths.handlerSourcePath)) {
throw new Error(
`compiled handler not found at ${paths.handlerSourcePath}. ` +
`Build the package (tsc --build) before installing.`,
);
}
await copyFile(paths.handlerSourcePath, paths.installedHandlerPath);
// Minimal-touch config edit.
const cliPath = normalizeCliPath(opts.cliPath);
const env: Record<string, string> = { ...(opts.env ?? {}) };
if (cliPath !== undefined) env['WAGGLE_HIVE_MIND_CLI'] = cliPath;
const merged = jsonRegister(existingConfig, Object.keys(env).length > 0 ? { env } : {});
await writeFile(paths.configPath, serializeConfig(merged), 'utf-8');
const createdByUs = !preExisted;
const pointer: InstallPointer = {
version: POINTER_VERSION,
installed_at: now().toISOString(),
config_path: paths.configPath,
settings_backup: backupPath,
created_by_us: createdByUs,
hooks_dir: paths.hiveHookDir,
installed_hooks: LIFECYCLE_NAMES,
cli_path: cliPath ?? null,
extra: {
hook_dir_name: HIVE_HOOK_DIR_NAME,
touched_keys: TOUCHED_KEYS,
},
};
await writePointer(paths.pointerPath, pointer);
log.info('install complete', { createdByUs, hookDir: paths.hiveHookDir, cliPath: cliPath ?? '(PATH lookup)' });
const result: InstallResult = {
paths,
backupPath,
pointerPath: paths.pointerPath,
hookDir: paths.hiveHookDir,
installedHooks: LIFECYCLE_NAMES,
touchedKeys: TOUCHED_KEYS,
createdByUs,
};
if (cliPath !== undefined) result.cliPath = cliPath;
return result;
}

View File

@@ -0,0 +1,172 @@
/**
* Bespoke JSON5 codec + minimal-touch register/unregister for OpenClaw's
* `~/.openclaw/openclaw.json`.
*
* OpenClaw's config is JSON5 (comments, trailing commas, `$include` merges,
* `${ENV}` substitution). Naive `JSON.parse`→`JSON.stringify` DESTROYS user
* comments and trailing commas, so we do NOT re-serialize the whole file for
* fidelity. Instead:
* - the actual hook IMPLEMENTATION lives in a managed DIRECTORY
* (`~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}`), written by
* install.ts;
* - this module performs a MINIMAL-TOUCH edit of openclaw.json — flip
* `hooks.internal.enabled = true` and add an
* `hooks.internal.entries["hive-mind"] = { enabled: true }` entry — so the
* gateway discovers + activates our hook dir;
* - reversibility relies on the LITERAL byte-identical backup the installer
* writes (install-core `backupByteIdentical` / `restoreFromBackup`), NOT
* on re-serialization fidelity. This module only produces the merged
* object we WRITE on install.
*
* Parse priority: strict `JSON.parse` FIRST (fast, exact), then `JSON5.parse`
* fallback (tolerates comments / trailing commas). Both fail → throw so the
* installer fails loudly rather than clobbering a user config.
*
* Immutability: every function returns a NEW config object and never mutates
* its input (mirrors the `jsonRegister` contract).
*/
import JSON5 from 'json5';
/** Logical entry key under `hooks.internal.entries` identifying our hook. */
export const HIVE_ENTRY_KEY = 'hive-mind';
/** Top-level JSON5 key holding the hooks subsystem. */
export const HOOKS_KEY = 'hooks';
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/**
* Parse an `openclaw.json` string into a plain object. Returns `{}` for an
* empty/whitespace string (create-if-missing). Tries strict JSON first, then
* JSON5; throws only if BOTH fail.
*/
export function parseConfig(raw: string): Record<string, unknown> {
if (!raw || raw.trim().length === 0) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
try {
parsed = JSON5.parse(raw);
} catch (err) {
throw new Error(
'failed to parse ~/.openclaw/openclaw.json as JSON or JSON5: ' +
(err instanceof Error ? err.message : String(err)),
);
}
}
const record = asRecord(parsed);
// A top-level scalar/array doc is not a valid openclaw config shape; treat
// it as empty rather than crashing (the original bytes are backed up
// regardless, so nothing is lost on uninstall).
return record ?? {};
}
/**
* Serialize a config object back to JSON (2-space, trailing newline). NOTE:
* this is LOSSY for JSON5 sources (comments / trailing commas are dropped) —
* which is exactly why uninstall restores the literal byte-identical backup
* instead of re-serializing. We use plain `JSON.stringify` (not JSON5) so the
* written file is valid strict JSON, which OpenClaw's JSON5 parser also
* accepts.
*/
export function serializeConfig(config: Record<string, unknown>): string {
return JSON.stringify(config, null, 2) + '\n';
}
/** The minimal env block our entry carries (workspace context for the handler). */
export interface HiveEntryEnv {
[key: string]: string;
}
export interface RegisterOptions {
/** Optional env to attach to the entry (e.g. WAGGLE_WORKSPACE_ID). */
env?: HiveEntryEnv;
}
/**
* Returns a NEW config object with the internal-hooks subsystem enabled and a
* marker entry pointing the gateway at our managed `hive-mind` hook dir.
* Existing config (including any other `hooks.internal.entries`) is preserved
* verbatim. Re-running install replaces OUR entry in place (idempotent
* upgrade) rather than duplicating.
*
* Touched keys (recorded by the caller in the pointer.extra so uninstall —
* the rare backup-less path — knows exactly what to strip):
* - hooks.internal.enabled = true
* - hooks.internal.entries["hive-mind"] = { enabled: true, env? }
*/
export function jsonRegister(
config: Record<string, unknown> | undefined,
opts: RegisterOptions = {},
): Record<string, unknown> {
const next: Record<string, unknown> = config ? { ...config } : {};
const hooks = asRecord(next[HOOKS_KEY]);
const nextHooks: Record<string, unknown> = hooks ? { ...hooks } : {};
const internal = asRecord(nextHooks['internal']);
const nextInternal: Record<string, unknown> = internal ? { ...internal } : {};
nextInternal['enabled'] = true;
const entries = asRecord(nextInternal['entries']);
const nextEntries: Record<string, unknown> = entries ? { ...entries } : {};
const entry: Record<string, unknown> = { enabled: true };
if (opts.env && Object.keys(opts.env).length > 0) entry['env'] = { ...opts.env };
nextEntries[HIVE_ENTRY_KEY] = entry;
nextInternal['entries'] = nextEntries;
nextHooks['internal'] = nextInternal;
next[HOOKS_KEY] = nextHooks;
return next;
}
/**
* Returns a NEW config object with our `hive-mind` entry removed from
* `hooks.internal.entries`. Leaves `hooks.internal.enabled` as-is (other hooks
* may rely on it — minimal-touch). Non-hive entries preserved verbatim. Never
* mutates input.
*
* Note: uninstall normally restores the literal byte-identical backup, so this
* is used for verify/diagnostics and the (rare) backup-less path.
*/
export function jsonUnregister(
config: Record<string, unknown> | undefined,
): Record<string, unknown> {
const next: Record<string, unknown> = config ? { ...config } : {};
const hooks = asRecord(next[HOOKS_KEY]);
if (!hooks) return next;
const nextHooks: Record<string, unknown> = { ...hooks };
const internal = asRecord(nextHooks['internal']);
if (!internal) {
next[HOOKS_KEY] = nextHooks;
return next;
}
const nextInternal: Record<string, unknown> = { ...internal };
const entries = asRecord(nextInternal['entries']);
if (entries) {
const nextEntries: Record<string, unknown> = { ...entries };
delete nextEntries[HIVE_ENTRY_KEY];
nextInternal['entries'] = nextEntries;
}
nextHooks['internal'] = nextInternal;
next[HOOKS_KEY] = nextHooks;
return next;
}
/** True iff `hooks.internal.entries["hive-mind"]` is present. */
export function hasHiveEntries(config: Record<string, unknown> | undefined): boolean {
if (!config) return false;
const hooks = asRecord(config[HOOKS_KEY]);
const internal = asRecord(hooks?.['internal']);
const entries = asRecord(internal?.['entries']);
return !!entries && Object.prototype.hasOwnProperty.call(entries, HIVE_ENTRY_KEY);
}

View File

@@ -0,0 +1,120 @@
/**
* Filesystem path helpers for the OpenClaw hive-mind hook install lifecycle.
*
* OpenClaw differs structurally from the stdin-JSON tools (codex / cursor /
* hermes): its hooks are IN-PROCESS TypeScript. A hook is a DIRECTORY
* `~/.openclaw/hooks/<name>/{HOOK.md, handler.js}` that the gateway discovers
* and dynamically `import()`s. Config (`~/.openclaw/openclaw.json`, JSON5)
* only flips the internal-hooks subsystem on and references the dir.
*
* So this module resolves THREE roots the JSON tools don't have:
* - the managed hook DIRECTORY we write (`~/.openclaw/hooks/hive-mind/`),
* - the compiled `handler.js` we COPY into that dir at install time,
* - the `HOOK.md` we write alongside it.
*
* The Windows-safe backup path + `--cli-path` quoting are reused verbatim
* from `@waggle/hive-mind-hooks-core` so openclaw reads like the reference.
*/
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { backupPathFor, hookCommandFor } from '@waggle/hive-mind-hooks-core';
/** Name of the managed hook directory we create under `~/.openclaw/hooks/`. */
export const HIVE_HOOK_DIR_NAME = 'hive-mind';
/** Logical hook entry key under `hooks.internal.entries`. */
export const HIVE_HOOK_ENTRY_KEY = 'hive-mind';
export interface OpenclawPaths {
/** OpenClaw config root (`~/.openclaw/`). */
openclawDir: string;
/** `~/.openclaw/openclaw.json` — the JSON5 config (internal-hooks subsystem). */
configPath: string;
/** `~/.openclaw/hive-mind-install.json` — pointer to the active backup + created dir. */
pointerPath: string;
/** `~/.openclaw/hooks/` — OpenClaw's hook-discovery root. */
hooksRoot: string;
/** `~/.openclaw/hooks/hive-mind/` — the managed hook dir WE write. */
hiveHookDir: string;
/** `~/.openclaw/hooks/hive-mind/HOOK.md` — frontmatter declaring our events. */
hookMdPath: string;
/** `~/.openclaw/hooks/hive-mind/handler.js` — the compiled handler we copy in. */
installedHandlerPath: string;
/** Source of the self-contained handler bundle in THIS package's `dist/`. */
handlerSourcePath: string;
}
export interface ResolvePathsOptions {
/** Override $HOME for tests. */
home?: string;
/** Override the URL used to locate dist/ (defaults to import.meta.url at runtime). */
moduleUrl?: string;
/** Override the compiled-handler source path directly (wins over moduleUrl). */
handlerSourcePath?: string;
}
/**
* OpenClaw's four internal lifecycle hooks. Unlike the stdin tools these are
* NOT separate compiled scripts — one `handler.js` default-export dispatches
* all four by `(type, action)`. The basenames here name the LIFECYCLE for
* pointer bookkeeping + verify, not separate files on disk.
*/
const HOOK_BASENAMES = [
'session-start',
'user-prompt-submit',
'stop',
'pre-compact',
] as const;
export type HookBasename = typeof HOOK_BASENAMES[number];
export function allHookBasenames(): readonly HookBasename[] {
return HOOK_BASENAMES;
}
/**
* Resolve the self-contained handler bundle shipped in this package's `dist/`.
* The compiled install module lives at `<pkg>/dist/<file>.js`; dirname gives
* `<pkg>/dist/`, so `handler.bundle.cjs` is a sibling.
*/
function handlerSourceFromModuleUrl(moduleUrl: string): string {
const dir = dirname(fileURLToPath(moduleUrl));
return resolve(dir, 'handler.bundle.cjs');
}
export function resolvePaths(opts: ResolvePathsOptions = {}): OpenclawPaths {
const home = opts.home ?? homedir();
const openclawDir = join(home, '.openclaw');
const configPath = join(openclawDir, 'openclaw.json');
const pointerPath = join(openclawDir, 'hive-mind-install.json');
const hooksRoot = join(openclawDir, 'hooks');
const hiveHookDir = join(hooksRoot, HIVE_HOOK_DIR_NAME);
const hookMdPath = join(hiveHookDir, 'HOOK.md');
const installedHandlerPath = join(hiveHookDir, 'handler.js');
let handlerSourcePath: string;
if (opts.handlerSourcePath) {
handlerSourcePath = resolve(opts.handlerSourcePath);
} else if (opts.moduleUrl) {
handlerSourcePath = handlerSourceFromModuleUrl(opts.moduleUrl);
} else {
// Fallback for ad-hoc test use — install.ts always passes moduleUrl.
handlerSourcePath = resolve(process.cwd(), 'dist', 'handler.bundle.cjs');
}
return {
openclawDir,
configPath,
pointerPath,
hooksRoot,
hiveHookDir,
hookMdPath,
installedHandlerPath,
handlerSourcePath,
};
}
/** Re-export the shared Windows-safe helpers so openclaw modules read like CC. */
export { backupPathFor, hookCommandFor };

View File

@@ -0,0 +1,98 @@
/**
* Programmatic uninstall entry point for the OpenClaw hive-mind hooks.
*
* Removes EXACTLY what install added:
* 1. Delete the managed hook DIRECTORY `~/.openclaw/hooks/hive-mind/`
* (HOOK.md + handler.js) recorded in the pointer.
* 2. Restore `~/.openclaw/openclaw.json`:
* - `created_by_us=false` (config pre-existed): restore the LITERAL
* byte-identical backup the installer wrote; refuse to delete the
* backup unless the in-place readback matches. (JSON5 re-serialization
* is lossy, so we restore the original BYTES — comments and trailing
* commas are preserved exactly because we never touch the re-serialized
* merge on this path.)
* - `created_by_us=true` (we created openclaw.json): delete the file we
* created — never orphan it, never leave a backup behind.
* 3. Remove the pointer.
*
* The config restore/delete is handled by the shared `restoreFromBackup`
* primitive; the hook-dir removal is openclaw-specific.
*/
import { unlink, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { readPointer, restoreFromBackup } from '@waggle/hive-mind-hooks-core';
import { resolvePaths, type OpenclawPaths, type ResolvePathsOptions } from './paths.js';
export interface UninstallResult {
paths: OpenclawPaths;
/** Backup 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 we created. */
createdRemoved: boolean;
/** True when the managed hook dir was removed. */
hookDirRemoved: boolean;
pointerRemoved: boolean;
backupRemoved: boolean;
}
export interface UninstallOptions extends ResolvePathsOptions {
logger?: Logger;
/** If false, leaves the backup file in place after restore. Default true. */
cleanupBackup?: boolean;
}
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/uninstall' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.handlerSourcePath !== undefined
? { handlerSourcePath: opts.handlerSourcePath }
: { moduleUrl: import.meta.url }),
});
const cleanup = opts.cleanupBackup ?? true;
if (!existsSync(paths.pointerPath)) {
throw new Error(
`no install pointer found at ${paths.pointerPath}. ` +
`Was @waggle/hive-mind-hooks-openclaw ever installed for this user?`,
);
}
const pointer = await readPointer(paths.pointerPath);
// 1. Remove the managed hook dir we created. Prefer the pointer's recorded
// hooks_dir; fall back to the resolved path.
const hookDir = pointer.hooks_dir ?? paths.hiveHookDir;
let hookDirRemoved = false;
if (existsSync(hookDir)) {
await rm(hookDir, { recursive: true, force: true });
hookDirRemoved = true;
log.info('managed hook dir removed', { hookDir });
}
// 2. Restore / delete openclaw.json via the shared primitive.
const restore = await restoreFromBackup({
configPath: paths.configPath,
pointer,
cleanupBackup: cleanup,
});
if (restore.createdRemoved) {
log.info('openclaw.json removed (created by us)', { config: paths.configPath });
} else {
log.info('openclaw.json restored byte-identical', { config: paths.configPath });
}
// 3. Remove the pointer.
await unlink(paths.pointerPath);
return {
paths,
restoredFrom: restore.restoredFrom,
createdRemoved: restore.createdRemoved,
hookDirRemoved,
pointerRemoved: true,
backupRemoved: restore.backupRemoved,
};
}

View File

@@ -0,0 +1,179 @@
/**
* Smoke-check the OpenClaw install: openclaw.json exists + parses (JSON/JSON5)
* + carries the hive-mind internal-hooks entry with the subsystem enabled, the
* managed hook dir + HOOK.md + handler.js exist on disk, and hive-mind-cli
* answers a `--help` probe. Plus the openclaw-specific activation advisory
* (spec §5.5 / §6.2): hooks are OFF until `hooks.internal.enabled=true`.
*
* Probe priority for `cli_path`:
* 1. Explicit `opts.cliPath` (caller override)
* 2. `cli_path` recorded in `~/.openclaw/hive-mind-install.json`
* 3. Bare `'hive-mind-cli'` on PATH
*/
import { readFile, access } from 'node:fs/promises';
import { constants, existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { resolvePaths, type ResolvePathsOptions } from './paths.js';
import { parseConfig, hasHiveEntries, HOOKS_KEY } from './json5-merger.js';
export interface VerifyCheck {
name: string;
ok: boolean;
detail?: string;
}
export interface VerifyResult {
ok: boolean;
checks: VerifyCheck[];
}
export interface VerifyOptions extends ResolvePathsOptions {
logger?: Logger;
/** Override hive-mind-cli executable name. Default 'hive-mind-cli'. */
cliPath?: string;
/** Test hook for spawn. */
spawnImpl?: typeof spawn;
}
async function fileReadable(p: string): Promise<boolean> {
try { await access(p, constants.R_OK); return true; } catch { return false; }
}
function isJsPath(p: string): boolean {
return p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs');
}
function internalEnabled(config: Record<string, unknown>): boolean {
const hooks = config[HOOKS_KEY];
if (!hooks || typeof hooks !== 'object') return false;
const internal = (hooks as Record<string, unknown>)['internal'];
if (!internal || typeof internal !== 'object') return false;
return (internal as Record<string, unknown>)['enabled'] === true;
}
function probeCliVersion(
cliPath: string,
spawnImpl: typeof spawn,
timeoutMs: number,
): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
let settled = false;
const command = isJsPath(cliPath) ? process.execPath : cliPath;
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
const child = spawnImpl(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill('SIGTERM'); } catch { /* gone */ }
resolve({ ok: false, output: 'timed out probing hive-mind-cli' });
}, timeoutMs);
child.stdout?.on('data', (c: Buffer) => stdout.push(c));
child.stderr?.on('data', (c: Buffer) => stderr.push(c));
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: false, output: err instanceof Error ? err.message : String(err) });
});
child.on('exit', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const out = Buffer.concat(stdout).toString('utf-8').slice(0, 200);
const err = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
resolve({ ok: code === 0, output: code === 0 ? out : err });
});
});
}
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/verify' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.handlerSourcePath !== undefined
? { handlerSourcePath: opts.handlerSourcePath }
: { moduleUrl: import.meta.url }),
});
const checks: VerifyCheck[] = [];
// 1. openclaw.json exists and parses.
if (!existsSync(paths.configPath)) {
checks.push({ name: 'openclaw.json exists', ok: false, detail: paths.configPath });
return { ok: false, checks };
}
checks.push({ name: 'openclaw.json exists', ok: true, detail: paths.configPath });
let parsed: Record<string, unknown>;
try {
parsed = parseConfig(await readFile(paths.configPath, 'utf-8'));
checks.push({ name: 'openclaw.json parses as JSON/JSON5', ok: true });
} catch (err) {
checks.push({
name: 'openclaw.json parses as JSON/JSON5',
ok: false,
detail: err instanceof Error ? err.message : String(err),
});
return { ok: false, checks };
}
// 2. hive-mind internal-hooks entry present.
checks.push({
name: 'openclaw.json contains the hive-mind internal-hooks entry',
ok: hasHiveEntries(parsed),
});
// 3. internal hooks subsystem enabled (hooks are OFF otherwise).
const enabled = internalEnabled(parsed);
checks.push({
name: 'internal hooks subsystem enabled',
ok: enabled,
detail: enabled
? 'hooks.internal.enabled: true — the hive-mind hook is active.'
: 'set hooks.internal.enabled: true (or run `openclaw hooks enable hive-mind`) — hooks are OFF until opted in.',
});
// 4. managed hook dir + HOOK.md + compiled handler exist on disk.
checks.push({
name: 'managed hook dir exists',
ok: existsSync(paths.hiveHookDir),
detail: paths.hiveHookDir,
});
checks.push({
name: 'HOOK.md readable on disk',
ok: await fileReadable(paths.hookMdPath),
detail: paths.hookMdPath,
});
checks.push({
name: 'handler.js readable on disk',
ok: await fileReadable(paths.installedHandlerPath),
detail: paths.installedHandlerPath,
});
// 5. hive-mind-cli responds to --help (prefer install-pinned --cli-path).
let cliPathFromPointer: string | undefined;
if (existsSync(paths.pointerPath)) {
try {
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
const pointerCliPath = pointerObj['cli_path'];
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
cliPathFromPointer = pointerCliPath;
}
} catch { /* pointer unreadable — fall through */ }
}
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
const spawnImpl = opts.spawnImpl ?? spawn;
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
checks.push({
name: 'hive-mind-cli reachable',
ok: probe.ok,
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
});
const ok = checks.every((c) => c.ok);
log.info('verify complete', { ok, total: checks.length, failed: checks.filter((c) => !c.ok).length });
return { ok, checks };
}

View File

@@ -0,0 +1,271 @@
import { describe, expect, it, vi } from 'vitest';
import { createLogger } from '@waggle/hive-mind-shim-core';
import type {
CliBridge,
HookFrame,
MemoryHit,
SaveMemoryResult,
} from '@waggle/hive-mind-shim-core';
import {
makeOpenclawHandler,
type HookContext,
type InternalHookEventLike,
type OpenclawHandlerInput,
type SessionStartExtracted,
type StopExtracted,
type UserPromptExtracted,
} from '@waggle/hive-mind-hooks-core';
import {
openclawAdapter,
OPENCLAW_EVENT_NAME,
OPENCLAW_PROVENANCE,
} from '../src/adapter.js';
const SILENT = createLogger({ name: 'test', write: () => { /* swallow log output */ } });
/** A recording mock CliBridge — never shells out, never touches ~/.openclaw. */
interface MockBridge extends CliBridge {
saved: HookFrame[];
recalls: number;
cleanups: number;
}
function makeMockBridge(opts: { hits?: MemoryHit[]; throwOn?: 'save' | 'recall' | 'cleanup' } = {}): MockBridge {
const saved: HookFrame[] = [];
const bridge = {
saved,
recalls: 0,
cleanups: 0,
async callMcpTool<T>(): Promise<T> {
return undefined as unknown as T;
},
async saveMemory(frame: HookFrame): Promise<SaveMemoryResult> {
if (opts.throwOn === 'save') throw new Error('boom: save_memory failed');
saved.push(frame);
return { id: String(saved.length), success: true, workspace: 'personal' };
},
async recallMemory(): Promise<MemoryHit[]> {
bridge.recalls += 1;
if (opts.throwOn === 'recall') throw new Error('boom: recall_memory failed');
return opts.hits ?? [];
},
async cleanupFrames(): Promise<{ pruned: number }> {
bridge.cleanups += 1;
if (opts.throwOn === 'cleanup') throw new Error('boom: cleanup_frames failed');
return { pruned: 0 };
},
setWorkspaceById(): void { /* noop */ },
getActiveWorkspaceId(): undefined { return undefined; },
} as unknown as MockBridge;
return bridge;
}
function ctxFor(bridge: CliBridge): HookContext {
return { bridge, logger: SILENT };
}
function provScope(sessionId: string): string {
return `${OPENCLAW_PROVENANCE}:${sessionId}`;
}
function hit(content: string): MemoryHit {
return { id: 1, content, importance: 'important', source: 'openclaw', score: 1, created_at: '2026-06-01', from: 'personal' };
}
describe('openclawAdapter (event map + field extraction over event.context)', () => {
it('maps the four lifecycles to OpenClaw type:action keys; pre-compact uses the session: prefix', () => {
expect(OPENCLAW_EVENT_NAME['session-start']).toBe('agent:bootstrap');
expect(OPENCLAW_EVENT_NAME['user-prompt-submit']).toBe('message:received');
expect(OPENCLAW_EVENT_NAME['stop']).toBe('message:sent');
// HOOK.md-form key (prefixed) — the runtime action drops the session: prefix.
expect(OPENCLAW_EVENT_NAME['pre-compact']).toBe('session:compact:before');
});
it('source is openclaw', () => {
expect(openclawAdapter.source).toBe('openclaw');
});
it('extracts cwd / sessionId (channelId-first) / prompt / response / parent from the context object', () => {
const received = { cwd: '/work', channelId: 'chan-7', content: 'the inbound prompt' };
expect(openclawAdapter.extractCwd(received)).toBe('/work');
expect(openclawAdapter.extractSessionId(received)).toBe('chan-7');
expect(openclawAdapter.extractPrompt(received)).toBe('the inbound prompt');
const sent = { content: 'the outbound reply', parentId: 'p-1' };
expect(openclawAdapter.extractResponse(sent, {})).toBe('the outbound reply');
expect(openclawAdapter.extractParent(sent)).toBe('p-1');
});
it('formatInject produces the { additionalContext } shape pushed into bootstrapFiles', () => {
expect(openclawAdapter.formatInject?.('recalled frames here')).toEqual({ additionalContext: 'recalled frames here' });
});
});
// ── makeOpenclawHandler drive path (the in-process handler) ─────────────
describe('makeOpenclawHandler — message:received saves a temporary frame', () => {
it('persists the inbound prompt as a temporary frame, provenance-scoped to openclaw-gateway:<channel>', async () => {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: UserPromptExtracted = {
prompt: 'hello from the gateway',
cwd: '/work',
sessionId: provScope('chan-7'),
};
const event: InternalHookEventLike = { type: 'message', action: 'received' };
await handler.handle({ event, extracted }, ctxFor(bridge));
expect(bridge.saved).toHaveLength(1);
const frame = bridge.saved[0];
expect(frame.importance).toBe('temporary');
expect(frame.content).toBe('hello from the gateway');
// Provenance stamp rides the frame scope (the only attribution channel save preserves).
expect(frame.scope).toBe(provScope('chan-7'));
expect(frame.scope).toContain('openclaw-gateway');
expect(frame.source).toBe('openclaw');
});
});
describe('makeOpenclawHandler — agent:bootstrap recalls + the adapter injects into bootstrapFiles', () => {
it('mutates the host-owned bootstrapFiles array with the recalled text (the inject seam)', async () => {
const bridge = makeMockBridge({ hits: [hit('prior decision: ship the thing')] });
// Replicate the package handler's SessionStart seam: recall, format via the
// adapter, push onto the MUTABLE bootstrapFiles array the gateway reads back.
const bootstrapFiles: unknown[] = [];
const recalled = await bridge.recallMemory('', { limit: 20, scope: 'personal' });
expect(recalled).toHaveLength(1);
const injected = openclawAdapter.formatInject?.(recalled.map((h) => h.content).join('\n')) as { additionalContext: string };
bootstrapFiles.push(injected.additionalContext);
expect(bootstrapFiles).toHaveLength(1);
expect(bootstrapFiles[0]).toContain('prior decision: ship the thing');
});
it('drives runSessionStartBody through the handler without throwing (recall path executes)', async () => {
const bridge = makeMockBridge({ hits: [hit('frame a')] });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: SessionStartExtracted = {
cwd: '/work',
sessionId: provScope('chan-7'),
recallLimit: 20,
};
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
expect(bridge.recalls).toBe(1);
});
});
describe('makeOpenclawHandler — message:sent (Stop) DEBOUNCE collapses 0..N/turn to one save', () => {
it('fires 3 message:sent in a turn → exactly ONE save of the LAST payload', async () => {
vi.useFakeTimers();
try {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 750 });
const sessionKey = 'turn-key';
const mk = (response: string): OpenclawHandlerInput => {
const extracted: StopExtracted = {
cwd: '/work',
sessionId: provScope('chan-7'),
response,
parent: undefined,
};
const event: InternalHookEventLike = { type: 'message', action: 'sent', sessionKey };
return { event, extracted };
};
// Fire three message:sent rapidly (same turn). The first two are superseded.
const p1 = handler.handle(mk('partial reply 1'), ctxFor(bridge));
const p2 = handler.handle(mk('partial reply 2'), ctxFor(bridge));
const p3 = handler.handle(mk('FINAL DECISION: we will ship the feature on Friday'), ctxFor(bridge));
// Superseded dispatches resolve immediately (fail-open: a hook must never block the host).
await Promise.all([p1, p2]);
expect(bridge.saved).toHaveLength(0); // nothing saved yet — still debouncing
// Advance past the debounce window; the last save fires.
await vi.advanceTimersByTimeAsync(800);
await p3;
// Exactly one frame saved, carrying the LAST payload.
expect(bridge.saved).toHaveLength(1);
const frame = bridge.saved[0];
expect(frame.content).toContain('FINAL DECISION');
expect(frame.content).not.toContain('partial reply 1');
// Stop frames are important/critical (never temporary), provenance-scoped.
expect(['important', 'critical']).toContain(frame.importance);
expect(frame.scope).toBe(provScope('chan-7'));
expect(frame.source).toBe('openclaw');
} finally {
vi.useRealTimers();
}
});
});
describe('makeOpenclawHandler — PreCompact matches on the action SUFFIX, not the joined string', () => {
it('fires cleanup_frames when the runtime action is "compact:before" (session: prefix dropped at runtime)', async () => {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
// Runtime event: type 'session', action 'compact:before' — joined would be
// 'session:compact:before' (matches), but the suffix-match is what the
// design relies on (CORRECTION 2). Use a non-joined type to prove suffix.
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
await handler.handle({ event, extracted: { scope: provScope('chan-7') } }, ctxFor(bridge));
expect(bridge.cleanups).toBe(1);
});
it('ignores an unmapped (type, action) pair', async () => {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const event: InternalHookEventLike = { type: 'tool', action: 'invoked' };
await handler.handle({ event, extracted: { scope: undefined } }, ctxFor(bridge));
expect(bridge.saved).toHaveLength(0);
expect(bridge.cleanups).toBe(0);
expect(bridge.recalls).toBe(0);
});
});
describe('§7.3 invariant 1 — FAIL-OPEN (handler swallows a bridge error, never throws)', () => {
it('a save error during message:received resolves (never rejects)', async () => {
const bridge = makeMockBridge({ throwOn: 'save' });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: UserPromptExtracted = { prompt: 'p', cwd: '/w', sessionId: provScope('c') };
const event: InternalHookEventLike = { type: 'message', action: 'received' };
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
expect(bridge.saved).toHaveLength(0);
});
it('a recall error during agent:bootstrap resolves (never rejects)', async () => {
const bridge = makeMockBridge({ throwOn: 'recall' });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: SessionStartExtracted = { cwd: '/w', sessionId: provScope('c'), recallLimit: 20 };
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
});
it('a cleanup error during compact:before resolves (never rejects)', async () => {
const bridge = makeMockBridge({ throwOn: 'cleanup' });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
await expect(handler.handle({ event, extracted: { scope: provScope('c') } }, ctxFor(bridge)))
.resolves.toBeUndefined();
});
});
// ── the package's own default-export handler (in-process entrypoint) ─────
describe('openclawHook default export — fail-open over the live bridge path', () => {
it('never throws on a garbage event (no matching lifecycle, no env CLI configured)', async () => {
const { default: openclawHook } = await import('../src/handler.js');
// Unmapped event — must resolve to undefined without touching any CLI.
await expect(openclawHook({ type: 'noop', action: 'noop' })).resolves.toBeUndefined();
});
it('never throws even when the recall path would fail (no real hive-mind-cli on PATH)', async () => {
const { default: openclawHook } = await import('../src/handler.js');
// agent:bootstrap drives recall through a real (unconfigured) bridge; the
// handler's try/catch must swallow any failure and resolve.
const event = { type: 'agent', action: 'bootstrap', context: { channelId: 'c', bootstrapFiles: [] } };
await expect(openclawHook(event)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,246 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import JSON5 from 'json5';
import { install } from '../src/install.js';
import { HIVE_ENTRY_KEY, HOOKS_KEY } from '../src/json5-merger.js';
interface TestEnv {
home: string;
/** A fake compiled handler.js the installer COPIES into the managed hook dir. */
handlerSource: string;
configPath: string;
pointerPath: string;
hiveHookDir: string;
}
/** openclaw.json is OPTIONAL — `initial=undefined` exercises create-if-missing. */
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmocl-install-'));
const openclawDir = join(home, '.openclaw');
await mkdir(openclawDir, { recursive: true });
const configPath = join(openclawDir, 'openclaw.json');
if (initial !== undefined) {
await writeFile(configPath, initial, 'utf-8');
}
// Fake compiled handler — install copies this verbatim into the hook dir.
const distDir = join(home, 'fake-dist');
await mkdir(distDir, { recursive: true });
const handlerSource = join(distDir, 'handler.js');
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
return {
home,
handlerSource,
configPath,
pointerPath: join(openclawDir, 'hive-mind-install.json'),
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
};
}
function readPointer(p: string): Promise<Record<string, unknown>> {
return readFile(p, 'utf-8').then((s) => JSON.parse(s) as Record<string, unknown>);
}
function internalEntries(config: Record<string, unknown>): Record<string, unknown> {
const hooks = config[HOOKS_KEY] as Record<string, Record<string, unknown>> | undefined;
const internal = hooks?.['internal'] as Record<string, unknown> | undefined;
return (internal?.['entries'] as Record<string, unknown> | undefined) ?? {};
}
describe('install (openclaw)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws when the compiled handler.js source is missing (build first)', async () => {
env = await bootstrap(undefined);
await rm(env.handlerSource, { force: true });
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
.rejects.toThrow(/compiled handler not found/i);
});
it('throws on a config.json that parses as neither JSON nor JSON5', async () => {
env = await bootstrap('{ : : : not valid : : : }');
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
.rejects.toThrow(/parse/i);
});
// ── pre-existed branch ────────────────────────────────────────────────
it('writes a LITERAL byte-identical backup before mutating a pre-existing openclaw.json', async () => {
// Comments + trailing commas that a JSON5 round-trip would NOT preserve —
// proves the backup is the original bytes, not a re-serialized merge.
const initial = [
'{',
' // my openclaw config',
' model: "opus", // keep me',
' hooks: { internal: { enabled: false, entries: {} } },',
'}',
'',
].join('\n');
env = await bootstrap(initial);
const original = await readFile(env.configPath, 'utf-8');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.backupPath).not.toBeNull();
const backupContent = await readFile(result.backupPath as string, 'utf-8');
expect(backupContent).toBe(original);
// The backup preserves the comment that JSON re-serialization drops.
expect(backupContent).toContain('// my openclaw config');
});
it('records created_by_us=false + settings_backup when openclaw.json pre-existed', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.createdByUs).toBe(false);
const pointer = await readPointer(result.pointerPath);
expect(pointer['created_by_us']).toBe(false);
expect(pointer['settings_backup']).toBe(result.backupPath);
});
it('minimal-touch: flips internal.enabled + adds hive entry, preserving user keys + entries', async () => {
const initial = [
'{',
' model: "opus",',
' hooks: { internal: { enabled: false, entries: { "user-own": { enabled: true } } } },',
'}',
].join('\n');
env = await bootstrap(initial);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const ents = internalEntries(after);
// Our entry added, the user entry preserved verbatim.
expect((ents[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
expect(ents['user-own']).toEqual({ enabled: true });
// Subsystem turned on, user top-level key preserved.
const hooks = after[HOOKS_KEY] as Record<string, Record<string, unknown>>;
expect(hooks['internal']['enabled']).toBe(true);
expect(after['model']).toBe('opus');
});
// ── create-if-missing branch ──────────────────────────────────────────
it('creates openclaw.json with the hive entry when it is absent', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(env.configPath)).toBe(true);
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
expect((internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
expect(result.createdByUs).toBe(true);
});
it('records created_by_us=true and writes NO backup when openclaw.json is absent', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.backupPath).toBeNull();
const pointer = await readPointer(result.pointerPath);
expect(pointer['created_by_us']).toBe(true);
expect(pointer['settings_backup']).toBeNull();
});
// ── managed hook DIR (in-process model — no per-event scripts) ─────────
it('writes the managed hook DIR with HOOK.md + a byte-identical copy of handler.js', async () => {
env = await bootstrap(undefined);
const handlerBytes = await readFile(env.handlerSource, 'utf-8');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.hookDir).toBe(env.hiveHookDir);
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
// handler.js is copied verbatim from dist.
expect(await readFile(join(env.hiveHookDir, 'handler.js'), 'utf-8')).toBe(handlerBytes);
// HOOK.md declares the four events incl. the prefixed compaction key.
const hookMd = await readFile(join(env.hiveHookDir, 'HOOK.md'), 'utf-8');
expect(hookMd).toContain('agent:bootstrap');
expect(hookMd).toContain('message:received');
expect(hookMd).toContain('message:sent');
expect(hookMd).toContain('session:compact:before');
});
// Spawns two child Node processes (esbuild bundle of the handler + import of
// the produced bundle). Standalone this takes <1s, but full-suite runs
// saturate the CPU (forks pool, 4 workers) and the spawns can exceed vitest's
// 30s default testTimeout (observed 2026-07-15 full-suite flake,
// standalone-green). 60s per-test timeout, same class as f322cc2c.
it('copies a self-contained handler that imports and runs with NODE_PATH empty', async () => {
env = await bootstrap(undefined);
const buildScript = fileURLToPath(new URL('../scripts/build-handler.mjs', import.meta.url));
const handlerEntry = fileURLToPath(new URL('../src/handler.ts', import.meta.url));
execFileSync(process.execPath, [buildScript, handlerEntry, env.handlerSource], {
cwd: env.home,
stdio: 'pipe',
});
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const installedHandler = join(env.hiveHookDir, 'handler.js');
const installedUrl = pathToFileURL(installedHandler).href;
const runInstalledHandler = [
`const module = await import(${JSON.stringify(installedUrl)});`,
`if (typeof module.default !== 'function') throw new Error('default export is not a function');`,
`await module.default({ type: 'noop', action: 'noop' });`,
].join('\n');
// Both the handler path and cwd are outside the repository/package tree.
// Any remaining @waggle import would fail with this empty resolution path.
execFileSync(process.execPath, ['--input-type=module', '--eval', runInstalledHandler], {
cwd: env.home,
env: { ...process.env, NODE_PATH: '' },
stdio: 'pipe',
});
}, 60_000);
// ── pointer + lifecycles + cli-path ───────────────────────────────────
it('drops a pointer recording the four lifecycles + touched keys + hook dir name', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(result.pointerPath)).toBe(true);
const pointer = await readPointer(result.pointerPath);
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
expect(pointer['hooks_dir']).toBe(env.hiveHookDir);
expect(typeof pointer['version']).toBe('string');
const extra = pointer['extra'] as Record<string, unknown>;
expect(extra['hook_dir_name']).toBe('hive-mind');
expect((extra['touched_keys'] as string[]).sort()).toEqual([
'hooks.internal.enabled',
'hooks.internal.entries.hive-mind',
]);
expect([...result.touchedKeys].sort()).toEqual([
'hooks.internal.enabled',
'hooks.internal.entries.hive-mind',
]);
});
it('respects a custom now() for a deterministic backup filename', async () => {
env = await bootstrap('{ "hooks": {} }');
const fixedTs = '2026-04-28T10:30:45.123Z';
const result = await install({
home: env.home,
handlerSourcePath: env.handlerSource,
now: () => new Date(fixedTs),
});
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
const stats = await stat(result.backupPath as string);
expect(stats.isFile()).toBe(true);
});
it('threads --cli-path into the entry env + records it in the pointer', async () => {
env = await bootstrap(undefined);
const cliPath = '/abs/path/to/dist/index.js';
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource, cliPath });
expect(result.cliPath).toBe(cliPath);
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const hive = internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect((hive['env'] as Record<string, unknown>)['WAGGLE_HIVE_MIND_CLI']).toBe(cliPath);
const pointer = await readPointer(result.pointerPath);
expect(pointer['cli_path']).toBe(cliPath);
});
});

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest';
import JSON5 from 'json5';
import {
HIVE_ENTRY_KEY,
HOOKS_KEY,
hasHiveEntries,
jsonRegister,
jsonUnregister,
parseConfig,
serializeConfig,
} from '../src/json5-merger.js';
function internal(config: Record<string, unknown>): Record<string, unknown> {
const hooks = config[HOOKS_KEY] as Record<string, unknown> | undefined;
return (hooks?.['internal'] as Record<string, unknown> | undefined) ?? {};
}
function entries(config: Record<string, unknown>): Record<string, unknown> {
return (internal(config)['entries'] as Record<string, unknown> | undefined) ?? {};
}
describe('parseConfig (openclaw JSON5 codec — strict JSON first, JSON5 fallback)', () => {
it('returns {} for an empty / whitespace-only config (create-if-missing)', () => {
expect(parseConfig('')).toEqual({});
expect(parseConfig(' \n ')).toEqual({});
});
it('parses strict JSON (the fast path)', () => {
const parsed = parseConfig('{"model":"opus","hooks":{}}');
expect(parsed['model']).toBe('opus');
});
it('parses a COMMENTED openclaw.json with trailing commas via the JSON5 fallback', () => {
const raw = [
'{',
' // OpenClaw gateway config — hand-edited, comments matter',
' model: "claude-opus", /* the good one */',
' hooks: {',
' internal: {',
' enabled: false,',
' entries: {',
" 'user-own': { enabled: true }, // trailing comma below",
' },',
' },',
' },',
'}',
].join('\n');
const parsed = parseConfig(raw);
expect(parsed['model']).toBe('claude-opus');
const userEntry = entries(parsed)['user-own'] as Record<string, unknown>;
expect(userEntry['enabled']).toBe(true);
});
it('throws when BOTH strict JSON and JSON5 fail (installer fails loudly, never clobbers)', () => {
expect(() => parseConfig('{ this : : : is not valid }')).toThrow(/parse/i);
});
it('treats a top-level scalar/array JSON5 doc as empty (not a crash)', () => {
expect(parseConfig('"just a string"')).toEqual({});
expect(parseConfig('[1, 2, 3]')).toEqual({});
});
});
describe('serializeConfig (openclaw — strict JSON, valid for OpenClaw JSON5 reader)', () => {
it('serializes to 2-space JSON with a trailing newline that re-parses', () => {
const text = serializeConfig(jsonRegister({ model: 'x' }));
expect(text.endsWith('\n')).toBe(true);
const reparsed = JSON5.parse(text) as Record<string, unknown>;
expect(reparsed['model']).toBe('x');
expect(hasHiveEntries(reparsed)).toBe(true);
});
});
describe('jsonRegister (openclaw minimal-touch — flip enabled + add hive entry)', () => {
it('returns a NEW object — does not mutate input (immutability contract)', () => {
const original: Record<string, unknown> = { hooks: { internal: { enabled: false } } };
const merged = jsonRegister(original);
expect(merged).not.toBe(original);
// Input untouched — enabled is still false on the original.
expect((original['hooks'] as Record<string, Record<string, unknown>>)['internal']['enabled']).toBe(false);
});
it('flips hooks.internal.enabled=true and adds entries["hive-mind"]={enabled:true}', () => {
const merged = jsonRegister({});
expect(internal(merged)['enabled']).toBe(true);
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect(hive).toEqual({ enabled: true });
});
it('attaches env to the hive entry when supplied', () => {
const merged = jsonRegister({}, { env: { WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' } });
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect(hive['env']).toEqual({ WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' });
});
it('does NOT clobber other internal entries or other internal keys (minimal-touch)', () => {
const existing: Record<string, unknown> = {
model: 'opus',
hooks: {
internal: {
enabled: false,
throttleMs: 250,
entries: { 'user-own': { enabled: true, foo: 'bar' } },
},
external: { whatever: 1 },
},
};
const merged = jsonRegister(existing);
// user entry preserved verbatim.
expect(entries(merged)['user-own']).toEqual({ enabled: true, foo: 'bar' });
// sibling internal key preserved.
expect(internal(merged)['throttleMs']).toBe(250);
// sibling hooks subtree preserved.
expect((merged['hooks'] as Record<string, unknown>)['external']).toEqual({ whatever: 1 });
// unrelated top-level key preserved.
expect(merged['model']).toBe('opus');
// and our entry was added + subsystem turned on.
expect(internal(merged)['enabled']).toBe(true);
expect(hasHiveEntries(merged)).toBe(true);
});
it('replaces OUR entry in place on re-install (idempotent — never duplicated)', () => {
const merged1 = jsonRegister({}, { env: { A: '1' } });
const merged2 = jsonRegister(merged1, { env: { A: '2' } });
const hive = entries(merged2)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect(hive['env']).toEqual({ A: '2' });
// Exactly one hive-mind entry key.
expect(Object.keys(entries(merged2)).filter((k) => k === HIVE_ENTRY_KEY)).toHaveLength(1);
});
});
describe('jsonUnregister (openclaw — diagnostics / backup-less path)', () => {
it('strips our hive-mind entry but preserves other entries + leaves enabled as-is', () => {
const withUser: Record<string, unknown> = {
hooks: { internal: { enabled: true, entries: { 'user-own': { enabled: true } } } },
};
const merged = jsonRegister(withUser);
expect(hasHiveEntries(merged)).toBe(true);
const stripped = jsonUnregister(merged);
expect(hasHiveEntries(stripped)).toBe(false);
// User entry survives.
expect(entries(stripped)['user-own']).toEqual({ enabled: true });
// Minimal-touch: we do NOT flip enabled back off (other hooks may rely on it).
expect(internal(stripped)['enabled']).toBe(true);
});
it('returns a NEW object and leaves the input untouched (immutability)', () => {
const merged = jsonRegister({});
const stripped = jsonUnregister(merged);
expect(stripped).not.toBe(merged);
expect(hasHiveEntries(merged)).toBe(true); // original still has the entry
});
it('is a no-op (new object) when there is no hooks/internal block', () => {
const stripped = jsonUnregister({ model: 'x' });
expect(stripped['model']).toBe('x');
expect(hasHiveEntries(stripped)).toBe(false);
});
});
describe('hasHiveEntries (openclaw structural marker)', () => {
it('false on empty / hookless / hive-less configs', () => {
expect(hasHiveEntries(undefined)).toBe(false);
expect(hasHiveEntries({})).toBe(false);
expect(hasHiveEntries({ hooks: {} })).toBe(false);
expect(hasHiveEntries({ hooks: { internal: { enabled: true, entries: {} } } })).toBe(false);
expect(hasHiveEntries({ hooks: { internal: { entries: { 'user-own': {} } } } })).toBe(false);
});
it('true once our hive-mind entry is present', () => {
expect(hasHiveEntries(jsonRegister({}))).toBe(true);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
allHookBasenames,
backupPathFor,
hookCommandFor,
resolvePaths,
HIVE_HOOK_DIR_NAME,
HIVE_HOOK_ENTRY_KEY,
} from '../src/paths.js';
describe('resolvePaths (openclaw)', () => {
it('places openclaw.json + pointer under <home>/.openclaw/', () => {
const home = resolve('/fake/home');
const paths = resolvePaths({ home, handlerSourcePath: resolve('/some/dist/handler.js') });
expect(paths.openclawDir).toBe(join(home, '.openclaw'));
expect(paths.configPath).toBe(join(home, '.openclaw', 'openclaw.json'));
expect(paths.pointerPath).toBe(join(home, '.openclaw', 'hive-mind-install.json'));
});
it('resolves the managed hook DIRECTORY (not per-event scripts) under ~/.openclaw/hooks/', () => {
const home = resolve('/h');
const paths = resolvePaths({ home, handlerSourcePath: resolve('/d/handler.js') });
// OpenClaw is in-process: one managed dir holding HOOK.md + handler.js,
// NOT four separate compiled hook scripts.
expect(paths.hooksRoot).toBe(join(home, '.openclaw', 'hooks'));
expect(paths.hiveHookDir).toBe(join(home, '.openclaw', 'hooks', HIVE_HOOK_DIR_NAME));
expect(paths.hookMdPath).toBe(join(paths.hiveHookDir, 'HOOK.md'));
expect(paths.installedHandlerPath).toBe(join(paths.hiveHookDir, 'handler.js'));
});
it('handlerSourcePath override wins over moduleUrl', () => {
const explicit = resolve('/x/y/handler.js');
const paths = resolvePaths({
home: resolve('/h'),
handlerSourcePath: explicit,
moduleUrl: 'file:///irrelevant/dist/install.js',
});
expect(paths.handlerSourcePath).toBe(explicit);
});
it('derives the self-contained handler bundle sibling from moduleUrl', () => {
const moduleUrl = pathToFileURL(resolve('/pkg/dist/install.js')).href;
const paths = resolvePaths({ home: resolve('/h'), moduleUrl });
expect(paths.handlerSourcePath).toBe(resolve('/pkg/dist/handler.bundle.cjs'));
});
it('falls back to cwd/dist/handler.bundle.cjs when neither override is given', () => {
const paths = resolvePaths({ home: resolve('/h') });
expect(paths.handlerSourcePath).toBe(resolve(process.cwd(), 'dist', 'handler.bundle.cjs'));
});
});
describe('allHookBasenames (openclaw — FOUR lifecycles incl. pre-compact)', () => {
it('names the four lifecycles the single handler dispatches (bookkeeping, not separate files)', () => {
expect([...allHookBasenames()].sort()).toEqual([
'pre-compact',
'session-start',
'stop',
'user-prompt-submit',
]);
// Unlike hermes, openclaw DOES carry a compaction lifecycle
// (session:compact:before → runtime action compact:before).
expect([...allHookBasenames()]).toContain('pre-compact');
});
});
describe('exported entry/dir name constants', () => {
it('the managed dir name and the logical entry key are both "hive-mind"', () => {
expect(HIVE_HOOK_DIR_NAME).toBe('hive-mind');
expect(HIVE_HOOK_ENTRY_KEY).toBe('hive-mind');
});
});
describe('re-exported shared Windows-safe helpers', () => {
it('backupPathFor replaces colons and dots in the timestamp for filesystem safety', () => {
const backup = backupPathFor('/h/.openclaw/openclaw.json', '2026-04-28T10:30:45.123Z');
expect(backup).toBe('/h/.openclaw/openclaw.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
});
it('hookCommandFor produces a quoted node invocation and appends --cli-path', () => {
const cmd = hookCommandFor(resolve('/abs/dist/handler.js'), '/abs/cli/dist/index.js');
expect(cmd).toMatch(/^node "[^"]+handler\.js"/);
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
});
});

View File

@@ -0,0 +1,145 @@
import { describe, expect, it, afterEach } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { install } from '../src/install.js';
import { uninstall } from '../src/uninstall.js';
interface TestEnv {
home: string;
handlerSource: string;
configPath: string;
pointerPath: string;
hiveHookDir: string;
}
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmocl-uninstall-'));
const openclawDir = join(home, '.openclaw');
await mkdir(openclawDir, { recursive: true });
const configPath = join(openclawDir, 'openclaw.json');
if (initial !== undefined) {
await writeFile(configPath, initial, 'utf-8');
}
const distDir = join(home, 'fake-dist');
await mkdir(distDir, { recursive: true });
const handlerSource = join(distDir, 'handler.js');
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
return {
home,
handlerSource,
configPath,
pointerPath: join(openclawDir, 'hive-mind-install.json'),
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
};
}
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf-8').digest('hex');
}
describe('uninstall (openclaw)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws when no pointer file exists', async () => {
env = await bootstrap('{ "hooks": {} }');
await expect(uninstall({ home: env.home, handlerSourcePath: env.handlerSource }))
.rejects.toThrow(/pointer/);
});
// ── created_by_us=false: LITERAL byte-identical restore (§7.3 invariant 2) ─
// JSON5 round-trip is lossy (comments + trailing commas are dropped on
// re-serialize), so reversibility relies on restoring the ORIGINAL BYTES.
it('install + uninstall round-trip is SHA-256 identical to pre-install state (comments preserved)', async () => {
// Comments + trailing commas a naive JSON re-serialize would NOT reproduce.
const initial = [
'{',
' // OpenClaw config — hand-edited, comments matter',
' model: "claude-opus", /* the good one */',
' temperature: 0.2,',
' hooks: {',
' internal: {',
' enabled: false,',
' entries: { "user-own": { enabled: true } },',
' },',
' },',
'}',
'',
].join('\n');
env = await bootstrap(initial);
const preInstall = await readFile(env.configPath, 'utf-8');
const preHash = sha256(preInstall);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const afterInstall = await readFile(env.configPath, 'utf-8');
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
// Sanity: the merged write IS lossy — the comment is gone post-install,
// which is exactly why we need the literal backup to reverse it.
expect(afterInstall).not.toContain('// OpenClaw config');
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.createdRemoved).toBe(false);
expect(u.restoredFrom).not.toBeNull();
expect(u.hookDirRemoved).toBe(true);
const afterUninstall = await readFile(env.configPath, 'utf-8');
// Byte-for-byte identical — the comment + trailing commas are back.
expect(sha256(afterUninstall)).toBe(preHash);
expect(afterUninstall).toBe(preInstall);
expect(afterUninstall).toContain('// OpenClaw config — hand-edited, comments matter');
});
it('removes the managed hook dir (HOOK.md + handler.js) on uninstall — no orphan', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.hookDirRemoved).toBe(true);
expect(existsSync(env.hiveHookDir)).toBe(false);
});
it('removes backup + pointer by default after a restore', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(result.backupPath as string)).toBe(true);
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.backupRemoved).toBe(true);
expect(existsSync(result.backupPath as string)).toBe(false);
expect(existsSync(result.pointerPath)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource, cleanupBackup: false });
expect(u.backupRemoved).toBe(false);
expect(existsSync(result.backupPath as string)).toBe(true);
});
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
it('deletes the openclaw.json we created and leaves NO orphan (absent → install → uninstall)', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.createdByUs).toBe(true);
expect(existsSync(env.configPath)).toBe(true);
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.createdRemoved).toBe(true);
expect(u.restoredFrom).toBeNull();
// No orphaned config, no managed dir, no leftover pointer.
expect(existsSync(env.configPath)).toBe(false);
expect(existsSync(env.hiveHookDir)).toBe(false);
expect(existsSync(env.pointerPath)).toBe(false);
});
});

View File

@@ -0,0 +1,170 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ChildProcess } from 'node:child_process';
import { install } from '../src/install.js';
import { verify } from '../src/verify.js';
interface TestEnv {
home: string;
handlerSource: string;
configPath: string;
}
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmocl-verify-'));
const openclawDir = join(home, '.openclaw');
await mkdir(openclawDir, { recursive: true });
const configPath = join(openclawDir, 'openclaw.json');
if (initial !== undefined) {
await writeFile(configPath, initial, 'utf-8');
}
const distDir = join(home, 'fake-dist');
await mkdir(distDir, { recursive: true });
const handlerSource = join(distDir, 'handler.js');
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
return { home, handlerSource, configPath };
}
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
const emitter = new EventEmitter();
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
const child = Object.assign(emitter, {
stdout,
stderr,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
setImmediate(() => emitter.emit('exit', opts.exitCode));
return child;
}) as unknown as typeof import('node:child_process').spawn;
}
describe('verify (openclaw)', () => {
const envs: TestEnv[] = [];
afterEach(async () => {
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
});
it('reports failure when openclaw.json is missing', async () => {
const env = await bootstrap(undefined);
envs.push(env);
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks[0].name).toBe('openclaw.json exists');
expect(result.checks[0].ok).toBe(false);
});
it('reports failure when the hive entry is not yet installed', async () => {
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
envs.push(env);
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.some((c) => !c.ok && c.name.includes('hive-mind internal-hooks entry'))).toBe(true);
});
it('passes after install — entry present, subsystem enabled, dir+HOOK.md+handler on disk, CLI reachable', async () => {
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
envs.push(env);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(true);
expect(result.checks.find((c) => c.name.includes('hive-mind internal-hooks entry'))?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'internal hooks subsystem enabled')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'managed hook dir exists')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'HOOK.md readable on disk')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(true);
});
it('flags the activation advisory (FAIL) when internal.enabled is false even with the entry present', async () => {
// Entry present but subsystem OFF — hooks are inert until opted in (§5.5/§6.2).
const config = '{ "hooks": { "internal": { "enabled": false, "entries": { "hive-mind": { "enabled": true } } } } }';
const env = await bootstrap(config);
envs.push(env);
// Write the managed dir so only the activation check is at fault.
await install({ home: env.home, handlerSourcePath: env.handlerSource });
// Re-disable the subsystem post-install to isolate the advisory.
await writeFile(env.configPath, config, 'utf-8');
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
const enabledCheck = result.checks.find((c) => c.name === 'internal hooks subsystem enabled');
expect(enabledCheck?.ok).toBe(false);
expect(enabledCheck?.detail?.toLowerCase()).toContain('hooks are off');
expect(result.ok).toBe(false);
});
it('reports CLI unreachable when the spawn exits non-zero', async () => {
const env = await bootstrap('{ "hooks": {} }');
envs.push(env);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
});
expect(result.ok).toBe(false);
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(false);
});
it('flags a missing handler.js even when the config entry is present', async () => {
const env = await bootstrap('{ "hooks": {} }');
envs.push(env);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
// Remove the installed handler from the managed dir.
await rm(join(env.home, '.openclaw', 'hooks', 'hive-mind', 'handler.js'), { force: true });
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(false);
});
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
const env = await bootstrap('{ "hooks": {} }');
envs.push(env);
const cliPath = '/abs/from/pointer.js';
await install({ home: env.home, handlerSourcePath: env.handlerSource, cliPath });
const records: Array<{ command: string; args: readonly string[] }> = [];
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
records.push({ command: cmd, args });
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
}) as typeof import('node:child_process').spawn;
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: recordingSpawn,
});
expect(result.ok).toBe(true);
const probeRecord = records[records.length - 1];
expect(probeRecord.command).toBe(process.execPath);
expect(probeRecord.args[0]).toBe(cliPath);
expect(probeRecord.args[1]).toBe('--help');
// Sanity: the pinned cli path was actually written into the pointer.
const pointer = JSON.parse(await readFile(join(env.home, '.openclaw', 'hive-mind-install.json'), 'utf-8')) as Record<string, unknown>;
expect(pointer['cli_path']).toBe(cliPath);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"],
"references": [
{ "path": "../hive-mind-hooks-core" },
{ "path": "../hive-mind-shim-core" }
]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist/**", "node_modules/**"]
}