# OrchestKit Hook Architecture

2 redundant systems 6 dead files Daemon fix planned
Overview
Wiring Diagram
Feature Matrix
Dead Code
Daemon Architecture
Performance
Preservation
2
Redundant spawn systems
8
Fire-and-forget hooks in hooks.json
6
Dead entry point files
N
Processes spawned per session
428
Lines in background worker
1
Daemon needed (proposed)
System A: run-hook-silent 7 hooks ACTIVE
Hook Event
→
run-hook-silent.mjs
→
spawn('node', [...])
→
run-hook-background.mjs
→
Bundle (posttool.mjs, etc.)
Transport: Base64 CLI argument | Spawn: `detached: !IS_WINDOWS` | Features: Metrics, PID tracking, debug config, input normalization, 60s timeout
System B: spawn-worker 1 hook 6 DEAD
Hook Event
→
*-fire-and-forget.mjs
→
spawn-worker.mjs
→
spawn('node', [...])
→
background-worker.mjs
→
Bundle (posttool.mjs, etc.)
Transport: Temp JSON file in .claude/hooks/pending/ | Spawn: `detached: true` (always) | Features: 5min timeout, orphan cleanup. NO metrics, NO PID, NO debug, NO normalization
Only `stop-fire-and-forget.mjs` is wired in hooks.json. The other 6 entry points are dead code.
System C: fire-and-forget.ts DEAD CODE
fire-and-forget.ts
→
(never imported)
0% test coverage. Zero imports in src/. Has ESM `__filename` bug. TypeScript duplicate of System B.
hooks.json Wiring Map
Every hook event and how it's executed. Purple = fire-and-forget (spawns background process). Blue = synchronous. Orange = fire-and-forget via System B.
run-hook-silent.mjs (System A, fire-and-forget)
run-hook.mjs (synchronous)
*-fire-and-forget.mjs (System B)
Dead code (not in hooks.json)
● Setup
setup/unified-dispatcherSystem A: run-hook-silent.mjs → background
setup/setup-check
setup/first-run-setup
setup/setup-maintenance
setup/setup-repair
setup/monorepo-detector
● SessionStart
lifecycle/unified-dispatcherSystem A: 6 sub-hooks in parallel
lifecycle/session-context-loader
lifecycle/analytics-consent-check
lifecycle/pr-status-enricher
lifecycle/prefill-guard
🔥 PostToolUse
posttool/unified-dispatcherHOT PATH! Fires on every tool call. 15 sub-hooks. System A spawn.
posttool/context-budget-monitor
posttool/unified-error-handler
posttool/write/unified-write-quality-dispatcher
skill/redact-secrets
posttool/task/team-member-start
● UserPromptSubmit
prompt/unified-dispatcher
prompt/capture-user-intentSystem A: fire-and-forget
● PermissionRequest
permission/unified-dispatcher
permission/dangerous-command-guard
permission/write-guard
● Stop
stop-fire-and-forget.mjsSystem B: spawn-worker → background-worker. 23 sub-hooks.
stop-uncommitted-check.mjs
● SubagentStop
subagent-stop/unified-dispatcher
lifecycle/session-metrics-updater
subagent-stop/team-member-tracker
● Notification
notification/unified-dispatcher
notification/sound
● SessionEnd
lifecycle/session-metrics-summary
lifecycle/session-cleanup
lifecycle/pattern-sync-push
● PreToolUse
14 sync hooks via run-hook.mjs

### Key Insight

8 fire-and-forget hooks (7 System A + 1 System B) each spawn a new Node.js process per event. The PostToolUse hot path alone fires on every `Bash|Write|Edit|Task|Skill|NotebookEdit` call, spawning a fresh ~30MB Node.js process each time just to run 15 lightweight file-append sub-hooks.

Feature Comparison: System A vs System B
Complete feature inventory across both background execution systems.

