feat: add internal hooks system

This commit is contained in:
Peter Steinberger
2026-01-17 01:31:39 +00:00
parent a76cbc43bb
commit faba508fe0
39 changed files with 4241 additions and 28 deletions

1
.gitignore vendored
View File

@@ -57,3 +57,4 @@ apps/ios/*.mobileprovision
.vscode/
IDENTITY.md
USER.md
.tgz

View File

@@ -7,6 +7,7 @@
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
- Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
### Breaking
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.

View File

@@ -1,6 +1,7 @@
---
summary: "CLI reference for `clawdbot hooks` (Gmail Pub/Sub + webhook helpers)"
summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)"
read_when:
- You want to manage internal agent hooks
- You want to wire Gmail Pub/Sub events into Clawdbot hooks
- You want to run the gog watch service and renew loop
---
@@ -10,9 +11,212 @@ read_when:
Webhook helpers and hook-based integrations.
Related:
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
- Webhooks: [Webhook](/automation/webhook)
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
## Internal Hooks
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
### List All Hooks
```bash
clawdbot hooks internal list
```
List all discovered internal hooks from workspace, managed, and bundled directories.
**Options:**
- `--eligible`: Show only eligible hooks (requirements met)
- `--json`: Output as JSON
- `-v, --verbose`: Show detailed information including missing requirements
**Example output:**
```
Internal Hooks (2/2 ready)
Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
```
**Example (verbose):**
```bash
clawdbot hooks internal list --verbose
```
Shows missing requirements for ineligible hooks.
**Example (JSON):**
```bash
clawdbot hooks internal list --json
```
Returns structured JSON for programmatic use.
### Get Hook Information
```bash
clawdbot hooks internal info <name>
```
Show detailed information about a specific hook.
**Arguments:**
- `<name>`: Hook name (e.g., `session-memory`)
**Options:**
- `--json`: Output as JSON
**Example:**
```bash
clawdbot hooks internal info session-memory
```
**Output:**
```
💾 session-memory ✓ Ready
Save session context to memory when /new command is issued
Details:
Source: clawdbot-bundled
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
Homepage: https://docs.clawd.bot/internal-hooks#session-memory
Events: command:new
Requirements:
Config: ✓ workspace.dir
```
### Check Hooks Eligibility
```bash
clawdbot hooks internal check
```
Show summary of hook eligibility status (how many are ready vs. not ready).
**Options:**
- `--json`: Output as JSON
**Example output:**
```
Internal Hooks Status
Total hooks: 2
Ready: 2
Not ready: 0
```
### Enable a Hook
```bash
clawdbot hooks internal enable <name>
```
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
**Arguments:**
- `<name>`: Hook name (e.g., `session-memory`)
**Example:**
```bash
clawdbot hooks internal enable session-memory
```
**Output:**
```
✓ Enabled hook: 💾 session-memory
```
**What it does:**
- Checks if hook exists and is eligible
- Updates `hooks.internal.entries.<name>.enabled = true` in your config
- Saves config to disk
**After enabling:**
- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev).
### Disable a Hook
```bash
clawdbot hooks internal disable <name>
```
Disable a specific hook by updating your config.
**Arguments:**
- `<name>`: Hook name (e.g., `command-logger`)
**Example:**
```bash
clawdbot hooks internal disable command-logger
```
**Output:**
```
⏸ Disabled hook: 📝 command-logger
```
**After disabling:**
- Restart the gateway so hooks reload
## Bundled Hooks
### session-memory
Saves session context to memory when you issue `/new`.
**Enable:**
```bash
clawdbot hooks internal enable session-memory
```
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
**See:** [session-memory documentation](/internal-hooks#session-memory)
### command-logger
Logs all command events to a centralized audit file.
**Enable:**
```bash
clawdbot hooks internal enable command-logger
```
**Output:** `~/.clawdbot/logs/commands.log`
**View logs:**
```bash
# Recent commands
tail -n 20 ~/.clawdbot/logs/commands.log
# Pretty-print
cat ~/.clawdbot/logs/commands.log | jq .
# Filter by action
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
```
**See:** [command-logger documentation](/internal-hooks#command-logger)
## Gmail
```bash
@@ -20,3 +224,4 @@ clawdbot hooks gmail setup --account you@example.com
clawdbot hooks gmail run
```
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.

776
docs/internal-hooks.md Normal file
View File

@@ -0,0 +1,776 @@
---
summary: "Internal agent hooks: event-driven automation for commands and lifecycle events"
read_when:
- You want event-driven automation for /new, /reset, /stop, and agent lifecycle events
- You want to build, install, or debug internal hooks
---
# Internal Agent Hooks
Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
## Overview
The internal hooks system allows you to:
- Save session context to memory when `/new` is issued
- Log all commands for auditing
- Trigger custom automations on agent lifecycle events
- Extend Clawdbot's behavior without modifying core code
## Getting Started
### Bundled Hooks
Clawdbot ships with two bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
List available hooks:
```bash
clawdbot hooks internal list
```
Enable a hook:
```bash
clawdbot hooks internal enable session-memory
```
Check hook status:
```bash
clawdbot hooks internal check
```
Get detailed information:
```bash
clawdbot hooks internal info session-memory
```
### Onboarding
During onboarding (`clawdbot onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection.
## Hook Discovery
Hooks are automatically discovered from three directories (in order of precedence):
1. **Workspace hooks**: `<workspace>/hooks/` (per-agent, highest precedence)
2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces)
3. **Bundled hooks**: `<clawdbot>/dist/hooks/bundled/` (shipped with Clawdbot)
Each hook is a directory containing:
```
my-hook/
├── HOOK.md # Metadata + documentation
└── handler.ts # Handler implementation
```
## Hook Structure
### HOOK.md Format
The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documentation:
```markdown
---
name: my-hook
description: "Short description of what this hook does"
homepage: https://docs.clawd.bot/internal-hooks#my-hook
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
---
# My Hook
Detailed documentation goes here...
## What It Does
- Listens for `/new` commands
- Performs some action
- Logs the result
## Requirements
- Node.js must be installed
## Configuration
No configuration needed.
```
### Metadata Fields
The `metadata.clawdbot` object supports:
- **`emoji`**: Display emoji for CLI (e.g., `"💾"`)
- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`)
- **`export`**: Named export to use (defaults to `"default"`)
- **`homepage`**: Documentation URL
- **`requires`**: Optional requirements
- **`bins`**: Required binaries on PATH (e.g., `["git", "node"]`)
- **`anyBins`**: At least one of these binaries must be present
- **`env`**: Required environment variables
- **`config`**: Required config paths (e.g., `["workspace.dir"]`)
- **`os`**: Required platforms (e.g., `["darwin", "linux"]`)
- **`always`**: Bypass eligibility checks (boolean)
- **`install`**: Installation methods (for bundled hooks: `[{"id":"bundled","kind":"bundled"}]`)
### Handler Implementation
The `handler.ts` file exports an `InternalHookHandler` function:
```typescript
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
const myHandler: InternalHookHandler = async (event) => {
// Only trigger on 'new' command
if (event.type !== 'command' || event.action !== 'new') {
return;
}
console.log(`[my-hook] New command triggered`);
console.log(` Session: ${event.sessionKey}`);
console.log(` Timestamp: ${event.timestamp.toISOString()}`);
// Your custom logic here
// Optionally send message to user
event.messages.push('✨ My hook executed!');
};
export default myHandler;
```
#### Event Context
Each event includes:
```typescript
{
type: 'command' | 'session' | 'agent',
action: string, // e.g., 'new', 'reset', 'stop'
sessionKey: string, // Session identifier
timestamp: Date, // When the event occurred
messages: string[], // Push messages here to send to user
context: {
sessionEntry?: SessionEntry,
sessionId?: string,
sessionFile?: string,
commandSource?: string, // e.g., 'whatsapp', 'telegram'
senderId?: string,
cfg?: ClawdbotConfig
}
}
```
## Event Types
### Command Events
Triggered when agent commands are issued:
- **`command`**: All command events (general listener)
- **`command:new`**: When `/new` command is issued
- **`command:reset`**: When `/reset` command is issued
- **`command:stop`**: When `/stop` command is issued
### Future Events
Planned event types:
- **`session:start`**: When a new session begins
- **`session:end`**: When a session ends
- **`agent:error`**: When an agent encounters an error
- **`message:sent`**: When a message is sent
- **`message:received`**: When a message is received
## Creating Custom Hooks
### 1. Choose Location
- **Workspace hooks** (`<workspace>/hooks/`): Per-agent, highest precedence
- **Managed hooks** (`~/.clawdbot/hooks/`): Shared across workspaces
### 2. Create Directory Structure
```bash
mkdir -p ~/.clawdbot/hooks/my-hook
cd ~/.clawdbot/hooks/my-hook
```
### 3. Create HOOK.md
```markdown
---
name: my-hook
description: "Does something useful"
metadata: {"clawdbot":{"emoji":"🎯","events":["command:new"]}}
---
# My Custom Hook
This hook does something useful when you issue `/new`.
```
### 4. Create handler.ts
```typescript
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
const handler: InternalHookHandler = async (event) => {
if (event.type !== 'command' || event.action !== 'new') {
return;
}
console.log('[my-hook] Running!');
// Your logic here
};
export default handler;
```
### 5. Enable and Test
```bash
# Verify hook is discovered
clawdbot hooks internal list
# Enable it
clawdbot hooks internal enable my-hook
# Restart your gateway process (menu bar app restart on macOS, or restart your dev process)
# Trigger the event
# Send /new via your messaging channel
```
## Configuration
### New Config Format (Recommended)
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"session-memory": { "enabled": true },
"command-logger": { "enabled": false }
}
}
}
}
```
### Per-Hook Configuration
Hooks can have custom configuration:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"my-hook": {
"enabled": true,
"env": {
"MY_CUSTOM_VAR": "value"
}
}
}
}
}
}
```
### Extra Directories
Load hooks from additional directories:
```json
{
"hooks": {
"internal": {
"enabled": true,
"load": {
"extraDirs": ["/path/to/more/hooks"]
}
}
}
}
```
### Legacy Config Format (Still Supported)
The old config format still works for backwards compatibility:
```json
{
"hooks": {
"internal": {
"enabled": true,
"handlers": [
{
"event": "command:new",
"module": "./hooks/handlers/my-handler.ts",
"export": "default"
}
]
}
}
}
```
**Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks.
## CLI Commands
### List Hooks
```bash
# List all hooks
clawdbot hooks internal list
# Show only eligible hooks
clawdbot hooks internal list --eligible
# Verbose output (show missing requirements)
clawdbot hooks internal list --verbose
# JSON output
clawdbot hooks internal list --json
```
### Hook Information
```bash
# Show detailed info about a hook
clawdbot hooks internal info session-memory
# JSON output
clawdbot hooks internal info session-memory --json
```
### Check Eligibility
```bash
# Show eligibility summary
clawdbot hooks internal check
# JSON output
clawdbot hooks internal check --json
```
### Enable/Disable
```bash
# Enable a hook
clawdbot hooks internal enable session-memory
# Disable a hook
clawdbot hooks internal disable command-logger
```
## Bundled Hooks
### session-memory
Saves session context to memory when you issue `/new`.
**Events**: `command:new`
**Requirements**: `workspace.dir` must be configured
**Output**: `<workspace>/memory/YYYY-MM-DD-slug.md` (defaults to `~/clawd`)
**What it does**:
1. Uses the pre-reset session entry to locate the correct transcript
2. Extracts the last 15 lines of conversation
3. Uses LLM to generate a descriptive filename slug
4. Saves session metadata to a dated memory file
**Example output**:
```markdown
# Session: 2026-01-16 14:30:00 UTC
- **Session Key**: agent:main:main
- **Session ID**: abc123def456
- **Source**: telegram
```
**Filename examples**:
- `2026-01-16-vendor-pitch.md`
- `2026-01-16-api-design.md`
- `2026-01-16-1430.md` (fallback timestamp if slug generation fails)
**Enable**:
```bash
clawdbot hooks internal enable session-memory
```
### command-logger
Logs all command events to a centralized audit file.
**Events**: `command`
**Requirements**: None
**Output**: `~/.clawdbot/logs/commands.log`
**What it does**:
1. Captures event details (command action, timestamp, session key, sender ID, source)
2. Appends to log file in JSONL format
3. Runs silently in the background
**Example log entries**:
```jsonl
{"timestamp":"2026-01-16T14:30:00.000Z","action":"new","sessionKey":"agent:main:main","senderId":"+1234567890","source":"telegram"}
{"timestamp":"2026-01-16T15:45:22.000Z","action":"stop","sessionKey":"agent:main:main","senderId":"user@example.com","source":"whatsapp"}
```
**View logs**:
```bash
# View recent commands
tail -n 20 ~/.clawdbot/logs/commands.log
# Pretty-print with jq
cat ~/.clawdbot/logs/commands.log | jq .
# Filter by action
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
```
**Enable**:
```bash
clawdbot hooks internal enable command-logger
```
## Best Practices
### Keep Handlers Fast
Hooks run during command processing. Keep them lightweight:
```typescript
// ✓ Good - async work, returns immediately
const handler: InternalHookHandler = async (event) => {
void processInBackground(event); // Fire and forget
};
// ✗ Bad - blocks command processing
const handler: InternalHookHandler = async (event) => {
await slowDatabaseQuery(event);
await evenSlowerAPICall(event);
};
```
### Handle Errors Gracefully
Always wrap risky operations:
```typescript
const handler: InternalHookHandler = async (event) => {
try {
await riskyOperation(event);
} catch (err) {
console.error('[my-handler] Failed:', err instanceof Error ? err.message : String(err));
// Don't throw - let other handlers run
}
};
```
### Filter Events Early
Return early if the event isn't relevant:
```typescript
const handler: InternalHookHandler = async (event) => {
// Only handle 'new' commands
if (event.type !== 'command' || event.action !== 'new') {
return;
}
// Your logic here
};
```
### Use Specific Event Keys
Specify exact events in metadata when possible:
```yaml
metadata: {"clawdbot":{"events":["command:new"]}} # Specific
```
Rather than:
```yaml
metadata: {"clawdbot":{"events":["command"]}} # General - more overhead
```
## Debugging
### Enable Hook Logging
The gateway logs hook loading at startup:
```
Registered hook: session-memory -> command:new
Registered hook: command-logger -> command
```
### Check Discovery
List all discovered hooks:
```bash
clawdbot hooks internal list --verbose
```
### Check Registration
In your handler, log when it's called:
```typescript
const handler: InternalHookHandler = async (event) => {
console.log('[my-handler] Triggered:', event.type, event.action);
// Your logic
};
```
### Verify Eligibility
Check why a hook isn't eligible:
```bash
clawdbot hooks internal info my-hook
```
Look for missing requirements in the output.
## Testing
### Gateway Logs
Monitor gateway logs to see hook execution:
```bash
# macOS
./scripts/clawlog.sh -f
# Other platforms
tail -f ~/.clawdbot/gateway.log
```
### Test Hooks Directly
Test your handlers in isolation:
```typescript
import { test } from 'vitest';
import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
import myHandler from './hooks/my-hook/handler.js';
test('my handler works', async () => {
const event = createInternalHookEvent('command', 'new', 'test-session', {
foo: 'bar'
});
await myHandler(event);
// Assert side effects
});
```
## Architecture
### Core Components
- **`src/hooks/types.ts`**: Type definitions
- **`src/hooks/workspace.ts`**: Directory scanning and loading
- **`src/hooks/frontmatter.ts`**: HOOK.md metadata parsing
- **`src/hooks/config.ts`**: Eligibility checking
- **`src/hooks/hooks-status.ts`**: Status reporting
- **`src/hooks/loader.ts`**: Dynamic module loader
- **`src/cli/hooks-internal-cli.ts`**: CLI commands
- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start
- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events
### Discovery Flow
```
Gateway startup
Scan directories (workspace → managed → bundled)
Parse HOOK.md files
Check eligibility (bins, env, config, os)
Load handlers from eligible hooks
Register handlers for events
```
### Event Flow
```
User sends /new
Command validation
Create hook event
Trigger hook (all registered handlers)
Command processing continues
Session reset
```
## Troubleshooting
### Hook Not Discovered
1. Check directory structure:
```bash
ls -la ~/.clawdbot/hooks/my-hook/
# Should show: HOOK.md, handler.ts
```
2. Verify HOOK.md format:
```bash
cat ~/.clawdbot/hooks/my-hook/HOOK.md
# Should have YAML frontmatter with name and metadata
```
3. List all discovered hooks:
```bash
clawdbot hooks internal list
```
### Hook Not Eligible
Check requirements:
```bash
clawdbot hooks internal info my-hook
```
Look for missing:
- Binaries (check PATH)
- Environment variables
- Config values
- OS compatibility
### Hook Not Executing
1. Verify hook is enabled:
```bash
clawdbot hooks internal list
# Should show ✓ next to enabled hooks
```
2. Restart your gateway process so hooks reload.
3. Check gateway logs for errors:
```bash
./scripts/clawlog.sh | grep hook
```
### Handler Errors
Check for TypeScript/import errors:
```bash
# Test import directly
node -e "import('./path/to/handler.ts').then(console.log)"
```
## Migration Guide
### From Legacy Config to Discovery
**Before**:
```json
{
"hooks": {
"internal": {
"enabled": true,
"handlers": [
{
"event": "command:new",
"module": "./hooks/handlers/my-handler.ts"
}
]
}
}
}
```
**After**:
1. Create hook directory:
```bash
mkdir -p ~/.clawdbot/hooks/my-hook
mv ./hooks/handlers/my-handler.ts ~/.clawdbot/hooks/my-hook/handler.ts
```
2. Create HOOK.md:
```markdown
---
name: my-hook
description: "My custom hook"
metadata: {"clawdbot":{"emoji":"🎯","events":["command:new"]}}
---
# My Hook
Does something useful.
```
3. Update config:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"my-hook": { "enabled": true }
}
}
}
}
```
4. Verify and restart your gateway process:
```bash
clawdbot hooks internal list
# Should show: 🎯 my-hook ✓
```
**Benefits of migration**:
- Automatic discovery
- CLI management
- Eligibility checking
- Better documentation
- Consistent structure
## See Also
- [CLI Reference: hooks internal](/cli/hooks)
- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled)
- [Webhook Hooks](/automation/webhook)
- [Configuration](/gateway/configuration#hooks)

View File

@@ -65,7 +65,7 @@
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts",
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
"release:check": "tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install",

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env tsx
/**
* Copy HOOK.md files from src/hooks/bundled to dist/hooks/bundled
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const srcBundled = path.join(projectRoot, 'src', 'hooks', 'bundled');
const distBundled = path.join(projectRoot, 'dist', 'hooks', 'bundled');
function copyHookMetadata() {
if (!fs.existsSync(srcBundled)) {
console.warn('[copy-hook-metadata] Source directory not found:', srcBundled);
return;
}
if (!fs.existsSync(distBundled)) {
fs.mkdirSync(distBundled, { recursive: true });
}
const entries = fs.readdirSync(srcBundled, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const hookName = entry.name;
const srcHookDir = path.join(srcBundled, hookName);
const distHookDir = path.join(distBundled, hookName);
const srcHookMd = path.join(srcHookDir, 'HOOK.md');
const distHookMd = path.join(distHookDir, 'HOOK.md');
if (!fs.existsSync(srcHookMd)) {
console.warn(`[copy-hook-metadata] No HOOK.md found for ${hookName}`);
continue;
}
if (!fs.existsSync(distHookDir)) {
fs.mkdirSync(distHookDir, { recursive: true });
}
fs.copyFileSync(srcHookMd, distHookMd);
console.log(`[copy-hook-metadata] Copied ${hookName}/HOOK.md`);
}
console.log('[copy-hook-metadata] Done');
}
copyHookMetadata();

View File

@@ -1,6 +1,8 @@
import { logVerbose } from "../../globals.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { routeReply } from "./route-reply.js";
import { handleBashCommand } from "./commands-bash.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
@@ -42,9 +44,8 @@ const HANDLERS: CommandHandler[] = [
];
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
const resetRequested =
params.command.commandBodyNormalized === "/reset" ||
params.command.commandBodyNormalized === "/new";
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
const resetRequested = Boolean(resetMatch);
if (resetRequested && !params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
@@ -52,6 +53,45 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
return { shouldContinue: false };
}
// Trigger internal hook for reset/new commands
if (resetRequested && params.command.isAuthorizedSender) {
const commandAction = resetMatch?.[1] ?? "new";
const hookEvent = createInternalHookEvent(
'command',
commandAction,
params.sessionKey ?? '',
{
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
commandSource: params.command.surface,
senderId: params.command.senderId,
cfg: params.cfg, // Pass config for LLM slug generation
}
);
await triggerInternalHook(hookEvent);
// Send hook messages immediately if present
if (hookEvent.messages.length > 0) {
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) {
const hookReply = { text: hookEvent.messages.join('\n\n') };
await routeReply({
payload: hookReply,
channel: channel,
to: to,
sessionKey: params.sessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
}
}
}
const allowTextCommands = shouldHandleTextCommands({
cfg: params.cfg,
surface: params.command.surface,

View File

@@ -2,6 +2,7 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { parseActivationCommand } from "../group-activation.js";
@@ -12,8 +13,8 @@ import {
setAbortMemory,
stopSubagentsForRequester,
} from "./abort.js";
import { clearSessionQueues } from "./queue.js";
import type { CommandHandler } from "./commands-types.js";
import { clearSessionQueues } from "./queue.js";
function resolveSessionEntryForKey(
store: Record<string, SessionEntry> | undefined,
@@ -213,14 +214,27 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
} else if (params.command.abortKey) {
setAbortMemory(params.command.abortKey, true);
}
// Trigger internal hook for stop command
const hookEvent = createInternalHookEvent(
'command',
'stop',
abortTarget.key ?? params.sessionKey ?? '',
{
sessionEntry: abortTarget.entry ?? params.sessionEntry,
sessionId: abortTarget.sessionId,
commandSource: params.command.surface,
senderId: params.command.senderId,
}
);
await triggerInternalHook(hookEvent);
const { stopped } = stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: abortTarget.key ?? params.sessionKey,
});
return {
shouldContinue: false,
reply: { text: formatAbortReplyText(stopped) },
};
return { shouldContinue: false, reply: { text: formatAbortReplyText(stopped) } };
};
export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => {
@@ -235,12 +249,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
if (abortTarget.sessionId) {
abortEmbeddedPiRun(abortTarget.sessionId);
}
const cleared = clearSessionQueues([abortTarget.key, abortTarget.sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`stop-trigger: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (abortTarget.entry && params.sessionStore && abortTarget.key) {
abortTarget.entry.abortedLastRun = true;
abortTarget.entry.updatedAt = Date.now();
@@ -253,12 +261,5 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
} else if (params.command.abortKey) {
setAbortMemory(params.command.abortKey, true);
}
const { stopped } = stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: abortTarget.key ?? params.sessionKey,
});
return {
shouldContinue: false,
reply: { text: formatAbortReplyText(stopped) },
};
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
};

View File

@@ -33,6 +33,7 @@ export type HandleCommandsParams = {
failures: Array<{ gate: string; key: string }>;
};
sessionEntry?: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import type { MsgContext } from "../templating.js";
import { resetBashChatCommandForTests } from "./bash-command.js";
import { buildCommandContext, handleCommands } from "./commands.js";
@@ -143,6 +144,24 @@ describe("handleCommands identity", () => {
});
});
describe("handleCommands internal hooks", () => {
it("triggers hooks for /new with arguments", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/new take notes", cfg);
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
await handleCommands(params);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ type: "command", action: "new" }),
);
spy.mockRestore();
});
});
describe("handleCommands context", () => {
it("returns context help for /context", async () => {
const cfg = {

View File

@@ -29,6 +29,7 @@ export async function handleInlineActions(params: {
cfg: ClawdbotConfig;
agentId: string;
sessionEntry?: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
@@ -66,8 +67,9 @@ export async function handleInlineActions(params: {
sessionCtx,
cfg,
agentId,
sessionEntry,
sessionStore,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,
sessionScope,
@@ -203,6 +205,7 @@ export async function handleInlineActions(params: {
failures: elevatedFailures,
},
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,
@@ -265,6 +268,7 @@ export async function handleInlineActions(params: {
failures: elevatedFailures,
},
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,

View File

@@ -100,6 +100,7 @@ export async function getReplyFromConfig(
let {
sessionCtx,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
sessionId,
@@ -122,6 +123,7 @@ export async function getReplyFromConfig(
agentCfg,
sessionCtx,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,

View File

@@ -30,6 +30,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
export type SessionInitResult = {
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
sessionId: string;
@@ -115,6 +116,7 @@ export async function initSessionState(params: {
let bodyStripped: string | undefined;
let systemSent = false;
let abortedLastRun = false;
let resetTriggered = false;
let persistedThinking: string | undefined;
let persistedVerbose: string | undefined;
@@ -149,12 +151,14 @@ export async function initSessionState(params: {
if (trimmedBody === trigger || strippedForReset === trigger) {
isNewSession = true;
bodyStripped = "";
resetTriggered = true;
break;
}
const triggerPrefix = `${trigger} `;
if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
resetTriggered = true;
break;
}
}
@@ -168,6 +172,7 @@ export async function initSessionState(params: {
}
}
const entry = sessionStore[sessionKey];
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
const idleMs = idleMinutes * 60_000;
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
@@ -308,6 +313,7 @@ export async function initSessionState(params: {
return {
sessionCtx,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
sessionId: sessionId ?? crypto.randomUUID(),

View File

@@ -75,6 +75,11 @@ export type MsgContext = {
* The chat/channel/user ID where the reply should be sent.
*/
OriginatingTo?: string;
/**
* Messages from internal hooks to be included in the response.
* Used for hook confirmation messages like "Session context saved to memory".
*/
HookMessages?: string[];
};
export type TemplateContext = MsgContext & {

View File

@@ -20,6 +20,7 @@ import {
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { registerInternalHooksSubcommands } from "./hooks-internal-cli.js";
export function registerHooksCli(program: Command) {
const hooks = program
@@ -31,6 +32,9 @@ export function registerHooksCli(program: Command) {
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/hooks", "docs.clawd.bot/cli/hooks")}\n`,
);
// Register internal hooks management subcommands
registerInternalHooksSubcommands(hooks);
const gmail = hooks.command("gmail").description("Gmail Pub/Sub hooks (via gogcli)");
gmail

View File

@@ -0,0 +1,439 @@
import chalk from "chalk";
import type { Command } from "commander";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
buildWorkspaceHookStatus,
type HookStatusEntry,
type HookStatusReport,
} from "../hooks/hooks-status.js";
import { loadConfig, writeConfigFile } from "../config/io.js";
export type HooksListOptions = {
json?: boolean;
eligible?: boolean;
verbose?: boolean;
};
export type HookInfoOptions = {
json?: boolean;
};
export type HooksCheckOptions = {
json?: boolean;
};
/**
* Format a single hook for display in the list
*/
function formatHookLine(hook: HookStatusEntry, verbose = false): string {
const emoji = hook.emoji ?? "🔗";
const status = hook.eligible
? chalk.green("✓")
: hook.disabled
? chalk.yellow("disabled")
: chalk.red("missing reqs");
const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name);
const desc = chalk.gray(
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
);
if (verbose) {
const missing: string[] = [];
if (hook.missing.bins.length > 0) {
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
}
if (hook.missing.anyBins.length > 0) {
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
}
if (hook.missing.env.length > 0) {
missing.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
missing.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
missing.push(`os: ${hook.missing.os.join(", ")}`);
}
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
}
return `${emoji} ${name} ${status} - ${desc}`;
}
/**
* Format the hooks list output
*/
export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string {
const hooks = opts.eligible ? report.hooks.filter((h) => h.eligible) : report.hooks;
if (opts.json) {
const jsonReport = {
workspaceDir: report.workspaceDir,
managedHooksDir: report.managedHooksDir,
hooks: hooks.map((h) => ({
name: h.name,
description: h.description,
emoji: h.emoji,
eligible: h.eligible,
disabled: h.disabled,
source: h.source,
events: h.events,
homepage: h.homepage,
missing: h.missing,
})),
};
return JSON.stringify(jsonReport, null, 2);
}
if (hooks.length === 0) {
const message = opts.eligible
? "No eligible hooks found. Run `clawdbot hooks list` to see all hooks."
: "No hooks found.";
return message;
}
const eligible = hooks.filter((h) => h.eligible);
const notEligible = hooks.filter((h) => !h.eligible);
const lines: string[] = [];
lines.push(
chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`),
);
lines.push("");
if (eligible.length > 0) {
lines.push(chalk.bold.green("Ready:"));
for (const hook of eligible) {
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
}
}
if (notEligible.length > 0 && !opts.eligible) {
if (eligible.length > 0) lines.push("");
lines.push(chalk.bold.yellow("Not ready:"));
for (const hook of notEligible) {
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
}
}
return lines.join("\n");
}
/**
* Format detailed info for a single hook
*/
export function formatHookInfo(
report: HookStatusReport,
hookName: string,
opts: HookInfoOptions,
): string {
const hook = report.hooks.find((h) => h.name === hookName || h.hookKey === hookName);
if (!hook) {
if (opts.json) {
return JSON.stringify({ error: "not found", hook: hookName }, null, 2);
}
return `Hook "${hookName}" not found. Run \`clawdbot hooks list\` to see available hooks.`;
}
if (opts.json) {
return JSON.stringify(hook, null, 2);
}
const lines: string[] = [];
const emoji = hook.emoji ?? "🔗";
const status = hook.eligible
? chalk.green("✓ Ready")
: hook.disabled
? chalk.yellow("⏸ Disabled")
: chalk.red("✗ Missing requirements");
lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`);
lines.push("");
lines.push(chalk.white(hook.description));
lines.push("");
// Details
lines.push(chalk.bold("Details:"));
lines.push(` Source: ${hook.source}`);
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
if (hook.homepage) {
lines.push(` Homepage: ${chalk.blue(hook.homepage)}`);
}
if (hook.events.length > 0) {
lines.push(` Events: ${hook.events.join(", ")}`);
}
// Requirements
const hasRequirements =
hook.requirements.bins.length > 0 ||
hook.requirements.anyBins.length > 0 ||
hook.requirements.env.length > 0 ||
hook.requirements.config.length > 0 ||
hook.requirements.os.length > 0;
if (hasRequirements) {
lines.push("");
lines.push(chalk.bold("Requirements:"));
if (hook.requirements.bins.length > 0) {
const binsStatus = hook.requirements.bins.map((bin) => {
const missing = hook.missing.bins.includes(bin);
return missing ? chalk.red(`${bin}`) : chalk.green(`${bin}`);
});
lines.push(` Binaries: ${binsStatus.join(", ")}`);
}
if (hook.requirements.anyBins.length > 0) {
const anyBinsStatus =
hook.missing.anyBins.length > 0
? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
: chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
lines.push(` Any binary: ${anyBinsStatus}`);
}
if (hook.requirements.env.length > 0) {
const envStatus = hook.requirements.env.map((env) => {
const missing = hook.missing.env.includes(env);
return missing ? chalk.red(`${env}`) : chalk.green(`${env}`);
});
lines.push(` Environment: ${envStatus.join(", ")}`);
}
if (hook.requirements.config.length > 0) {
const configStatus = hook.configChecks.map((check) => {
return check.satisfied
? chalk.green(`${check.path}`)
: chalk.red(`${check.path}`);
});
lines.push(` Config: ${configStatus.join(", ")}`);
}
if (hook.requirements.os.length > 0) {
const osStatus =
hook.missing.os.length > 0
? chalk.red(`✗ (${hook.requirements.os.join(", ")})`)
: chalk.green(`✓ (${hook.requirements.os.join(", ")})`);
lines.push(` OS: ${osStatus}`);
}
}
return lines.join("\n");
}
/**
* Format check output
*/
export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string {
if (opts.json) {
const eligible = report.hooks.filter((h) => h.eligible);
const notEligible = report.hooks.filter((h) => !h.eligible);
return JSON.stringify(
{
total: report.hooks.length,
eligible: eligible.length,
notEligible: notEligible.length,
hooks: {
eligible: eligible.map((h) => h.name),
notEligible: notEligible.map((h) => ({
name: h.name,
missing: h.missing,
})),
},
},
null,
2,
);
}
const eligible = report.hooks.filter((h) => h.eligible);
const notEligible = report.hooks.filter((h) => !h.eligible);
const lines: string[] = [];
lines.push(chalk.bold.cyan("Internal Hooks Status"));
lines.push("");
lines.push(`Total hooks: ${report.hooks.length}`);
lines.push(chalk.green(`Ready: ${eligible.length}`));
lines.push(chalk.yellow(`Not ready: ${notEligible.length}`));
if (notEligible.length > 0) {
lines.push("");
lines.push(chalk.bold.yellow("Hooks not ready:"));
for (const hook of notEligible) {
const reasons = [];
if (hook.disabled) reasons.push("disabled");
if (hook.missing.bins.length > 0) reasons.push(`bins: ${hook.missing.bins.join(", ")}`);
if (hook.missing.anyBins.length > 0)
reasons.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
if (hook.missing.env.length > 0) reasons.push(`env: ${hook.missing.env.join(", ")}`);
if (hook.missing.config.length > 0)
reasons.push(`config: ${hook.missing.config.join(", ")}`);
if (hook.missing.os.length > 0) reasons.push(`os: ${hook.missing.os.join(", ")}`);
lines.push(` ${hook.emoji ?? "🔗"} ${hook.name} - ${reasons.join("; ")}`);
}
}
return lines.join("\n");
}
export async function enableHook(hookName: string): Promise<void> {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const hook = report.hooks.find((h) => h.name === hookName);
if (!hook) {
throw new Error(`Hook "${hookName}" not found`);
}
if (!hook.eligible) {
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
}
// Update config
const entries = { ...config.hooks?.internal?.entries };
entries[hookName] = { ...entries[hookName], enabled: true };
const nextConfig = {
...config,
hooks: {
...config.hooks,
internal: {
...config.hooks?.internal,
enabled: true,
entries,
},
},
};
await writeConfigFile(nextConfig);
console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
}
export async function disableHook(hookName: string): Promise<void> {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const hook = report.hooks.find((h) => h.name === hookName);
if (!hook) {
throw new Error(`Hook "${hookName}" not found`);
}
// Update config
const entries = { ...config.hooks?.internal?.entries };
entries[hookName] = { ...entries[hookName], enabled: false };
const nextConfig = {
...config,
hooks: {
...config.hooks,
internal: {
...config.hooks?.internal,
entries,
},
},
};
await writeConfigFile(nextConfig);
console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
}
export function registerInternalHooksSubcommands(hooksCommand: Command): void {
// Add "internal" subcommand to existing "hooks" command
const internal = hooksCommand
.command("internal")
.description("Manage internal agent hooks")
.alias("int");
// list command
internal
.command("list")
.description("List all internal hooks")
.option("--eligible", "Show only eligible hooks", false)
.option("--json", "Output as JSON", false)
.option("-v, --verbose", "Show more details including missing requirements", false)
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
console.log(formatHooksList(report, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// info command
internal
.command("info <name>")
.description("Show detailed information about a hook")
.option("--json", "Output as JSON", false)
.action(async (name, opts) => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
console.log(formatHookInfo(report, name, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// check command
internal
.command("check")
.description("Check hooks eligibility status")
.option("--json", "Output as JSON", false)
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
console.log(formatHooksCheck(report, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// enable command
internal
.command("enable <name>")
.description("Enable a hook")
.action(async (name) => {
try {
await enableHook(name);
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// disable command
internal
.command("disable <name>")
.description("Disable a hook")
.action(async (name) => {
try {
await disableHook(name);
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// Default action (no subcommand) - show list
internal.action(async () => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
console.log(formatHooksList(report, {}));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
}

View File

@@ -0,0 +1,185 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { setupInternalHooks } from './onboard-hooks.js';
import type { ClawdbotConfig } from '../config/config.js';
import type { RuntimeEnv } from '../runtime.js';
import type { WizardPrompter } from '../wizard/prompts.js';
import type { HookStatusReport } from '../hooks/hooks-status.js';
// Mock hook discovery modules
vi.mock('../hooks/hooks-status.js', () => ({
buildWorkspaceHookStatus: vi.fn(),
}));
vi.mock('../agents/agent-scope.js', () => ({
resolveAgentWorkspaceDir: vi.fn().mockReturnValue('/mock/workspace'),
resolveDefaultAgentId: vi.fn().mockReturnValue('main'),
}));
describe('onboard-hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockPrompter = (multiselectValue: string[]): WizardPrompter => ({
confirm: vi.fn().mockResolvedValue(true),
note: vi.fn().mockResolvedValue(undefined),
intro: vi.fn().mockResolvedValue(undefined),
outro: vi.fn().mockResolvedValue(undefined),
text: vi.fn().mockResolvedValue(''),
select: vi.fn().mockResolvedValue(''),
multiselect: vi.fn().mockResolvedValue(multiselectValue),
progress: vi.fn().mockReturnValue({
stop: vi.fn(),
update: vi.fn(),
}),
});
const createMockRuntime = (): RuntimeEnv => ({
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
});
const createMockHookReport = (eligible = true): HookStatusReport => ({
workspaceDir: '/mock/workspace',
managedHooksDir: '/mock/.clawdbot/hooks',
hooks: [
{
name: 'session-memory',
description: 'Save session context to memory when /new command is issued',
source: 'clawdbot-bundled',
emoji: '💾',
events: ['command:new'],
disabled: false,
eligible,
requirements: { config: ['workspace.dir'] },
missing: {},
},
],
});
describe('setupInternalHooks', () => {
it('should enable internal hooks when user selects them', async () => {
const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js');
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport());
const cfg: ClawdbotConfig = {};
const prompter = createMockPrompter(['session-memory']);
const runtime = createMockRuntime();
const result = await setupInternalHooks(cfg, runtime, prompter);
expect(result.hooks?.internal?.enabled).toBe(true);
expect(result.hooks?.internal?.entries).toEqual({
'session-memory': { enabled: true },
});
expect(prompter.note).toHaveBeenCalledTimes(2);
expect(prompter.multiselect).toHaveBeenCalledWith({
message: 'Enable internal hooks?',
options: [
{ value: '__skip__', label: 'Skip for now' },
{
value: 'session-memory',
label: '💾 session-memory',
hint: 'Save session context to memory when /new command is issued',
},
],
});
});
it('should not enable hooks when user skips', async () => {
const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js');
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport());
const cfg: ClawdbotConfig = {};
const prompter = createMockPrompter(['__skip__']);
const runtime = createMockRuntime();
const result = await setupInternalHooks(cfg, runtime, prompter);
expect(result.hooks?.internal).toBeUndefined();
expect(prompter.note).toHaveBeenCalledTimes(1);
});
it('should handle no eligible hooks', async () => {
const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js');
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport(false));
const cfg: ClawdbotConfig = {};
const prompter = createMockPrompter([]);
const runtime = createMockRuntime();
const result = await setupInternalHooks(cfg, runtime, prompter);
expect(result).toEqual(cfg);
expect(prompter.multiselect).not.toHaveBeenCalled();
expect(prompter.note).toHaveBeenCalledWith(
'No eligible hooks found. You can configure hooks later in your config.',
'No Hooks Available',
);
});
it('should preserve existing hooks config when enabled', async () => {
const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js');
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport());
const cfg: ClawdbotConfig = {
hooks: {
enabled: true,
path: '/webhook',
token: 'existing-token',
},
};
const prompter = createMockPrompter(['session-memory']);
const runtime = createMockRuntime();
const result = await setupInternalHooks(cfg, runtime, prompter);
expect(result.hooks?.enabled).toBe(true);
expect(result.hooks?.path).toBe('/webhook');
expect(result.hooks?.token).toBe('existing-token');
expect(result.hooks?.internal?.enabled).toBe(true);
expect(result.hooks?.internal?.entries).toEqual({
'session-memory': { enabled: true },
});
});
it('should preserve existing config when user skips', async () => {
const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js');
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport());
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: '/workspace' } },
};
const prompter = createMockPrompter(['__skip__']);
const runtime = createMockRuntime();
const result = await setupInternalHooks(cfg, runtime, prompter);
expect(result).toEqual(cfg);
expect(result.agents?.defaults?.workspace).toBe('/workspace');
});
it('should show informative notes to user', async () => {
const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js');
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport());
const cfg: ClawdbotConfig = {};
const prompter = createMockPrompter(['session-memory']);
const runtime = createMockRuntime();
await setupInternalHooks(cfg, runtime, prompter);
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
expect(noteCalls).toHaveLength(2);
// First note should explain what internal hooks are
expect(noteCalls[0][0]).toContain('Internal hooks');
expect(noteCalls[0][0]).toContain('automate actions');
// Second note should confirm configuration
expect(noteCalls[1][0]).toContain('Enabled 1 hook: session-memory');
expect(noteCalls[1][0]).toContain('clawdbot hooks internal list');
});
});
});

View File

@@ -0,0 +1,86 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { buildWorkspaceHookStatus } from "../hooks/hooks-status.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
export async function setupInternalHooks(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<ClawdbotConfig> {
await prompter.note(
[
"Internal hooks let you automate actions when agent commands are issued.",
"Example: Save session context to memory when you issue /new.",
"",
"Learn more: https://docs.clawd.bot/internal-hooks",
].join("\n"),
"Internal Hooks",
);
// Discover available hooks using the hook discovery system
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg });
// Filter for eligible and recommended hooks (session-memory is recommended)
const recommendedHooks = report.hooks.filter(
(h) => h.eligible && h.name === "session-memory",
);
if (recommendedHooks.length === 0) {
await prompter.note(
"No eligible hooks found. You can configure hooks later in your config.",
"No Hooks Available",
);
return cfg;
}
const toEnable = await prompter.multiselect({
message: "Enable internal hooks?",
options: [
{ value: "__skip__", label: "Skip for now" },
...recommendedHooks.map((hook) => ({
value: hook.name,
label: `${hook.emoji ?? "🔗"} ${hook.name}`,
hint: hook.description,
})),
],
});
const selected = toEnable.filter((name) => name !== "__skip__");
if (selected.length === 0) {
return cfg;
}
// Enable selected hooks using the new entries config format
const entries = { ...cfg.hooks?.internal?.entries };
for (const name of selected) {
entries[name] = { enabled: true };
}
const next: ClawdbotConfig = {
...cfg,
hooks: {
...cfg.hooks,
internal: {
enabled: true,
entries,
},
},
};
await prompter.note(
[
`Enabled ${selected.length} hook${selected.length > 1 ? "s" : ""}: ${selected.join(", ")}`,
"",
"You can manage hooks later with:",
" clawdbot hooks internal list",
" clawdbot hooks internal enable <name>",
" clawdbot hooks internal disable <name>",
].join("\n"),
"Hooks Configured",
);
return next;
}

View File

@@ -64,6 +64,35 @@ export type HooksGmailConfig = {
thinking?: "off" | "minimal" | "low" | "medium" | "high";
};
export type InternalHookHandlerConfig = {
/** Event key to listen for (e.g., 'command:new', 'session:start') */
event: string;
/** Path to handler module (absolute or relative to cwd) */
module: string;
/** Export name from module (default: 'default') */
export?: string;
};
export type HookConfig = {
enabled?: boolean;
env?: Record<string, string>;
[key: string]: unknown;
};
export type InternalHooksConfig = {
/** Enable internal hooks system */
enabled?: boolean;
/** Legacy: List of internal hook handlers to register (still supported) */
handlers?: InternalHookHandlerConfig[];
/** Per-hook configuration overrides */
entries?: Record<string, HookConfig>;
/** Load configuration */
load?: {
/** Additional hook directories to scan */
extraDirs?: string[];
};
};
export type HooksConfig = {
enabled?: boolean;
path?: string;
@@ -73,4 +102,6 @@ export type HooksConfig = {
transformsDir?: string;
mappings?: HookMappingConfig[];
gmail?: HooksGmailConfig;
/** Internal agent event hooks */
internal?: InternalHooksConfig;
};

View File

@@ -41,6 +41,32 @@ export const HookMappingSchema = z
})
.optional();
export const InternalHookHandlerSchema = z.object({
event: z.string(),
module: z.string(),
export: z.string().optional(),
});
const HookConfigSchema = z
.object({
enabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
})
.passthrough();
export const InternalHooksSchema = z
.object({
enabled: z.boolean().optional(),
handlers: z.array(InternalHookHandlerSchema).optional(),
entries: z.record(z.string(), HookConfigSchema).optional(),
load: z
.object({
extraDirs: z.array(z.string()).optional(),
})
.optional(),
})
.optional();
export const HooksGmailSchema = z
.object({
account: z.string().optional(),

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema } from "./zod-schema.hooks.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
import { ChannelsSchema } from "./zod-schema.providers.js";
import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js";
@@ -148,6 +148,7 @@ export const ClawdbotSchema = z
transformsDir: z.string().optional(),
mappings: z.array(HookMappingSchema).optional(),
gmail: HooksGmailSchema,
internal: InternalHooksSchema,
})
.optional(),
web: z

View File

@@ -8,6 +8,8 @@ import {
import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js";
import { startGmailWatcher } from "../hooks/gmail-watcher.js";
import { clearInternalHooks } from "../hooks/internal-hooks.js";
import { loadInternalHooks } from "../hooks/loader.js";
import type { loadClawdbotPlugins } from "../plugins/loader.js";
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
@@ -90,6 +92,18 @@ export async function startGatewaySidecars(params: {
}
}
// Load internal hook handlers from configuration and directory discovery.
try {
// Clear any previously registered hooks to ensure fresh loading
clearInternalHooks();
const loadedCount = await loadInternalHooks(params.cfg, params.defaultWorkspaceDir);
if (loadedCount > 0) {
params.logHooks.info(`loaded ${loadedCount} internal hook handler${loadedCount > 1 ? 's' : ''}`);
}
} catch (err) {
params.logHooks.error(`failed to load internal hooks: ${String(err)}`);
}
// Launch configured channels so gateway replies via the surface the message came from.
// Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS).
const skipChannels =

40
src/hooks/bundled-dir.ts Normal file
View File

@@ -0,0 +1,40 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export function resolveBundledHooksDir(): string | undefined {
const override = process.env.CLAWDBOT_BUNDLED_HOOKS_DIR?.trim();
if (override) return override;
// bun --compile: ship a sibling `hooks/bundled/` next to the executable.
try {
const execDir = path.dirname(process.execPath);
const sibling = path.join(execDir, "hooks", "bundled");
if (fs.existsSync(sibling)) return sibling;
} catch {
// ignore
}
// npm: resolve `<packageRoot>/dist/hooks/bundled` relative to this module (compiled hooks).
// This path works when installed via npm: node_modules/clawdbot/dist/hooks/bundled-dir.js
try {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const distBundled = path.join(moduleDir, "bundled");
if (fs.existsSync(distBundled)) return distBundled;
} catch {
// ignore
}
// dev: resolve `<packageRoot>/src/hooks/bundled` relative to dist/hooks/bundled-dir.js
// This path works in dev: dist/hooks/bundled-dir.js -> ../../src/hooks/bundled
try {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(moduleDir, "..", "..");
const srcBundled = path.join(root, "src", "hooks", "bundled");
if (fs.existsSync(srcBundled)) return srcBundled;
} catch {
// ignore
}
return undefined;
}

186
src/hooks/bundled/README.md Normal file
View File

@@ -0,0 +1,186 @@
# Bundled Internal Hooks
This directory contains internal hooks that ship with Clawdbot. These hooks are automatically discovered and can be enabled/disabled via CLI or configuration.
## Available Hooks
### 💾 session-memory
Automatically saves session context to memory when you issue `/new`.
**Events**: `command:new`
**What it does**: Creates a dated memory file with LLM-generated slug based on conversation content.
**Output**: `<workspace>/memory/YYYY-MM-DD-slug.md` (defaults to `~/clawd`)
**Enable**:
```bash
clawdbot hooks internal enable session-memory
```
### 📝 command-logger
Logs all command events to a centralized audit file.
**Events**: `command` (all commands)
**What it does**: Appends JSONL entries to command log file.
**Output**: `~/.clawdbot/logs/commands.log`
**Enable**:
```bash
clawdbot hooks internal enable command-logger
```
## Hook Structure
Each hook is a directory containing:
- **HOOK.md**: Metadata and documentation in YAML frontmatter + Markdown
- **handler.ts**: The hook handler function (default export)
Example structure:
```
session-memory/
├── HOOK.md # Metadata + docs
└── handler.ts # Handler implementation
```
## HOOK.md Format
```yaml
---
name: my-hook
description: "Short description"
homepage: https://docs.clawd.bot/hooks/my-hook
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
---
# Hook Title
Documentation goes here...
```
### Metadata Fields
- **emoji**: Display emoji for CLI
- **events**: Array of events to listen for (e.g., `["command:new", "session:start"]`)
- **requires**: Optional requirements
- **bins**: Required binaries on PATH
- **anyBins**: At least one of these binaries must be present
- **env**: Required environment variables
- **config**: Required config paths (e.g., `["workspace.dir"]`)
- **os**: Required platforms (e.g., `["darwin", "linux"]`)
- **install**: Installation methods (for bundled hooks: `[{"id":"bundled","kind":"bundled"}]`)
## Creating Custom Hooks
To create your own hooks, place them in:
- **Workspace hooks**: `<workspace>/hooks/` (highest precedence)
- **Managed hooks**: `~/.clawdbot/hooks/` (shared across workspaces)
Custom hooks follow the same structure as bundled hooks.
## Managing Hooks
List all hooks:
```bash
clawdbot hooks internal list
```
Show hook details:
```bash
clawdbot hooks internal info session-memory
```
Check hook status:
```bash
clawdbot hooks internal check
```
Enable/disable:
```bash
clawdbot hooks internal enable session-memory
clawdbot hooks internal disable command-logger
```
## Configuration
Hooks can be configured in `~/.clawdbot/clawdbot.json`:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"session-memory": {
"enabled": true
},
"command-logger": {
"enabled": false
}
}
}
}
}
```
## Event Types
Currently supported events:
- **command**: All command events
- **command:new**: `/new` command specifically
- **command:reset**: `/reset` command
- **command:stop**: `/stop` command
More event types coming soon (session lifecycle, agent errors, etc.).
## Handler API
Hook handlers receive an `InternalHookEvent` object:
```typescript
interface InternalHookEvent {
type: 'command' | 'session' | 'agent';
action: string; // e.g., 'new', 'reset', 'stop'
sessionKey: string;
context: Record<string, unknown>;
timestamp: Date;
messages: string[]; // Push messages here to send to user
}
```
Example handler:
```typescript
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
const myHandler: InternalHookHandler = async (event) => {
if (event.type !== 'command' || event.action !== 'new') {
return;
}
// Your logic here
console.log('New command triggered!');
// Optionally send message to user
event.messages.push('✨ Hook executed!');
};
export default myHandler;
```
## Testing
Test your hooks by:
1. Place hook in workspace hooks directory
2. Restart gateway: `pkill -9 -f 'clawdbot.*gateway' && pnpm clawdbot gateway`
3. Enable the hook: `clawdbot hooks internal enable my-hook`
4. Trigger the event (e.g., send `/new` command)
5. Check gateway logs for hook execution
## Documentation
Full documentation: https://docs.clawd.bot/internal-hooks

View File

@@ -0,0 +1,109 @@
---
name: command-logger
description: "Log all command events to a centralized audit file"
homepage: https://docs.clawd.bot/internal-hooks#command-logger
metadata: {"clawdbot":{"emoji":"📝","events":["command"],"install":[{"id":"bundled","kind":"bundled","label":"Bundled with Clawdbot"}]}}
---
# Command Logger Hook
Logs all command events (`/new`, `/reset`, `/stop`, etc.) to a centralized audit log file for debugging and monitoring purposes.
## What It Does
Every time you issue a command to the agent:
1. **Captures event details** - Command action, timestamp, session key, sender ID, source
2. **Appends to log file** - Writes a JSON line to `~/.clawdbot/logs/commands.log`
3. **Silent operation** - Runs in the background without user notifications
## Output Format
Log entries are written in JSONL (JSON Lines) format:
```json
{"timestamp":"2026-01-16T14:30:00.000Z","action":"new","sessionKey":"agent:main:main","senderId":"+1234567890","source":"telegram"}
{"timestamp":"2026-01-16T15:45:22.000Z","action":"stop","sessionKey":"agent:main:main","senderId":"user@example.com","source":"whatsapp"}
```
## Use Cases
- **Debugging**: Track when commands were issued and from which source
- **Auditing**: Monitor command usage across different channels
- **Analytics**: Analyze command patterns and frequency
- **Troubleshooting**: Investigate issues by reviewing command history
## Log File Location
`~/.clawdbot/logs/commands.log`
## Requirements
No requirements - this hook works out of the box on all platforms.
## Configuration
No configuration needed. The hook automatically:
- Creates the log directory if it doesn't exist
- Appends to the log file (doesn't overwrite)
- Handles errors silently without disrupting command execution
## Disabling
To disable this hook:
```bash
clawdbot hooks internal disable command-logger
```
Or via config:
```json
{
"hooks": {
"internal": {
"entries": {
"command-logger": { "enabled": false }
}
}
}
}
```
## Log Rotation
The hook does not automatically rotate logs. To manage log size, you can:
1. **Manual rotation**:
```bash
mv ~/.clawdbot/logs/commands.log ~/.clawdbot/logs/commands.log.old
```
2. **Use logrotate** (Linux):
Create `/etc/logrotate.d/clawdbot`:
```
/home/username/.clawdbot/logs/commands.log {
weekly
rotate 4
compress
missingok
notifempty
}
```
## Viewing Logs
View recent commands:
```bash
tail -n 20 ~/.clawdbot/logs/commands.log
```
Pretty-print with jq:
```bash
cat ~/.clawdbot/logs/commands.log | jq .
```
Filter by action:
```bash
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
```

View File

@@ -0,0 +1,64 @@
/**
* Example internal hook handler: Log all commands to a file
*
* This handler demonstrates how to create a hook that logs all command events
* to a centralized log file for audit/debugging purposes.
*
* To enable this handler, add it to your config:
*
* ```json
* {
* "hooks": {
* "internal": {
* "enabled": true,
* "handlers": [
* {
* "event": "command",
* "module": "./hooks/handlers/command-logger.ts"
* }
* ]
* }
* }
* }
* ```
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { InternalHookHandler } from '../../internal-hooks.js';
/**
* Log all command events to a file
*/
const logCommand: InternalHookHandler = async (event) => {
// Only trigger on command events
if (event.type !== 'command') {
return;
}
try {
// Create log directory
const logDir = path.join(os.homedir(), '.clawdbot', 'logs');
await fs.mkdir(logDir, { recursive: true });
// Append to command log file
const logFile = path.join(logDir, 'commands.log');
const logLine = JSON.stringify({
timestamp: event.timestamp.toISOString(),
action: event.action,
sessionKey: event.sessionKey,
senderId: event.context.senderId ?? 'unknown',
source: event.context.commandSource ?? 'unknown',
}) + '\n';
await fs.appendFile(logFile, logLine, 'utf-8');
} catch (err) {
console.error(
'[command-logger] Failed to log command:',
err instanceof Error ? err.message : String(err)
);
}
};
export default logCommand;

View File

@@ -0,0 +1,76 @@
---
name: session-memory
description: "Save session context to memory when /new command is issued"
homepage: https://docs.clawd.bot/internal-hooks#session-memory
metadata: {"clawdbot":{"emoji":"💾","events":["command:new"],"requires":{"config":["workspace.dir"]},"install":[{"id":"bundled","kind":"bundled","label":"Bundled with Clawdbot"}]}}
---
# Session Memory Hook
Automatically saves session context to your workspace memory when you issue the `/new` command.
## What It Does
When you run `/new` to start a fresh session:
1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
5. **Sends confirmation** - Notifies you with the file path
## Output Format
Memory files are created with the following format:
```markdown
# Session: 2026-01-16 14:30:00 UTC
- **Session Key**: agent:main:main
- **Session ID**: abc123def456
- **Source**: telegram
```
## Filename Examples
The LLM generates descriptive slugs based on your conversation:
- `2026-01-16-vendor-pitch.md` - Discussion about vendor evaluation
- `2026-01-16-api-design.md` - API architecture planning
- `2026-01-16-bug-fix.md` - Debugging session
- `2026-01-16-1430.md` - Fallback timestamp if slug generation fails
## Requirements
- **Config**: `workspace.dir` must be set (automatically configured during onboarding)
The hook uses your configured LLM provider to generate slugs, so it works with any provider (Anthropic, OpenAI, etc.).
## Configuration
No additional configuration required. The hook automatically:
- Uses your workspace directory (`~/clawd` by default)
- Uses your configured LLM for slug generation
- Falls back to timestamp slugs if LLM is unavailable
## Disabling
To disable this hook:
```bash
clawdbot hooks internal disable session-memory
```
Or remove it from your config:
```json
{
"hooks": {
"internal": {
"entries": {
"session-memory": { "enabled": false }
}
}
}
}
```

View File

@@ -0,0 +1,174 @@
/**
* Session memory hook handler
*
* Saves session context to memory when /new command is triggered
* Creates a new dated memory file with LLM-generated slug
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { ClawdbotConfig } from '../../../config/config.js';
import { resolveAgentWorkspaceDir } from '../../../agents/agent-scope.js';
import { resolveAgentIdFromSessionKey } from '../../../routing/session-key.js';
import type { InternalHookHandler } from '../../internal-hooks.js';
/**
* Read recent messages from session file for slug generation
*/
async function getRecentSessionContent(sessionFilePath: string): Promise<string | null> {
try {
const content = await fs.readFile(sessionFilePath, 'utf-8');
const lines = content.trim().split('\n');
// Get last 15 lines (recent conversation)
const recentLines = lines.slice(-15);
// Parse JSONL and extract messages
const messages: string[] = [];
for (const line of recentLines) {
try {
const entry = JSON.parse(line);
// Session files have entries with type="message" containing a nested message object
if (entry.type === 'message' && entry.message) {
const msg = entry.message;
const role = msg.role;
if ((role === 'user' || role === 'assistant') && msg.content) {
// Extract text content
const text = Array.isArray(msg.content)
? msg.content.find((c: any) => c.type === 'text')?.text
: msg.content;
if (text && !text.startsWith('/')) {
messages.push(`${role}: ${text}`);
}
}
}
} catch {
// Skip invalid JSON lines
}
}
return messages.join('\n');
} catch {
return null;
}
}
/**
* Save session context to memory when /new command is triggered
*/
const saveSessionToMemory: InternalHookHandler = async (event) => {
// Only trigger on 'new' command
if (event.type !== 'command' || event.action !== 'new') {
return;
}
try {
console.log('[session-memory] Hook triggered for /new command');
const context = event.context || {};
const cfg = context.cfg as ClawdbotConfig | undefined;
const agentId = resolveAgentIdFromSessionKey(event.sessionKey);
const workspaceDir = cfg
? resolveAgentWorkspaceDir(cfg, agentId)
: path.join(os.homedir(), 'clawd');
const memoryDir = path.join(workspaceDir, 'memory');
await fs.mkdir(memoryDir, { recursive: true });
// Get today's date for filename
const now = new Date(event.timestamp);
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD
// Generate descriptive slug from session using LLM
const sessionEntry = (
context.previousSessionEntry ||
context.sessionEntry ||
{}
) as Record<string, unknown>;
const currentSessionId = sessionEntry.sessionId as string;
const currentSessionFile = sessionEntry.sessionFile as string;
console.log('[session-memory] Current sessionId:', currentSessionId);
console.log('[session-memory] Current sessionFile:', currentSessionFile);
console.log('[session-memory] cfg present:', !!cfg);
const sessionFile = currentSessionFile || undefined;
let slug: string | null = null;
let sessionContent: string | null = null;
if (sessionFile) {
// Get recent conversation content
sessionContent = await getRecentSessionContent(sessionFile);
console.log('[session-memory] sessionContent length:', sessionContent?.length || 0);
if (sessionContent && cfg) {
console.log('[session-memory] Calling generateSlugViaLLM...');
// Dynamically import the LLM slug generator (avoids module caching issues)
// When compiled, handler is at dist/hooks/bundled/session-memory/handler.js
// Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js
const clawdbotRoot = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '../..');
const slugGenPath = path.join(clawdbotRoot, 'llm-slug-generator.js');
const { generateSlugViaLLM } = await import(slugGenPath);
// Use LLM to generate a descriptive slug
slug = await generateSlugViaLLM({ sessionContent, cfg });
console.log('[session-memory] Generated slug:', slug);
}
}
// If no slug, use timestamp
if (!slug) {
const timeSlug = now.toISOString().split('T')[1]!.split('.')[0]!.replace(/:/g, '');
slug = timeSlug.slice(0, 4); // HHMM
console.log('[session-memory] Using fallback timestamp slug:', slug);
}
// Create filename with date and slug
const filename = `${dateStr}-${slug}.md`;
const memoryFilePath = path.join(memoryDir, filename);
console.log('[session-memory] Generated filename:', filename);
console.log('[session-memory] Full path:', memoryFilePath);
// Format time as HH:MM:SS UTC
const timeStr = now.toISOString().split('T')[1]!.split('.')[0];
// Extract context details
const sessionId = (sessionEntry.sessionId as string) || 'unknown';
const source = (context.commandSource as string) || 'unknown';
// Build Markdown entry
const entryParts = [
`# Session: ${dateStr} ${timeStr} UTC`,
'',
`- **Session Key**: ${event.sessionKey}`,
`- **Session ID**: ${sessionId}`,
`- **Source**: ${source}`,
'',
];
// Include conversation content if available
if (sessionContent) {
entryParts.push('## Conversation Summary', '', sessionContent, '');
}
const entry = entryParts.join('\n');
// Write to new memory file
await fs.writeFile(memoryFilePath, entry, 'utf-8');
console.log('[session-memory] Memory file written successfully');
// Send confirmation message to user with filename
const relPath = memoryFilePath.replace(os.homedir(), '~');
const confirmMsg = `💾 Session context saved to memory before reset.\n📄 ${relPath}`;
event.messages.push(confirmMsg);
console.log('[session-memory] Confirmation message queued:', confirmMsg);
} catch (err) {
console.error(
'[session-memory] Failed to save session memory:',
err instanceof Error ? err.message : String(err)
);
}
};
export default saveSessionToMemory;

134
src/hooks/config.ts Normal file
View File

@@ -0,0 +1,134 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig, HookConfig } from "../config/config.js";
import { resolveHookKey } from "./frontmatter.js";
import type { HookEligibilityContext, HookEntry } from "./types.js";
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
"browser.enabled": true,
"workspace.dir": true,
};
function isTruthy(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") return value.trim().length > 0;
return true;
}
export function resolveConfigPath(config: ClawdbotConfig | undefined, pathStr: string) {
const parts = pathStr.split(".").filter(Boolean);
let current: unknown = config;
for (const part of parts) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
export function isConfigPathTruthy(config: ClawdbotConfig | undefined, pathStr: string): boolean {
const value = resolveConfigPath(config, pathStr);
if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) {
return DEFAULT_CONFIG_VALUES[pathStr] === true;
}
return isTruthy(value);
}
export function resolveHookConfig(
config: ClawdbotConfig | undefined,
hookKey: string,
): HookConfig | undefined {
const hooks = config?.hooks?.internal?.entries;
if (!hooks || typeof hooks !== "object") return undefined;
const entry = (hooks as Record<string, HookConfig | undefined>)[hookKey];
if (!entry || typeof entry !== "object") return undefined;
return entry;
}
export function resolveRuntimePlatform(): string {
return process.platform;
}
export function hasBinary(bin: string): boolean {
const pathEnv = process.env.PATH ?? "";
const parts = pathEnv.split(path.delimiter).filter(Boolean);
for (const part of parts) {
const candidate = path.join(part, bin);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
// keep scanning
}
}
return false;
}
export function shouldIncludeHook(params: {
entry: HookEntry;
config?: ClawdbotConfig;
eligibility?: HookEligibilityContext;
}): boolean {
const { entry, config, eligibility } = params;
const hookKey = resolveHookKey(entry.hook.name, entry);
const hookConfig = resolveHookConfig(config, hookKey);
const osList = entry.clawdbot?.os ?? [];
const remotePlatforms = eligibility?.remote?.platforms ?? [];
// Check if explicitly disabled
if (hookConfig?.enabled === false) return false;
// Check OS requirement
if (
osList.length > 0 &&
!osList.includes(resolveRuntimePlatform()) &&
!remotePlatforms.some((platform) => osList.includes(platform))
) {
return false;
}
// If marked as 'always', bypass all other checks
if (entry.clawdbot?.always === true) {
return true;
}
// Check required binaries (all must be present)
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
if (requiredBins.length > 0) {
for (const bin of requiredBins) {
if (hasBinary(bin)) continue;
if (eligibility?.remote?.hasBin?.(bin)) continue;
return false;
}
}
// Check anyBins (at least one must be present)
const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? [];
if (requiredAnyBins.length > 0) {
const anyFound =
requiredAnyBins.some((bin) => hasBinary(bin)) ||
eligibility?.remote?.hasAnyBin?.(requiredAnyBins);
if (!anyFound) return false;
}
// Check required environment variables
const requiredEnv = entry.clawdbot?.requires?.env ?? [];
if (requiredEnv.length > 0) {
for (const envName of requiredEnv) {
if (process.env[envName]) continue;
if (hookConfig?.env?.[envName]) continue;
return false;
}
}
// Check required config paths
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
if (requiredConfig.length > 0) {
for (const configPath of requiredConfig) {
if (!isConfigPathTruthy(config, configPath)) return false;
}
}
return true;
}

