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)