mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
feat: add Claude Code hooks for desktop notifications
Wire Claude Code's official hooks system (Notification, Stop) to POST back to Claudeman's new /api/hook-event endpoint, which broadcasts SSE events consumed by the existing NotificationManager for desktop alerts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ Claudeman is a Claude Code session manager with a web interface and autonomous R
|
|||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||||
|
|
||||||
**Key Dependencies**: fastify (REST API), node-pty (PTY spawning), ink/react (TUI), xterm.js (web terminal)
|
**Key Dependencies**: fastify (REST API), node-pty (PTY spawning), ink/react (TUI), xterm.js (web terminal), @modelcontextprotocol/sdk (MCP server for spawn protocol)
|
||||||
|
|
||||||
**Requirements**: Node.js 18+, Claude CLI (`claude`) in PATH, GNU Screen (`apt install screen` / `brew install screen`)
|
**Requirements**: Node.js 18+, Claude CLI (`claude`) in PATH, GNU Screen (`apt install screen` / `brew install screen`)
|
||||||
|
|
||||||
@@ -34,12 +34,13 @@ npm install
|
|||||||
**CRITICAL**: `npm run dev` runs CLI help, NOT the web server. Use `npx tsx src/index.ts web` for development.
|
**CRITICAL**: `npm run dev` runs CLI help, NOT the web server. Use `npx tsx src/index.ts web` for development.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # Compile TypeScript + copy static files to dist/web/
|
npm run build # Compile TS + copy static files + templates + make bins executable
|
||||||
npm run clean # Remove dist/
|
npm run clean # Remove dist/
|
||||||
|
|
||||||
# Start web server (pick one):
|
# Start web server (pick one):
|
||||||
npx tsx src/index.ts web # Dev mode - no build needed (RECOMMENDED)
|
npx tsx src/index.ts web # Dev mode - no build needed (RECOMMENDED)
|
||||||
npx tsx src/index.ts web -p 8080 # Dev mode with custom port
|
npx tsx src/index.ts web -p 8080 # Dev mode with custom port
|
||||||
|
npx tsx src/index.ts web --https # Dev mode with self-signed TLS (enables browser notifications)
|
||||||
npm run web # After npm run build (shorthand)
|
npm run web # After npm run build (shorthand)
|
||||||
node dist/index.js web # After npm run build
|
node dist/index.js web # After npm run build
|
||||||
claudeman web # After npm link
|
claudeman web # After npm link
|
||||||
@@ -68,7 +69,7 @@ npx vitest run -t "should create session" # By pattern
|
|||||||
# 3115: integration-flows.test.ts
|
# 3115: integration-flows.test.ts
|
||||||
# 3120: session-cleanup.test.ts
|
# 3120: session-cleanup.test.ts
|
||||||
# 3125: ralph-integration.test.ts
|
# 3125: ralph-integration.test.ts
|
||||||
# Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator
|
# Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, hooks-config
|
||||||
# Next available: 3127+
|
# Next available: 3127+
|
||||||
|
|
||||||
# Tests mock PTY - no real Claude CLI spawned
|
# Tests mock PTY - no real Claude CLI spawned
|
||||||
@@ -86,6 +87,12 @@ npx vitest run -t "should create session" # By pattern
|
|||||||
npm run typecheck # Type check without building (or: npx tsc --noEmit)
|
npm run typecheck # Type check without building (or: npx tsc --noEmit)
|
||||||
# Note: No ESLint/Prettier configured - rely on TypeScript strict mode
|
# Note: No ESLint/Prettier configured - rely on TypeScript strict mode
|
||||||
|
|
||||||
|
# MCP Server (for Claude Code to call spawn tools directly):
|
||||||
|
# Configure in Claude Code's MCP settings:
|
||||||
|
# command: "node", args: ["dist/mcp-server.js"]
|
||||||
|
# env: { CLAUDEMAN_API_URL: "http://localhost:3000", CLAUDEMAN_SESSION_ID: "<id>" }
|
||||||
|
npx tsx src/mcp-server.ts # Dev mode (stdio transport)
|
||||||
|
|
||||||
# Debugging
|
# Debugging
|
||||||
screen -ls # List GNU screen sessions
|
screen -ls # List GNU screen sessions
|
||||||
screen -r <name> # Attach to screen session (Ctrl+A D to detach)
|
screen -r <name> # Attach to screen session (Ctrl+A D to detach)
|
||||||
@@ -145,13 +152,16 @@ claudeman reset # Reset all state
|
|||||||
| `src/tui/App.tsx` | TUI main component: tabs, terminal viewport, status bar (Ink/React) |
|
| `src/tui/App.tsx` | TUI main component: tabs, terminal viewport, status bar (Ink/React) |
|
||||||
| `src/tui/components/*.tsx` | TUI components: StartScreen, TabBar, TerminalView, StatusBar, RalphPanel, HelpOverlay |
|
| `src/tui/components/*.tsx` | TUI components: StartScreen, TabBar, TerminalView, StatusBar, RalphPanel, HelpOverlay |
|
||||||
| `src/tui/hooks/useSessionManager.ts` | TUI session state, screen polling, input handling |
|
| `src/tui/hooks/useSessionManager.ts` | TUI session state, screen polling, input handling |
|
||||||
|
| `src/hooks-config.ts` | Generates .claude/settings.local.json with Claude Code hooks for desktop notifications |
|
||||||
| `src/types.ts` | All TypeScript interfaces |
|
| `src/types.ts` | All TypeScript interfaces |
|
||||||
| `src/templates/claude-md.ts` | CLAUDE.md template generation with placeholder support |
|
| `src/templates/claude-md.ts` | CLAUDE.md template generation with placeholder support |
|
||||||
| `src/templates/case-template.md` | Default CLAUDE.md template for new cases (with placeholders) |
|
| `src/templates/case-template.md` | Default CLAUDE.md template for new cases (with placeholders) |
|
||||||
| `src/spawn-types.ts` | Types, YAML parser, factory functions for spawn1337 protocol |
|
| `src/spawn-types.ts` | Types, YAML parser, factory functions for spawn1337 protocol |
|
||||||
| `src/spawn-detector.ts` | Detects `<spawn1337>` tags in terminal output (like ralph-tracker.ts) |
|
| `src/spawn-detector.ts` | Detects `<spawn1337>` tags in terminal output (legacy, replaced by MCP) |
|
||||||
| `src/spawn-orchestrator.ts` | Full agent lifecycle: spawn, monitor, budget, queue, cleanup |
|
| `src/spawn-orchestrator.ts` | Full agent lifecycle: spawn, monitor, budget, queue, cleanup |
|
||||||
| `src/spawn-claude-md.ts` | Generates CLAUDE.md for spawned agent sessions |
|
| `src/spawn-claude-md.ts` | Generates CLAUDE.md for spawned agent sessions |
|
||||||
|
| `src/mcp-server.ts` | MCP server binary (`claudeman-mcp`) exposing spawn tools to Claude Code |
|
||||||
|
| `src/tui/DirectAttach.ts` | Full-screen console attach with tab switching between sessions |
|
||||||
|
|
||||||
### Data Flow
|
### Data Flow
|
||||||
|
|
||||||
@@ -183,12 +193,26 @@ Steps can be skipped via config (`sendClear: false`, `sendInit: false`). Optiona
|
|||||||
|
|
||||||
Spawned agents are full-power Claude sessions running in their own screen sessions. They communicate via a filesystem-based message bus and signal completion via RalphTracker's `<promise>` mechanism.
|
Spawned agents are full-power Claude sessions running in their own screen sessions. They communicate via a filesystem-based message bus and signal completion via RalphTracker's `<promise>` mechanism.
|
||||||
|
|
||||||
**Protocol Flow:**
|
**Primary Interface: MCP Server** (`claudeman-mcp` binary). Claude Code calls spawn tools directly via MCP protocol, replacing the legacy terminal-tag-parsing approach (SpawnDetector).
|
||||||
|
|
||||||
|
**MCP Tools:**
|
||||||
|
- `spawn_agent` - Spawn a new autonomous agent (builds task spec from parameters)
|
||||||
|
- `list_agents` - List all agents (active + completed + queued)
|
||||||
|
- `get_agent_status` - Get detailed agent status + progress
|
||||||
|
- `get_agent_result` - Read a completed agent's result
|
||||||
|
- `send_agent_message` - Send a message to a running agent
|
||||||
|
- `cancel_agent` - Cancel a running agent
|
||||||
|
|
||||||
|
**MCP Environment Variables:**
|
||||||
|
- `CLAUDEMAN_API_URL` - Base URL for the Claudeman API (default: `http://localhost:3000`)
|
||||||
|
- `CLAUDEMAN_SESSION_ID` - Session ID of the calling Claude session
|
||||||
|
|
||||||
|
**Protocol Flow (via MCP):**
|
||||||
```
|
```
|
||||||
Parent outputs <spawn1337>task.md</spawn1337>
|
Claude calls spawn_agent MCP tool
|
||||||
→ SpawnDetector parses tag
|
→ MCP server builds task spec YAML
|
||||||
→ SpawnOrchestrator reads + parses task spec file (YAML frontmatter)
|
→ POST /api/spawn/trigger with task spec
|
||||||
→ Creates agent directory: ~/claudeman-cases/spawn-<agentId>/
|
→ SpawnOrchestrator creates agent directory: ~/claudeman-cases/spawn-<agentId>/
|
||||||
→ Spawns interactive Claude session in screen
|
→ Spawns interactive Claude session in screen
|
||||||
→ Injects initial prompt via writeViaScreen()
|
→ Injects initial prompt via writeViaScreen()
|
||||||
→ Agent works autonomously, writes progress to spawn-comms/
|
→ Agent works autonomously, writes progress to spawn-comms/
|
||||||
@@ -196,7 +220,7 @@ Parent outputs <spawn1337>task.md</spawn1337>
|
|||||||
→ Orchestrator reads result.md, notifies parent via SSE
|
→ Orchestrator reads result.md, notifies parent via SSE
|
||||||
```
|
```
|
||||||
|
|
||||||
**Tag Patterns** (detected by `SpawnDetector`):
|
**Legacy Tag Patterns** (detected by `SpawnDetector`, still functional but superseded by MCP):
|
||||||
- `<spawn1337>path/to/task.md</spawn1337>` - Spawn request
|
- `<spawn1337>path/to/task.md</spawn1337>` - Spawn request
|
||||||
- `<spawn1337-status agentId="id"/>` - Status query
|
- `<spawn1337-status agentId="id"/>` - Status query
|
||||||
- `<spawn1337-cancel agentId="id"/>` - Cancel request
|
- `<spawn1337-cancel agentId="id"/>` - Cancel request
|
||||||
@@ -241,7 +265,7 @@ Task instructions here...
|
|||||||
- Max spawn depth: 3 (prevents infinite recursion)
|
- Max spawn depth: 3 (prevents infinite recursion)
|
||||||
- Default timeout: 30 minutes (max: 120)
|
- Default timeout: 30 minutes (max: 120)
|
||||||
|
|
||||||
**Session Integration:** Each session has a `SpawnDetector` (alongside RalphTracker) that forwards terminal data. Spawn events (`spawnRequested`, `spawnStatusRequested`, `spawnCancelRequested`, `spawnMessageToChild`) are emitted on the session and wired to the orchestrator in server.ts.
|
**Session Integration:** The MCP server communicates with the Claudeman API over HTTP. Legacy SpawnDetector (alongside RalphTracker) can still forward terminal data for tag-based spawning. Spawn events (`spawnRequested`, `spawnStatusRequested`, `spawnCancelRequested`, `spawnMessageToChild`) are emitted on the session and wired to the orchestrator in server.ts.
|
||||||
|
|
||||||
**Agent Tree:** Agents can spawn children (up to `maxSpawnDepth`). Sessions track `parentAgentId` and `childAgentIds`. Cancelling a parent cascades to all children.
|
**Agent Tree:** Agents can spawn children (up to `maxSpawnDepth`). Sessions track `parentAgentId` and `childAgentIds`. Cancelling a parent cascades to all children.
|
||||||
|
|
||||||
@@ -440,7 +464,7 @@ Tab switch/new session fix: clear xterm → write buffer → resize PTY → Ctrl
|
|||||||
|
|
||||||
All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`.
|
All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`.
|
||||||
|
|
||||||
Event prefixes: `session:`, `task:`, `respawn:`, `spawn:`, `scheduled:`, `case:`, `screen:`, `init`.
|
Event prefixes: `session:`, `task:`, `respawn:`, `spawn:`, `hook:`, `scheduled:`, `case:`, `screen:`, `init`.
|
||||||
|
|
||||||
Key events for frontend handling (see `app.js:handleSSEEvent()`):
|
Key events for frontend handling (see `app.js:handleSSEEvent()`):
|
||||||
- `session:idle`, `session:working` - Status indicator updates
|
- `session:idle`, `session:working` - Status indicator updates
|
||||||
@@ -450,6 +474,7 @@ Key events for frontend handling (see `app.js:handleSSEEvent()`):
|
|||||||
- `respawn:detectionUpdate` - Multi-layer idle detection status (confidence level, waiting state)
|
- `respawn:detectionUpdate` - Multi-layer idle detection status (confidence level, waiting state)
|
||||||
- `spawn:queued`, `spawn:started`, `spawn:completed`, `spawn:failed`, `spawn:timeout`, `spawn:cancelled` - Agent lifecycle
|
- `spawn:queued`, `spawn:started`, `spawn:completed`, `spawn:failed`, `spawn:timeout`, `spawn:cancelled` - Agent lifecycle
|
||||||
- `spawn:progress`, `spawn:message`, `spawn:budgetWarning`, `spawn:stateUpdate` - Agent monitoring
|
- `spawn:progress`, `spawn:message`, `spawn:budgetWarning`, `spawn:stateUpdate` - Agent monitoring
|
||||||
|
- `hook:idle_prompt`, `hook:permission_prompt`, `hook:stop` - Claude Code hooks (desktop notifications)
|
||||||
|
|
||||||
### Frontend (app.js)
|
### Frontend (app.js)
|
||||||
|
|
||||||
@@ -463,6 +488,18 @@ Vanilla JS + xterm.js. Key functions:
|
|||||||
- Client uses `requestAnimationFrame` to batch xterm.js writes
|
- Client uses `requestAnimationFrame` to batch xterm.js writes
|
||||||
- Prevents UI jank during high-throughput Claude output
|
- Prevents UI jank during high-throughput Claude output
|
||||||
|
|
||||||
|
### HTTPS & Browser Notifications
|
||||||
|
|
||||||
|
**HTTPS**: The `--https` flag generates/reuses self-signed certificates in `~/.claudeman/certs/` (`server.key`, `server.crt`). Required for the Web Notification API in browsers.
|
||||||
|
|
||||||
|
**Notification Layers** (in `app.js`, `NotificationManager` class):
|
||||||
|
1. In-app drawer with notification list
|
||||||
|
2. Tab title flashing with unread count (when tab unfocused)
|
||||||
|
3. Web Notification API (browser push notifications)
|
||||||
|
4. Audio alerts (critical level only)
|
||||||
|
|
||||||
|
Notifications triggered for: session events, respawn updates, spawn agent lifecycle. Preferences persist to server-side `state.json` per session.
|
||||||
|
|
||||||
### State Store
|
### State Store
|
||||||
|
|
||||||
Writes debounced (500ms) to `~/.claudeman/state.json`. The web server persists full session state via `persistSessionState()` on every meaningful change:
|
Writes debounced (500ms) to `~/.claudeman/state.json`. The web server persists full session state via `persistSessionState()` on every meaningful change:
|
||||||
@@ -538,6 +575,7 @@ TUI uses React JSX (`jsxImportSource: react`) for Ink components.
|
|||||||
- **SSE event**: Emit via `broadcast()` in server.ts, handle in `app.js:handleSSEEvent()` switch
|
- **SSE event**: Emit via `broadcast()` in server.ts, handle in `app.js:handleSSEEvent()` switch
|
||||||
- **Session event**: Add to `SessionEvents` interface in `session.ts`, emit via `this.emit()`, subscribe in server.ts, handle in frontend
|
- **Session event**: Add to `SessionEvents` interface in `session.ts`, emit via `this.emit()`, subscribe in server.ts, handle in frontend
|
||||||
- **Session setting**: Add field to `SessionState` in `types.ts`, include in `session.toState()`, call `this.persistSessionState(session)` in server.ts after the change
|
- **Session setting**: Add field to `SessionState` in `types.ts`, include in `session.toState()`, call `this.persistSessionState(session)` in server.ts after the change
|
||||||
|
- **MCP tool**: Add tool definition in `mcp-server.ts` using `server.tool()`, use `apiRequest()` to call Claudeman REST API
|
||||||
- **New test file**: Create `test/<name>.test.ts`, pick unique port (next available: 3127+), add to port allocation comment above
|
- **New test file**: Create `test/<name>.test.ts`, pick unique port (next available: 3127+), add to port allocation comment above
|
||||||
|
|
||||||
### API Error Codes
|
### API Error Codes
|
||||||
@@ -573,6 +611,7 @@ App.tsx
|
|||||||
│ ├── List navigation (↑/↓, Enter, a/d/D)
|
│ ├── List navigation (↑/↓, Enter, a/d/D)
|
||||||
│ ├── Case creation flow
|
│ ├── Case creation flow
|
||||||
│ └── Tab switcher menu
|
│ └── Tab switcher menu
|
||||||
|
├── DirectAttach.ts # Full-screen console attach with tab switching
|
||||||
├── TabBar.tsx # Session tabs (when attached)
|
├── TabBar.tsx # Session tabs (when attached)
|
||||||
├── TerminalView.tsx # Viewport into screen session
|
├── TerminalView.tsx # Viewport into screen session
|
||||||
├── StatusBar.tsx # Bottom bar with status/tokens
|
├── StatusBar.tsx # Bottom bar with status/tokens
|
||||||
@@ -639,6 +678,7 @@ Long-running sessions are supported with automatic trimming:
|
|||||||
| GET | `/api/spawn/status` | Orchestrator status (counts, config) |
|
| GET | `/api/spawn/status` | Orchestrator status (counts, config) |
|
||||||
| PUT | `/api/spawn/config` | Update orchestrator config |
|
| PUT | `/api/spawn/config` | Update orchestrator config |
|
||||||
| POST | `/api/spawn/trigger` | Programmatic spawn (bypass terminal detection) |
|
| POST | `/api/spawn/trigger` | Programmatic spawn (bypass terminal detection) |
|
||||||
|
| POST | `/api/hook-event` | Receive Claude Code hook callbacks (idle_prompt, permission_prompt, stop) |
|
||||||
|
|
||||||
## Keyboard Shortcuts (Web UI)
|
## Keyboard Shortcuts (Web UI)
|
||||||
|
|
||||||
@@ -699,6 +739,7 @@ Long-running sessions are supported with automatic trimming:
|
|||||||
| `~/.claudeman/state-inner.json` | Ralph loop/todo state per session (separate to reduce writes) |
|
| `~/.claudeman/state-inner.json` | Ralph loop/todo state per session (separate to reduce writes) |
|
||||||
| `~/.claudeman/screens.json` | Screen session metadata (for recovery after restart) |
|
| `~/.claudeman/screens.json` | Screen session metadata (for recovery after restart) |
|
||||||
| `~/.claudeman/settings.json` | User preferences (lastUsedCase, custom template path) |
|
| `~/.claudeman/settings.json` | User preferences (lastUsedCase, custom template path) |
|
||||||
|
| `~/.claudeman/certs/` | Self-signed TLS certificates for `--https` mode |
|
||||||
|
|
||||||
**State lifecycle**:
|
**State lifecycle**:
|
||||||
- Web server creates → session added to `state.json` + `screens.json`
|
- Web server creates → session added to `state.json` + `screens.json`
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Claude Code hooks configuration generator
|
||||||
|
*
|
||||||
|
* Generates .claude/settings.local.json with hook definitions that POST
|
||||||
|
* to Claudeman's /api/hook-event endpoint when Claude Code fires
|
||||||
|
* notification or stop hooks. Uses $CLAUDEMAN_API_URL and
|
||||||
|
* $CLAUDEMAN_SESSION_ID env vars (set on every managed session) so the
|
||||||
|
* config is static per case directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import type { HookEventType } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the hooks section for .claude/settings.local.json
|
||||||
|
*
|
||||||
|
* The curl commands reference env vars that are resolved at runtime by
|
||||||
|
* the shell, so the same config works for any session in the case dir.
|
||||||
|
*/
|
||||||
|
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||||
|
const curlCmd = (event: HookEventType) =>
|
||||||
|
`curl -s -X POST $CLAUDEMAN_API_URL/api/hook-event ` +
|
||||||
|
`-H 'Content-Type: application/json' ` +
|
||||||
|
`-d '{"event":"${event}","sessionId":"'$CLAUDEMAN_SESSION_ID'"}' 2>/dev/null || true`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hooks: {
|
||||||
|
Notification: [
|
||||||
|
{
|
||||||
|
matcher: 'idle_prompt',
|
||||||
|
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: 10000 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matcher: 'permission_prompt',
|
||||||
|
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: 10000 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Stop: [
|
||||||
|
{
|
||||||
|
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: 10000 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes hooks config to .claude/settings.local.json in the given case path.
|
||||||
|
* Merges with existing file content, only touching the `hooks` key.
|
||||||
|
*/
|
||||||
|
export function writeHooksConfig(casePath: string): void {
|
||||||
|
const claudeDir = join(casePath, '.claude');
|
||||||
|
if (!existsSync(claudeDir)) {
|
||||||
|
mkdirSync(claudeDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||||
|
let existing: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (existsSync(settingsPath)) {
|
||||||
|
try {
|
||||||
|
existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
// If file is malformed, start fresh
|
||||||
|
existing = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooksConfig = generateHooksConfig();
|
||||||
|
const merged = { ...existing, ...hooksConfig };
|
||||||
|
|
||||||
|
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||||
|
}
|
||||||
@@ -369,6 +369,21 @@ export interface QuickRunRequest {
|
|||||||
workingDir?: string;
|
workingDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook event types triggered by Claude Code's hooks system
|
||||||
|
*/
|
||||||
|
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'stop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for the hook-event API endpoint
|
||||||
|
*/
|
||||||
|
export interface HookEventRequest {
|
||||||
|
/** Type of hook event that fired */
|
||||||
|
event: HookEventType;
|
||||||
|
/** Session ID from CLAUDEMAN_SESSION_ID env var */
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== API Response Types ==========
|
// ========== API Response Types ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1092,6 +1092,46 @@ class ClaudemanApp {
|
|||||||
message: `Agent "${data.agentId}" finished successfully`,
|
message: `Agent "${data.agentId}" finished successfully`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hook events (from Claude Code hooks system)
|
||||||
|
this.eventSource.addEventListener('hook:idle_prompt', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const session = this.sessions.get(data.sessionId);
|
||||||
|
this.notificationManager?.notify({
|
||||||
|
urgency: 'warning',
|
||||||
|
category: 'hook-idle',
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
sessionName: session?.name || data.sessionId,
|
||||||
|
title: 'Waiting for Input',
|
||||||
|
message: 'Claude is idle and waiting for a prompt',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('hook:permission_prompt', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const session = this.sessions.get(data.sessionId);
|
||||||
|
this.notificationManager?.notify({
|
||||||
|
urgency: 'critical',
|
||||||
|
category: 'hook-permission',
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
sessionName: session?.name || data.sessionId,
|
||||||
|
title: 'Permission Required',
|
||||||
|
message: 'Claude needs tool approval to continue',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('hook:stop', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const session = this.sessions.get(data.sessionId);
|
||||||
|
this.notificationManager?.notify({
|
||||||
|
urgency: 'info',
|
||||||
|
category: 'hook-stop',
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
sessionName: session?.name || data.sessionId,
|
||||||
|
title: 'Response Complete',
|
||||||
|
message: 'Claude has finished responding',
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnectionStatus(status) {
|
setConnectionStatus(status) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ScreenManager } from '../screen-manager.js';
|
|||||||
import { getStore } from '../state-store.js';
|
import { getStore } from '../state-store.js';
|
||||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||||
|
import { writeHooksConfig } from '../hooks-config.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
type QuickStartRequest,
|
type QuickStartRequest,
|
||||||
type CreateScheduledRunRequest,
|
type CreateScheduledRunRequest,
|
||||||
type QuickRunRequest,
|
type QuickRunRequest,
|
||||||
|
type HookEventRequest,
|
||||||
type ApiResponse,
|
type ApiResponse,
|
||||||
type SessionResponse,
|
type SessionResponse,
|
||||||
type QuickStartResponse,
|
type QuickStartResponse,
|
||||||
@@ -1081,6 +1083,9 @@ export class WebServer extends EventEmitter {
|
|||||||
// Write .mcp.json for Claude Code to discover spawn tools
|
// Write .mcp.json for Claude Code to discover spawn tools
|
||||||
this.writeMcpConfig(casePath);
|
this.writeMcpConfig(casePath);
|
||||||
|
|
||||||
|
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||||
|
writeHooksConfig(casePath);
|
||||||
|
|
||||||
this.broadcast('case:created', { name, path: casePath });
|
this.broadcast('case:created', { name, path: casePath });
|
||||||
|
|
||||||
return { success: true, case: { name, path: casePath } };
|
return { success: true, case: { name, path: casePath } };
|
||||||
@@ -1221,6 +1226,9 @@ export class WebServer extends EventEmitter {
|
|||||||
// Write .mcp.json for Claude Code to discover spawn tools
|
// Write .mcp.json for Claude Code to discover spawn tools
|
||||||
this.writeMcpConfig(casePath);
|
this.writeMcpConfig(casePath);
|
||||||
|
|
||||||
|
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||||
|
writeHooksConfig(casePath);
|
||||||
|
|
||||||
this.broadcast('case:created', { name: caseName, path: casePath });
|
this.broadcast('case:created', { name: caseName, path: casePath });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: `Failed to create case: ${getErrorMessage(err)}` };
|
return { success: false, error: `Failed to create case: ${getErrorMessage(err)}` };
|
||||||
@@ -1447,6 +1455,21 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
return { success: true, data: { agentId } };
|
return { success: true, data: { agentId } };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Hook Events ==========
|
||||||
|
|
||||||
|
this.app.post('/api/hook-event', async (req) => {
|
||||||
|
const { event, sessionId } = req.body as HookEventRequest;
|
||||||
|
const validEvents = ['idle_prompt', 'permission_prompt', 'stop'] as const;
|
||||||
|
if (!event || !validEvents.includes(event as typeof validEvents[number])) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid event type');
|
||||||
|
}
|
||||||
|
if (!sessionId || !this.sessions.has(sessionId)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
|
}
|
||||||
|
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now() });
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists full session state including respawn config to state.json */
|
/** Persists full session state including respawn config to state.json */
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for hooks config generation
|
||||||
|
*
|
||||||
|
* Tests the generation of .claude/settings.local.json with Claude Code
|
||||||
|
* hook definitions for desktop notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { generateHooksConfig, writeHooksConfig } from '../src/hooks-config.js';
|
||||||
|
|
||||||
|
describe('generateHooksConfig', () => {
|
||||||
|
it('should return an object with hooks key', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
expect(config).toHaveProperty('hooks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Notification hooks array', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
expect(config.hooks.Notification).toBeInstanceOf(Array);
|
||||||
|
expect(config.hooks.Notification).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Stop hooks array', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
expect(config.hooks.Stop).toBeInstanceOf(Array);
|
||||||
|
expect(config.hooks.Stop).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure idle_prompt matcher', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
|
||||||
|
const idleHook = notifHooks.find(h => h.matcher === 'idle_prompt');
|
||||||
|
expect(idleHook).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure permission_prompt matcher', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
|
||||||
|
const permHook = notifHooks.find(h => h.matcher === 'permission_prompt');
|
||||||
|
expect(permHook).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use env vars in curl commands (not hardcoded URLs)', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>;
|
||||||
|
const cmd = notifHooks[0].hooks[0].command;
|
||||||
|
expect(cmd).toContain('$CLAUDEMAN_API_URL');
|
||||||
|
expect(cmd).toContain('$CLAUDEMAN_SESSION_ID');
|
||||||
|
expect(cmd).not.toContain('localhost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include || true for silent failure', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>;
|
||||||
|
expect(notifHooks[0].hooks[0].command).toContain('|| true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set timeout to 10000ms', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>;
|
||||||
|
expect(notifHooks[0].hooks[0].timeout).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include correct event names in curl payloads', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>;
|
||||||
|
expect(notifHooks[0].hooks[0].command).toContain('"idle_prompt"');
|
||||||
|
expect(notifHooks[1].hooks[0].command).toContain('"permission_prompt"');
|
||||||
|
const stopHooks = config.hooks.Stop as Array<{ hooks: Array<{ command: string }> }>;
|
||||||
|
expect(stopHooks[0].hooks[0].command).toContain('"stop"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set hook type to command', () => {
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ type: string }> }>;
|
||||||
|
expect(notifHooks[0].hooks[0].type).toBe('command');
|
||||||
|
const stopHooks = config.hooks.Stop as Array<{ hooks: Array<{ type: string }> }>;
|
||||||
|
expect(stopHooks[0].hooks[0].type).toBe('command');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeHooksConfig', () => {
|
||||||
|
const testDir = join(tmpdir(), 'claudeman-hooks-test-' + Date.now());
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!existsSync(testDir)) {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create .claude directory if it does not exist', () => {
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
expect(existsSync(join(testDir, '.claude'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create settings.local.json', () => {
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||||
|
expect(existsSync(settingsPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write valid JSON', () => {
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||||
|
const content = readFileSync(settingsPath, 'utf-8');
|
||||||
|
expect(() => JSON.parse(content)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include hooks config in output', () => {
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||||
|
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||||
|
expect(parsed.hooks).toBeDefined();
|
||||||
|
expect(parsed.hooks.Notification).toHaveLength(2);
|
||||||
|
expect(parsed.hooks.Stop).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge with existing settings.local.json', () => {
|
||||||
|
const claudeDir = join(testDir, '.claude');
|
||||||
|
mkdirSync(claudeDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(claudeDir, 'settings.local.json'),
|
||||||
|
JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
|
||||||
|
expect(parsed.existingKey).toBe('existingValue');
|
||||||
|
expect(parsed.permissions).toEqual({ allow: ['Read'] });
|
||||||
|
expect(parsed.hooks).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite existing hooks key', () => {
|
||||||
|
const claudeDir = join(testDir, '.claude');
|
||||||
|
mkdirSync(claudeDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(claudeDir, 'settings.local.json'),
|
||||||
|
JSON.stringify({ hooks: { oldHook: [] } }, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
|
||||||
|
expect(parsed.hooks.oldHook).toBeUndefined();
|
||||||
|
expect(parsed.hooks.Notification).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed existing settings.local.json', () => {
|
||||||
|
const claudeDir = join(testDir, '.claude');
|
||||||
|
mkdirSync(claudeDir, { recursive: true });
|
||||||
|
writeFileSync(join(claudeDir, 'settings.local.json'), 'not valid json{{{');
|
||||||
|
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
|
||||||
|
expect(parsed.hooks).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should end file with newline', () => {
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
const content = readFileSync(join(testDir, '.claude', 'settings.local.json'), 'utf-8');
|
||||||
|
expect(content.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user