152
src/hooks/frontmatter.ts Normal file
View File

@@ -0,0 +1,152 @@
import type {
ClawdbotHookMetadata,
HookEntry,
HookInstallSpec,
HookInvocationPolicy,
ParsedHookFrontmatter,
} from "./types.js";
function stripQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
export function parseFrontmatter(content: string): ParsedHookFrontmatter {
const frontmatter: ParsedHookFrontmatter = {};
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return frontmatter;
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return frontmatter;
const block = normalized.slice(4, endIndex);
for (const line of block.split("\n")) {
const match = line.match(/^([\w-]+):\s*(.*)$/);
if (!match) continue;
const key = match[1];
const value = stripQuotes(match[2].trim());
if (!key || !value) continue;
frontmatter[key] = value;
}
return frontmatter;
}
function normalizeStringList(input: unknown): string[] {
if (!input) return [];
if (Array.isArray(input)) {
return input.map((value) => String(value).trim()).filter(Boolean);
}
if (typeof input === "string") {
return input
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
return [];
}
function parseInstallSpec(input: unknown): HookInstallSpec | undefined {
if (!input || typeof input !== "object") return undefined;
const raw = input as Record<string, unknown>;
const kindRaw =
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
const kind = kindRaw.trim().toLowerCase();
if (kind !== "bundled" && kind !== "npm" && kind !== "git") {
return undefined;
}
const spec: HookInstallSpec = {
kind: kind as HookInstallSpec["kind"],
};
if (typeof raw.id === "string") spec.id = raw.id;
if (typeof raw.label === "string") spec.label = raw.label;
const bins = normalizeStringList(raw.bins);
if (bins.length > 0) spec.bins = bins;
if (typeof raw.package === "string") spec.package = raw.package;
if (typeof raw.repository === "string") spec.repository = raw.repository;
return spec;
}
function getFrontmatterValue(frontmatter: ParsedHookFrontmatter, key: string): string | undefined {
const raw = frontmatter[key];
return typeof raw === "string" ? raw : undefined;
}
function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (
normalized === "false" ||
normalized === "0" ||
normalized === "no" ||
normalized === "off"
) {
return false;
}
return fallback;
}
export function resolveClawdbotMetadata(
frontmatter: ParsedHookFrontmatter,
): ClawdbotHookMetadata | undefined {
const raw = getFrontmatterValue(frontmatter, "metadata");
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw) as { clawdbot?: unknown };
if (!parsed || typeof parsed !== "object") return undefined;
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
if (!clawdbot || typeof clawdbot !== "object") return undefined;
const clawdbotObj = clawdbot as Record<string, unknown>;
const requiresRaw =
typeof clawdbotObj.requires === "object" && clawdbotObj.requires !== null
? (clawdbotObj.requires as Record<string, unknown>)
: undefined;
const installRaw = Array.isArray(clawdbotObj.install) ? (clawdbotObj.install as unknown[]) : [];
const install = installRaw
.map((entry) => parseInstallSpec(entry))
.filter((entry): entry is HookInstallSpec => Boolean(entry));
const osRaw = normalizeStringList(clawdbotObj.os);
const eventsRaw = normalizeStringList(clawdbotObj.events);
return {
always: typeof clawdbotObj.always === "boolean" ? clawdbotObj.always : undefined,
emoji: typeof clawdbotObj.emoji === "string" ? clawdbotObj.emoji : undefined,
homepage: typeof clawdbotObj.homepage === "string" ? clawdbotObj.homepage : undefined,
hookKey: typeof clawdbotObj.hookKey === "string" ? clawdbotObj.hookKey : undefined,
export: typeof clawdbotObj.export === "string" ? clawdbotObj.export : undefined,
os: osRaw.length > 0 ? osRaw : undefined,
events: eventsRaw.length > 0 ? eventsRaw : [],
requires: requiresRaw
? {
bins: normalizeStringList(requiresRaw.bins),
anyBins: normalizeStringList(requiresRaw.anyBins),
env: normalizeStringList(requiresRaw.env),
config: normalizeStringList(requiresRaw.config),
}
: undefined,
install: install.length > 0 ? install : undefined,
};
} catch {
return undefined;
}
}
export function resolveHookInvocationPolicy(
frontmatter: ParsedHookFrontmatter,
): HookInvocationPolicy {
return {
enabled: parseFrontmatterBool(getFrontmatterValue(frontmatter, "enabled"), true),
};
}
export function resolveHookKey(hookName: string, entry?: HookEntry): string {
return entry?.clawdbot?.hookKey ?? hookName;
}