Feature | System A (run-hook-silent) | System B (spawn-worker) | System C (fire-and-forget.ts)
--- | --- | --- | ---
Status | Active (7 hooks) | Mostly dead (1 of 7 hooks) | Dead (0 imports)
IPC Mechanism | Base64 CLI argument | Temp JSON file (.claude/hooks/pending/) | Same as System B
Execution Timeout | 60 seconds | 5 minutes (5x longer!) | N/A
Metrics Tracking | metrics.json (atomic write) | None | N/A
PID File Management | .claude/hooks/pids/*.pid | None | N/A
Debug Config | .claude/hooks/debug.json | None | N/A
Input Normalization | 4 fields (camelCase/snake_case) | None (dispatchers handle it) | N/A
Hook Name Sanitization | SEC-001: regex replace | UUID-based (implicit) | N/A
Structured Logging | JSON to background-hooks.log | Plain text to background-worker.log | console.error only
Stale Process Cleanup | PID file scan on startup | Orphaned file cleanup (10min) | N/A
Windows Spawn Strategy | detached: !IS_WINDOWS | detached: true (always) | detached: true (always)
Stdin Handling | Event-based with 100ms timeout | Async iterator, no timeout | N/A (TypeScript API)
Bundle Loading | Map + hooks[name] lookup | Hardcoded dispatcher registry | N/A
Test Coverage | Extensive (16 exports tested) | Minimal (wiring test only) | 0%
hooks.json Entries | 7 | 1 (stop only) | 0

Dead Code Inventory
Files that exist on disk but have zero runtime consumers. Safe to delete.
7
Dead files
~350
Dead lines of code
0%
Test coverage

### 6 Unused Fire-and-Forget Entry Points

These scripts import from `spawn-worker.mjs` but are NOT referenced in hooks.json. The actual hooks use `run-hook-silent.mjs` (System A) instead.

### fire-and-forget.ts (TypeScript Library)

0%
Coverage
0
Imports
1
ESM Bug

`src/hooks/src/lib/fire-and-forget.ts` — Uses `__filename` which is undefined in ESM. Never imported by any source file. TypeScript duplicate of spawn-worker.mjs.

### Near-Dead: spawn-worker.mjs + background-worker.mjs

Only consumed by `stop-fire-and-forget.mjs` (1 hook). After daemon migration, these become fully dead. 254 lines of code serving a single hook.

Proposed: Persistent Hook Daemon
Replace per-event process spawning with a single long-running daemon using node:net IPC.
BEFORE

### Per-Event Process Spawning

Each dot = new Node.js process (~30MB)
Per tool call: spawn() → Node cold start → load bundle → execute → exit
Overhead: ~50-100ms per event
Memory: N × ~30MB concurrent processes
Windows: Console window per spawn
AFTER

### Persistent Daemon + Socket IPC

1 daemon, messages via Unix socket
Per tool call: socket.connect() → send JSON → done
Overhead: ~0.2ms per event (130μs socket + JSON)
Memory: 1 × ~50MB daemon (bundles cached)
Windows: 1 spawn at session start (hidden)

### Daemon Lifecycle

First hook fires
→
Try socket connect
→
ECONNREFUSED
→
Spawn daemon (once)
→
node:net server binds
→
Ready for work
Subsequent hooks
→
socket.connect()
→
Send JSON
→
Dispatch (cached bundle)
SessionEnd
→
{"type":"shutdown"}
→
Flush metrics
→
Cleanup socket
→
Exit

### Socket Path (Cross-Platform)

Unix/macOS: /tmp/orchestkit-{sessionId}.sock
Windows: \\.\pipe\orchestkit-{sessionId}
Same `node:net` API handles both. Zero external dependencies. Session-scoped = parallel sessions don't interfere.

### Fallback Safety

If daemon fails to start (2s timeout), client falls back to `run-hook-background.mjs` spawn (current behavior). Zero risk of lost functionality. Rollback = revert one file.

Performance Comparison
Per-event overhead: spawn-per-event vs persistent daemon with socket IPC.

### Per-Event Overhead

Spawn-per-event
~75ms avg
Daemon (socket IPC)
~0.2ms
375x faster per event

### Session Impact (100 tool calls)

Spawn overhead
~7.5 seconds total
Daemon overhead
~20ms total

### Memory Usage

Spawn (peak, 5 concurrent)
~150MB (5 x 30MB)
Daemon (constant)
~50MB (1 process)

### Windows Console Windows

Spawn-per-event
~100+ windows (session)
Daemon
1 window (hidden)
Feature Preservation Matrix
Every feature from both systems mapped to its daemon equivalent. Nothing lost.
Preserved (same behavior)
Improved (better in daemon)
Simplified (less complexity)
Removed (no consumers)

Feature | Current | Daemon | Status
--- | --- | --- | ---
Silent JSON output | run-hook-silent.mjs outputs before exit | Client still outputs before exit | Preserved
Input normalization | run-hook-background.mjs (4 fields) | Daemon normalizes on receive | Preserved
Execution timeout | 60s per-process timeout | 60s per-task timeout in daemon | Preserved
Bundle loading | Cold-load every event (~50ms) | Cached in Map, loaded once per session | Improved (375x faster)
Metrics tracking | Atomic file write per event | In-memory, periodic flush to disk | Improved (less I/O)
Debug config | Read from disk per event | Loaded once, hot-reload on SIGHUP | Improved (less I/O)
Error logging | JSON lines to background-hooks.log | Same file, same format | Preserved
Hook name sanitization | SEC-001 regex in background worker | Same sanitizer in daemon | Preserved
Stdin reading + timeout | 100ms timeout in run-hook-silent | Same pattern in client | Preserved
Env var passthrough | 3 vars passed to child env | Sent in work payload | Preserved
Windows behavior | detached: !IS_WINDOWS (flaky) | No per-event spawn = no console windows | Root cause eliminated
PID tracking | Per-hook PID files in .claude/hooks/pids/ | Single daemon PID file | Simplified
Stale PID cleanup | Scan pids/ dir on every startup | Single daemon; idle timeout prevents orphans | Simplified
Base64 CLI transport | Input encoded as CLI arg | JSON over socket | Replaced (internal)
Temp file IPC | .claude/hooks/pending/*.json | Direct socket send | Replaced (internal)
Orphan file cleanup | 10min sweep of pending/ dir | No temp files to orphan | Not needed

### Fallback Guarantee

`run-hook-background.mjs` is kept as fallback. If daemon is unreachable, client degrades to current spawn behavior. Zero risk. Incremental rollout. Rollback = revert one file (`run-hook-silent.mjs`).

    Architecture Analysis Summary:

    CURRENT STATE:
    - 2 redundant spawn systems (System A: run-hook-silent, System B: spawn-worker)
    - 8 fire-and-forget hooks spawn a new Node.js process per event
    - 6 dead fire-and-forget entry points + 1 dead TypeScript file (fire-and-forget.ts)
    - PostToolUse hot path spawns ~30MB process on every tool call
    - Windows: console windows flash on each spawn

    PROPOSED FIX: Persistent hook daemon with node:net socket IPC
    - 1 daemon per session (lazy-start on first hook event)
    - Bundles cached in memory (375x faster per-event)
    - Unix domain socket (Unix/macOS) / named pipe (Windows)
    - Zero new dependencies (node:net is stdlib)
    - All 16 features preserved or improved
    - Fallback to current spawn if daemon unavailable

    FILES: Create hook-daemon.mjs + daemon-client.mjs, modify run-hook-silent.mjs,
    delete 7 dead files (6 entry points + fire-and-forget.ts)