225
src/hooks/hooks-status.ts Normal file
View File

@@ -0,0 +1,225 @@
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_DIR } from "../utils.js";
import {
hasBinary,
isConfigPathTruthy,
resolveConfigPath,
resolveHookConfig,
} from "./config.js";
import type {
HookEligibilityContext,
HookEntry,
HookInstallSpec,
} from "./types.js";
import { loadWorkspaceHookEntries } from "./workspace.js";
export type HookStatusConfigCheck = {
path: string;
value: unknown;
satisfied: boolean;
};
export type HookInstallOption = {
id: string;
kind: HookInstallSpec["kind"];
label: string;
bins: string[];
};
export type HookStatusEntry = {
name: string;
description: string;
source: string;
filePath: string;
baseDir: string;
handlerPath: string;
hookKey: string;
emoji?: string;
homepage?: string;
events: string[];
always: boolean;
disabled: boolean;
eligible: boolean;
requirements: {
bins: string[];
anyBins: string[];
env: string[];
config: string[];
os: string[];
};
missing: {
bins: string[];
anyBins: string[];
env: string[];
config: string[];
os: string[];
};
configChecks: HookStatusConfigCheck[];
install: HookInstallOption[];
};
export type HookStatusReport = {
workspaceDir: string;
managedHooksDir: string;
hooks: HookStatusEntry[];
};
function resolveHookKey(entry: HookEntry): string {
return entry.clawdbot?.hookKey ?? entry.hook.name;
}
function normalizeInstallOptions(entry: HookEntry): HookInstallOption[] {
const install = entry.clawdbot?.install ?? [];
if (install.length === 0) return [];
// For hooks, we just list all install options
return install.map((spec, index) => {
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (!label) {
if (spec.kind === "bundled") {
label = "Bundled with Clawdbot";
} else if (spec.kind === "npm" && spec.package) {
label = `Install ${spec.package} (npm)`;
} else if (spec.kind === "git" && spec.repository) {
label = `Install from ${spec.repository}`;
} else {
label = "Run installer";
}
}
return { id, kind: spec.kind, label, bins };
});
}
function buildHookStatus(
entry: HookEntry,
config?: ClawdbotConfig,
eligibility?: HookEligibilityContext,
): HookStatusEntry {
const hookKey = resolveHookKey(entry);
const hookConfig = resolveHookConfig(config, hookKey);
const disabled = hookConfig?.enabled === false;
const always = entry.clawdbot?.always === true;
const emoji = entry.clawdbot?.emoji ?? entry.frontmatter.emoji;
const homepageRaw =
entry.clawdbot?.homepage ??
entry.frontmatter.homepage ??
entry.frontmatter.website ??
entry.frontmatter.url;
const homepage = homepageRaw?.trim() ? homepageRaw.trim() : undefined;
const events = entry.clawdbot?.events ?? [];
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? [];
const requiredEnv = entry.clawdbot?.requires?.env ?? [];
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
const requiredOs = entry.clawdbot?.os ?? [];
const missingBins = requiredBins.filter((bin) => {
if (hasBinary(bin)) return false;
if (eligibility?.remote?.hasBin?.(bin)) return false;
return true;
});
const missingAnyBins =
requiredAnyBins.length > 0 &&
!(
requiredAnyBins.some((bin) => hasBinary(bin)) ||
eligibility?.remote?.hasAnyBin?.(requiredAnyBins)
)
? requiredAnyBins
: [];
const missingOs =
requiredOs.length > 0 &&
!requiredOs.includes(process.platform) &&
!eligibility?.remote?.platforms?.some((platform) => requiredOs.includes(platform))
? requiredOs
: [];
const missingEnv: string[] = [];
for (const envName of requiredEnv) {
if (process.env[envName]) continue;
if (hookConfig?.env?.[envName]) continue;
missingEnv.push(envName);
}
const configChecks: HookStatusConfigCheck[] = requiredConfig.map((pathStr) => {
const value = resolveConfigPath(config, pathStr);
const satisfied = isConfigPathTruthy(config, pathStr);
return { path: pathStr, value, satisfied };
});
const missingConfig = configChecks
.filter((check) => !check.satisfied)
.map((check) => check.path);
const missing = always
? { bins: [], anyBins: [], env: [], config: [], os: [] }
: {
bins: missingBins,
anyBins: missingAnyBins,
env: missingEnv,
config: missingConfig,
os: missingOs,
};
const eligible =
!disabled &&
(always ||
(missing.bins.length === 0 &&
missing.anyBins.length === 0 &&
missing.env.length === 0 &&
missing.config.length === 0 &&
missing.os.length === 0));
return {
name: entry.hook.name,
description: entry.hook.description,
source: entry.hook.source,
filePath: entry.hook.filePath,
baseDir: entry.hook.baseDir,
handlerPath: entry.hook.handlerPath,
hookKey,
emoji,
homepage,
events,
always,
disabled,
eligible,
requirements: {
bins: requiredBins,
anyBins: requiredAnyBins,
env: requiredEnv,
config: requiredConfig,
os: requiredOs,
},
missing,
configChecks,
install: normalizeInstallOptions(entry),
};
}
export function buildWorkspaceHookStatus(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedHooksDir?: string;
entries?: HookEntry[];
eligibility?: HookEligibilityContext;
},
): HookStatusReport {
const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks");
const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts);
return {
workspaceDir,
managedHooksDir,
hooks: hookEntries.map((entry) => buildHookStatus(entry, opts?.config, opts?.eligibility)),
};
}

View File

@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearInternalHooks,
createInternalHookEvent,
getRegisteredEventKeys,
registerInternalHook,
triggerInternalHook,
unregisterInternalHook,
type InternalHookEvent,
} from './internal-hooks.js';
describe('internal-hooks', () => {
beforeEach(() => {
clearInternalHooks();
});
afterEach(() => {
clearInternalHooks();
});
describe('registerInternalHook', () => {
it('should register a hook handler', () => {
const handler = vi.fn();
registerInternalHook('command:new', handler);
const keys = getRegisteredEventKeys();
expect(keys).toContain('command:new');
});
it('should allow multiple handlers for the same event', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
registerInternalHook('command:new', handler1);
registerInternalHook('command:new', handler2);
const keys = getRegisteredEventKeys();
expect(keys).toContain('command:new');
});
});
describe('unregisterInternalHook', () => {
it('should unregister a specific handler', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
registerInternalHook('command:new', handler1);
registerInternalHook('command:new', handler2);
unregisterInternalHook('command:new', handler1);
const event = createInternalHookEvent('command', 'new', 'test-session');
void triggerInternalHook(event);
expect(handler1).not.toHaveBeenCalled();
expect(handler2).toHaveBeenCalled();
});
it('should clean up empty handler arrays', () => {
const handler = vi.fn();
registerInternalHook('command:new', handler);
unregisterInternalHook('command:new', handler);
const keys = getRegisteredEventKeys();
expect(keys).not.toContain('command:new');
});
});
describe('triggerInternalHook', () => {
it('should trigger handlers for general event type', async () => {
const handler = vi.fn();
registerInternalHook('command', handler);
const event = createInternalHookEvent('command', 'new', 'test-session');
await triggerInternalHook(event);
expect(handler).toHaveBeenCalledWith(event);
});
it('should trigger handlers for specific event action', async () => {
const handler = vi.fn();
registerInternalHook('command:new', handler);
const event = createInternalHookEvent('command', 'new', 'test-session');
await triggerInternalHook(event);
expect(handler).toHaveBeenCalledWith(event);
});
it('should trigger both general and specific handlers', async () => {
const generalHandler = vi.fn();
const specificHandler = vi.fn();
registerInternalHook('command', generalHandler);
registerInternalHook('command:new', specificHandler);
const event = createInternalHookEvent('command', 'new', 'test-session');
await triggerInternalHook(event);
expect(generalHandler).toHaveBeenCalledWith(event);
expect(specificHandler).toHaveBeenCalledWith(event);
});
it('should handle async handlers', async () => {
const handler = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
registerInternalHook('command:new', handler);
const event = createInternalHookEvent('command', 'new', 'test-session');
await triggerInternalHook(event);
expect(handler).toHaveBeenCalledWith(event);
});
it('should catch and log errors from handlers', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const errorHandler = vi.fn(() => {
throw new Error('Handler failed');
});
const successHandler = vi.fn();
registerInternalHook('command:new', errorHandler);
registerInternalHook('command:new', successHandler);
const event = createInternalHookEvent('command', 'new', 'test-session');
await triggerInternalHook(event);
expect(errorHandler).toHaveBeenCalled();
expect(successHandler).toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Internal hook error'),
expect.stringContaining('Handler failed')
);
consoleError.mockRestore();
});
it('should not throw if no handlers are registered', async () => {
const event = createInternalHookEvent('command', 'new', 'test-session');
await expect(triggerInternalHook(event)).resolves.not.toThrow();
});
});
describe('createInternalHookEvent', () => {
it('should create a properly formatted event', () => {
const event = createInternalHookEvent('command', 'new', 'test-session', {
foo: 'bar',
});
expect(event.type).toBe('command');
expect(event.action).toBe('new');
expect(event.sessionKey).toBe('test-session');
expect(event.context).toEqual({ foo: 'bar' });
expect(event.timestamp).toBeInstanceOf(Date);
});
it('should use empty context if not provided', () => {
const event = createInternalHookEvent('command', 'new', 'test-session');
expect(event.context).toEqual({});
});
});
describe('getRegisteredEventKeys', () => {
it('should return all registered event keys', () => {
registerInternalHook('command:new', vi.fn());
registerInternalHook('command:stop', vi.fn());
registerInternalHook('session:start', vi.fn());
const keys = getRegisteredEventKeys();
expect(keys).toContain('command:new');
expect(keys).toContain('command:stop');
expect(keys).toContain('session:start');
});
it('should return empty array when no handlers are registered', () => {
const keys = getRegisteredEventKeys();
expect(keys).toEqual([]);
});
});
describe('clearInternalHooks', () => {
it('should remove all registered handlers', () => {
registerInternalHook('command:new', vi.fn());
registerInternalHook('command:stop', vi.fn());
clearInternalHooks();
const keys = getRegisteredEventKeys();
expect(keys).toEqual([]);
});
});
describe('integration', () => {
it('should handle a complete hook lifecycle', async () => {
const results: InternalHookEvent[] = [];
const handler = vi.fn((event: InternalHookEvent) => {
results.push(event);
});
// Register
registerInternalHook('command:new', handler);
// Trigger
const event1 = createInternalHookEvent('command', 'new', 'session-1');
await triggerInternalHook(event1);
const event2 = createInternalHookEvent('command', 'new', 'session-2');
await triggerInternalHook(event2);
// Verify
expect(results).toHaveLength(2);
expect(results[0].sessionKey).toBe('session-1');
expect(results[1].sessionKey).toBe('session-2');
// Unregister
unregisterInternalHook('command:new', handler);
// Trigger again - should not call handler
const event3 = createInternalHookEvent('command', 'new', 'session-3');
await triggerInternalHook(event3);
expect(results).toHaveLength(2);
});
});
});

155
src/hooks/internal-hooks.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* Internal hook system for clawdbot agent events
*
* Provides an extensible event-driven hook system for internal agent events
* like command processing, session lifecycle, etc.
*/
export type InternalHookEventType = 'command' | 'session' | 'agent';
export interface InternalHookEvent {
/** The type of event (command, session, agent, etc.) */
type: InternalHookEventType;
/** The specific action within the type (e.g., 'new', 'reset', 'stop') */
action: string;
/** The session key this event relates to */
sessionKey: string;
/** Additional context specific to the event */
context: Record<string, unknown>;
/** Timestamp when the event occurred */
timestamp: Date;
/** Messages to send back to the user (hooks can push to this array) */
messages: string[];
}
export type InternalHookHandler = (event: InternalHookEvent) => Promise<void> | void;
/** Registry of hook handlers by event key */
const handlers = new Map<string, InternalHookHandler[]>();
/**
* Register a hook handler for a specific event type or event:action combination
*
* @param eventKey - Event type (e.g., 'command') or specific action (e.g., 'command:new')
* @param handler - Function to call when the event is triggered
*
* @example
* ```ts
* // Listen to all command events
* registerInternalHook('command', async (event) => {
* console.log('Command:', event.action);
* });
*
* // Listen only to /new commands
* registerInternalHook('command:new', async (event) => {
* await saveSessionToMemory(event);
* });
* ```
*/
export function registerInternalHook(
eventKey: string,
handler: InternalHookHandler
): void {
if (!handlers.has(eventKey)) {
handlers.set(eventKey, []);
}
handlers.get(eventKey)!.push(handler);
}
/**
* Unregister a specific hook handler
*
* @param eventKey - Event key the handler was registered for
* @param handler - The handler function to remove
*/
export function unregisterInternalHook(
eventKey: string,
handler: InternalHookHandler
): void {
const eventHandlers = handlers.get(eventKey);
if (!eventHandlers) {
return;
}
const index = eventHandlers.indexOf(handler);
if (index !== -1) {
eventHandlers.splice(index, 1);
}
// Clean up empty handler arrays
if (eventHandlers.length === 0) {
handlers.delete(eventKey);
}
}
/**
* Clear all registered hooks (useful for testing)
*/
export function clearInternalHooks(): void {
handlers.clear();
}
/**
* Get all registered event keys (useful for debugging)
*/
export function getRegisteredEventKeys(): string[] {
return Array.from(handlers.keys());
}
/**
* Trigger an internal hook event
*
* Calls all handlers registered for:
* 1. The general event type (e.g., 'command')
* 2. The specific event:action combination (e.g., 'command:new')
*
* Handlers are called in registration order. Errors are caught and logged
* but don't prevent other handlers from running.
*
* @param event - The event to trigger
*/
export async function triggerInternalHook(event: InternalHookEvent): Promise<void> {
const typeHandlers = handlers.get(event.type) ?? [];
const specificHandlers = handlers.get(`${event.type}:${event.action}`) ?? [];
const allHandlers = [...typeHandlers, ...specificHandlers];
if (allHandlers.length === 0) {
return;
}
for (const handler of allHandlers) {
try {
await handler(event);
} catch (err) {
console.error(
`Internal hook error [${event.type}:${event.action}]:`,
err instanceof Error ? err.message : String(err)
);
}
}
}
/**
* Create an internal hook event with common fields filled in
*
* @param type - The event type
* @param action - The action within that type
* @param sessionKey - The session key
* @param context - Additional context
*/
export function createInternalHookEvent(
type: InternalHookEventType,
action: string,
sessionKey: string,
context: Record<string, unknown> = {}
): InternalHookEvent {
return {
type,
action,
sessionKey,
context,
timestamp: new Date(),
messages: [],
};
}

View File

@@ -0,0 +1,80 @@
/**
* LLM-based slug generator for session memory filenames
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runEmbeddedPiAgent } from '../agents/pi-embedded.js';
import type { ClawdbotConfig } from '../config/config.js';
import { resolveDefaultAgentId, resolveAgentWorkspaceDir, resolveAgentDir } from '../agents/agent-scope.js';
/**
* Generate a short 1-2 word filename slug from session content using LLM
*/
export async function generateSlugViaLLM(params: {
sessionContent: string;
cfg: ClawdbotConfig;
}): Promise<string | null> {
let tempSessionFile: string | null = null;
try {
const agentId = resolveDefaultAgentId(params.cfg);
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
const agentDir = resolveAgentDir(params.cfg, agentId);
// Create a temporary session file for this one-off LLM call
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clawdbot-slug-'));
tempSessionFile = path.join(tempDir, 'session.jsonl');
const prompt = `Based on this conversation, generate a short 1-2 word filename slug (lowercase, hyphen-separated, no file extension).
Conversation summary:
${params.sessionContent.slice(0, 2000)}
Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", "bug-fix"`;
const result = await runEmbeddedPiAgent({
sessionId: `slug-generator-${Date.now()}`,
sessionKey: 'temp:slug-generator',
sessionFile: tempSessionFile,
workspaceDir,
agentDir,
config: params.cfg,
prompt,
timeoutMs: 15_000, // 15 second timeout
runId: `slug-gen-${Date.now()}`,
});
// Extract text from payloads
if (result.payloads && result.payloads.length > 0) {
const text = result.payloads[0]?.text;
if (text) {
// Clean up the response - extract just the slug
const slug = text
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 30); // Max 30 chars
return slug || null;
}
}
return null;
} catch (err) {
console.error('[llm-slug-generator] Failed to generate slug:', err);
return null;
} finally {
// Clean up temporary session file
if (tempSessionFile) {
try {
await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
}
}

271
src/hooks/loader.test.ts Normal file
View File

@@ -0,0 +1,271 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadInternalHooks } from './loader.js';
import { clearInternalHooks, getRegisteredEventKeys, triggerInternalHook, createInternalHookEvent } from './internal-hooks.js';
import type { ClawdbotConfig } from '../config/config.js';
describe('loader', () => {
let tmpDir: string;
let originalBundledDir: string | undefined;
beforeEach(async () => {
clearInternalHooks();
// Create a temp directory for test modules
tmpDir = path.join(os.tmpdir(), `clawdbot-test-${Date.now()}`);
await fs.mkdir(tmpDir, { recursive: true });
// Disable bundled hooks during tests by setting env var to non-existent directory
originalBundledDir = process.env.CLAWDBOT_BUNDLED_HOOKS_DIR;
process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = '/nonexistent/bundled/hooks';
});
afterEach(async () => {
clearInternalHooks();
// Restore original env var
if (originalBundledDir === undefined) {
delete process.env.CLAWDBOT_BUNDLED_HOOKS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = originalBundledDir;
}
// Clean up temp directory
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('loadInternalHooks', () => {
it('should return 0 when internal hooks are not enabled', async () => {
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: false,
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
});
it('should return 0 when hooks config is missing', async () => {
const cfg: ClawdbotConfig = {};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
});
it('should load a handler from a module', async () => {
// Create a test handler module
const handlerPath = path.join(tmpDir, 'test-handler.js');
const handlerCode = `
export default async function(event) {
// Test handler
}
`;
await fs.writeFile(handlerPath, handlerCode, 'utf-8');
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: true,
handlers: [
{
event: 'command:new',
module: handlerPath,
},
],
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(1);
const keys = getRegisteredEventKeys();
expect(keys).toContain('command:new');
});
it('should load multiple handlers', async () => {
// Create test handler modules
const handler1Path = path.join(tmpDir, 'handler1.js');
const handler2Path = path.join(tmpDir, 'handler2.js');
await fs.writeFile(handler1Path, 'export default async function() {}', 'utf-8');
await fs.writeFile(handler2Path, 'export default async function() {}', 'utf-8');
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: true,
handlers: [
{ event: 'command:new', module: handler1Path },
{ event: 'command:stop', module: handler2Path },
],
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(2);
const keys = getRegisteredEventKeys();
expect(keys).toContain('command:new');
expect(keys).toContain('command:stop');
});
it('should support named exports', async () => {
// Create a handler module with named export
const handlerPath = path.join(tmpDir, 'named-export.js');
const handlerCode = `
export const myHandler = async function(event) {
// Named export handler
}
`;
await fs.writeFile(handlerPath, handlerCode, 'utf-8');
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: true,
handlers: [
{
event: 'command:new',
module: handlerPath,
export: 'myHandler',
},
],
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(1);
});
it('should handle module loading errors gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: true,
handlers: [
{
event: 'command:new',
module: '/nonexistent/path/handler.js',
},
],
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Failed to load internal hook handler'),
expect.any(String)
);
consoleError.mockRestore();
});
it('should handle non-function exports', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
// Create a module with a non-function export
const handlerPath = path.join(tmpDir, 'bad-export.js');
await fs.writeFile(handlerPath, 'export default "not a function";', 'utf-8');
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: true,
handlers: [
{
event: 'command:new',
module: handlerPath,
},
],
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('is not a function')
);
consoleError.mockRestore();
});
it('should handle relative paths', async () => {
// Create a handler module
const handlerPath = path.join(tmpDir, 'relative-handler.js');
await fs.writeFile(handlerPath, 'export default async function() {}', 'utf-8');
// Get relative path from cwd
const relativePath = path.relative(process.cwd(), handlerPath);
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: true,
handlers: [
{
event: 'command:new',
module: relativePath,
},
],
},
},
};
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(1);
});
it('should actually call the loaded handler', async () => {
// Create a handler that we can verify was called
const handlerPath = path.join(tmpDir, 'callable-handler.js');
const handlerCode = `
let callCount = 0;
export default async function(event) {
callCount++;
}
export function getCallCount() {
return callCount;
}
`;
await fs.writeFile(handlerPath, handlerCode, 'utf-8');
const cfg: ClawdbotConfig = {
hooks: {
internal: {
enabled: true,
handlers: [
{
event: 'command:new',
module: handlerPath,
},
],
},
},
};
await loadInternalHooks(cfg, tmpDir);
// Trigger the hook
const event = createInternalHookEvent('command', 'new', 'test-session');
await triggerInternalHook(event);
// The handler should have been called, but we can't directly verify
// the call count from this context without more complex test infrastructure
// This test mainly verifies that loading and triggering doesn't crash
expect(getRegisteredEventKeys()).toContain('command:new');
});
});
});

152
src/hooks/loader.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Dynamic loader for internal hook handlers
*
* Loads hook handlers from external modules based on configuration
* and from directory-based discovery (bundled, managed, workspace)
*/
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { registerInternalHook } from './internal-hooks.js';
import type { ClawdbotConfig } from '../config/config.js';
import type { InternalHookHandler } from './internal-hooks.js';
import { loadWorkspaceHookEntries } from './workspace.js';
import { resolveHookConfig } from './config.js';
import { shouldIncludeHook } from './config.js';
/**
* Load and register all internal hook handlers
*
* Loads hooks from both:
* 1. Directory-based discovery (bundled, managed, workspace)
* 2. Legacy config handlers (backwards compatibility)
*
* @param cfg - Clawdbot configuration
* @param workspaceDir - Workspace directory for hook discovery
* @returns Number of handlers successfully loaded
*
* @example
* ```ts
* const config = await loadConfig();
* const workspaceDir = resolveAgentWorkspaceDir(config, agentId);
* const count = await loadInternalHooks(config, workspaceDir);
* console.log(`Loaded ${count} internal hook handlers`);
* ```
*/
export async function loadInternalHooks(
cfg: ClawdbotConfig,
workspaceDir: string,
): Promise<number> {
// Check if internal hooks are enabled
if (!cfg.hooks?.internal?.enabled) {
return 0;
}
let loadedCount = 0;
// 1. Load hooks from directories (new system)
try {
const hookEntries = loadWorkspaceHookEntries(workspaceDir, { config: cfg });
// Filter by eligibility
const eligible = hookEntries.filter((entry) =>
shouldIncludeHook({ entry, config: cfg }),
);
for (const entry of eligible) {
const hookConfig = resolveHookConfig(cfg, entry.hook.name);
// Skip if explicitly disabled in config
if (hookConfig?.enabled === false) {
continue;
}
try {
// Import handler module with cache-busting
const url = pathToFileURL(entry.hook.handlerPath).href;
const cacheBustedUrl = `${url}?t=${Date.now()}`;
const mod = (await import(cacheBustedUrl)) as Record<string, unknown>;
// Get handler function (default or named export)
const exportName = entry.clawdbot?.export ?? 'default';
const handler = mod[exportName];
if (typeof handler !== 'function') {
console.error(
`Internal hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`,
);
continue;
}
// Register for all events listed in metadata
const events = entry.clawdbot?.events ?? [];
if (events.length === 0) {
console.warn(
`Internal hook warning: Hook '${entry.hook.name}' has no events defined in metadata`,
);
continue;
}
for (const event of events) {
registerInternalHook(event, handler as InternalHookHandler);
}
console.log(
`Registered internal hook: ${entry.hook.name} -> ${events.join(', ')}${exportName !== 'default' ? ` (export: ${exportName})` : ''}`,
);
loadedCount++;
} catch (err) {
console.error(
`Failed to load internal hook ${entry.hook.name}:`,
err instanceof Error ? err.message : String(err),
);
}
}
} catch (err) {
console.error(
'Failed to load directory-based hooks:',
err instanceof Error ? err.message : String(err),
);
}
// 2. Load legacy config handlers (backwards compatibility)
const handlers = cfg.hooks.internal.handlers ?? [];
for (const handlerConfig of handlers) {
try {
// Resolve module path (absolute or relative to cwd)
const modulePath = path.isAbsolute(handlerConfig.module)
? handlerConfig.module
: path.join(process.cwd(), handlerConfig.module);
// Import the module with cache-busting to ensure fresh reload
const url = pathToFileURL(modulePath).href;
const cacheBustedUrl = `${url}?t=${Date.now()}`;
const mod = (await import(cacheBustedUrl)) as Record<string, unknown>;
// Get the handler function
const exportName = handlerConfig.export ?? 'default';
const handler = mod[exportName];
if (typeof handler !== 'function') {
console.error(
`Internal hook error: Handler '${exportName}' from ${modulePath} is not a function`,
);
continue;
}
// Register the handler
registerInternalHook(handlerConfig.event, handler as InternalHookHandler);
console.log(
`Registered internal hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== 'default' ? `#${exportName}` : ''}`,
);
loadedCount++;
} catch (err) {
console.error(
`Failed to load internal hook handler from ${handlerConfig.module}:`,
err instanceof Error ? err.message : String(err),
);
}
}
return loadedCount;
}

64
src/hooks/types.ts Normal file
View File

@@ -0,0 +1,64 @@
export type HookInstallSpec = {
id?: string;
kind: "bundled" | "npm" | "git";
label?: string;
package?: string;
repository?: string;
bins?: string[];
};
export type ClawdbotHookMetadata = {
always?: boolean;
hookKey?: string;
emoji?: string;
homepage?: string;
/** Events this hook handles (e.g., ["command:new", "session:start"]) */
events: string[];
/** Optional export name (default: "default") */
export?: string;
os?: string[];
requires?: {
bins?: string[];
anyBins?: string[];
env?: string[];
config?: string[];
};
install?: HookInstallSpec[];
};
export type HookInvocationPolicy = {
enabled: boolean;
};
export type ParsedHookFrontmatter = Record<string, string>;
export type Hook = {
name: string;
description: string;
source: "clawdbot-bundled" | "clawdbot-managed" | "clawdbot-workspace";
filePath: string; // Path to HOOK.md
baseDir: string; // Directory containing hook
handlerPath: string; // Path to handler module (handler.ts/js)
};
export type HookEntry = {
hook: Hook;
frontmatter: ParsedHookFrontmatter;
clawdbot?: ClawdbotHookMetadata;
invocation?: HookInvocationPolicy;
};
export type HookEligibilityContext = {
remote?: {
platforms: string[];
hasBin: (bin: string) => boolean;
hasAnyBin: (bins: string[]) => boolean;
note?: string;
};
};
export type HookSnapshot = {
hooks: Array<{ name: string; events: string[] }>;
resolvedHooks?: Hook[];
version?: number;
};

197
src/hooks/workspace.ts Normal file
View File

@@ -0,0 +1,197 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveBundledHooksDir } from "./bundled-dir.js";
import { shouldIncludeHook } from "./config.js";
import {
parseFrontmatter,
resolveClawdbotMetadata,
resolveHookInvocationPolicy,
} from "./frontmatter.js";
import type {
Hook,
HookEligibilityContext,
HookEntry,
HookSnapshot,
ParsedHookFrontmatter,
} from "./types.js";
function filterHookEntries(
entries: HookEntry[],
config?: ClawdbotConfig,
eligibility?: HookEligibilityContext,
): HookEntry[] {
return entries.filter((entry) => shouldIncludeHook({ entry, config, eligibility }));
}
/**
* Scan a directory for hooks (subdirectories containing HOOK.md)
*/
function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
const { dir, source } = params;
// Check if directory exists
if (!fs.existsSync(dir)) return [];
const stat = fs.statSync(dir);
if (!stat.isDirectory()) return [];
const hooks: Hook[] = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const hookDir = path.join(dir, entry.name);
const hookMdPath = path.join(hookDir, "HOOK.md");
// Skip if no HOOK.md file
if (!fs.existsSync(hookMdPath)) continue;
try {
// Read HOOK.md to extract name and description
const content = fs.readFileSync(hookMdPath, "utf-8");
const frontmatter = parseFrontmatter(content);
const name = frontmatter.name || entry.name;
const description = frontmatter.description || "";
// Locate handler file (handler.ts, handler.js, index.ts, index.js)
const handlerCandidates = [
"handler.ts",
"handler.js",
"index.ts",
"index.js",
];
let handlerPath: string | undefined;
for (const candidate of handlerCandidates) {
const candidatePath = path.join(hookDir, candidate);
if (fs.existsSync(candidatePath)) {
handlerPath = candidatePath;
break;
}
}
// Skip if no handler file found
if (!handlerPath) {
console.warn(`[hooks] Hook "${name}" has HOOK.md but no handler file in ${hookDir}`);
continue;
}
hooks.push({
name,
description,
source: source as Hook["source"],
filePath: hookMdPath,
baseDir: hookDir,
handlerPath,
});
} catch (err) {
console.warn(`[hooks] Failed to load hook from ${hookDir}:`, err);
continue;
}
}
return hooks;
}
function loadHookEntries(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedHooksDir?: string;
bundledHooksDir?: string;
},
): HookEntry[] {
const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks");
const workspaceHooksDir = path.join(workspaceDir, "hooks");
const bundledHooksDir = opts?.bundledHooksDir ?? resolveBundledHooksDir();
const extraDirsRaw = opts?.config?.hooks?.internal?.load?.extraDirs ?? [];
const extraDirs = extraDirsRaw
.map((d) => (typeof d === "string" ? d.trim() : ""))
.filter(Boolean);
const bundledHooks = bundledHooksDir
? loadHooksFromDir({
dir: bundledHooksDir,
source: "clawdbot-bundled",
})
: [];
const extraHooks = extraDirs.flatMap((dir) => {
const resolved = resolveUserPath(dir);
return loadHooksFromDir({
dir: resolved,
source: "clawdbot-workspace", // Extra dirs treated as workspace
});
});
const managedHooks = loadHooksFromDir({
dir: managedHooksDir,
source: "clawdbot-managed",
});
const workspaceHooks = loadHooksFromDir({
dir: workspaceHooksDir,
source: "clawdbot-workspace",
});
const merged = new Map<string, Hook>();
// Precedence: extra < bundled < managed < workspace (workspace wins)
for (const hook of extraHooks) merged.set(hook.name, hook);
for (const hook of bundledHooks) merged.set(hook.name, hook);
for (const hook of managedHooks) merged.set(hook.name, hook);
for (const hook of workspaceHooks) merged.set(hook.name, hook);
const hookEntries: HookEntry[] = Array.from(merged.values()).map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
try {
const raw = fs.readFileSync(hook.filePath, "utf-8");
frontmatter = parseFrontmatter(raw);
} catch {
// ignore malformed hooks
}
return {
hook,
frontmatter,
clawdbot: resolveClawdbotMetadata(frontmatter),
invocation: resolveHookInvocationPolicy(frontmatter),
};
});
return hookEntries;
}
export function buildWorkspaceHookSnapshot(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedHooksDir?: string;
bundledHooksDir?: string;
entries?: HookEntry[];
eligibility?: HookEligibilityContext;
snapshotVersion?: number;
},
): HookSnapshot {
const hookEntries = opts?.entries ?? loadHookEntries(workspaceDir, opts);
const eligible = filterHookEntries(hookEntries, opts?.config, opts?.eligibility);
return {
hooks: eligible.map((entry) => ({
name: entry.hook.name,
events: entry.clawdbot?.events ?? [],
})),
resolvedHooks: eligible.map((entry) => entry.hook),
version: opts?.snapshotVersion,
};
}
export function loadWorkspaceHookEntries(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedHooksDir?: string;
bundledHooksDir?: string;
},
): HookEntry[] {
return loadHookEntries(workspaceDir, opts);
}

View File

@@ -19,6 +19,7 @@ import {
} from "../commands/onboard-helpers.js";
import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js";
import { setupSkills } from "../commands/onboard-skills.js";
import { setupInternalHooks } from "../commands/onboard-hooks.js";
import type {
GatewayAuthChoice,
OnboardMode,
@@ -403,6 +404,10 @@ export async function runOnboardingWizard(
} else {
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
}
// Setup internal hooks (session memory on /new)
nextConfig = await setupInternalHooks(nextConfig, runtime, prompter);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);