Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions docs/ai/design/2026-06-29-feature-claude-prompt-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
---
phase: design
title: System Design & Architecture
description: Define the technical architecture, components, and data models
---

# System Design & Architecture

## Verified Hook Assumptions

| Assumption | Status | Evidence |
|---|---|---|
| A1: `Notification` hook exists | ❌ FALSE | Real Claude plugin hooks.json files show only: `PreToolUse`, `PostToolUse`, `Stop`, `UserPromptSubmit`, `SessionStart` |
| A2: payload includes `session_id` | ✅ CONFIRMED (PreToolUse) | `security_reminder_hook.py:529`: `session_id = input_data.get("session_id")` |
| A3: settings.json hooks schema | ✅ CONFIRMED | Multiple plugin hooks.json files confirm `{ hooks: { Event: [{ matcher?, hooks: [{type,command,timeout}] }] } }` |

**Chosen event**: `PreToolUse` with `matcher: "Bash|Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion"`.

### Interactive prompt types and how they surface

| Prompt type | How it surfaces | Captured? |
|---|---|---|
| Tool approval prompt (Bash, Edit, Write, MultiEdit, NotebookEdit) | `PreToolUse` hook fires before tool execution | Yes — hook writes to agent-request store |
| Question / single-select / multi-select prompt (`AskUserQuestion` tool) | `PreToolUse` hook fires with `tool_name: "AskUserQuestion"` and `tool_input.questions` array | Yes — included in hook matcher; forwarded as `[Question] <raw JSON>` (richer formatting is a future PR) |
| Claude Code TUI selection dialog (generated by Claude Code UI layer, not the model) | No hook event fired | No — not hookable; limitation |

## Architecture Overview

```mermaid
graph TD
CC[Claude Code process] -->|fires PreToolUse for Bash/Edit/Write/MultiEdit/NotebookEdit/AskUserQuestion| HK[claude-prompt-hook.js\n~/.claude/hooks/]
HK -->|overwrites| AR[Agent-Request Store\n~/.ai-devkit/agent-requests/session-id.json]

subgraph channel-runner [channel-runner.ts — startOutputPolling tick]
JSONL[1. poll JSONL via getConversation] -->|new assistant/system msgs| TG[Telegram sendMessage]
ASTORE[2. read agent-request store file]
ASTORE -->|timestamp changed: send| TG
ASTORE -->|timestamp unchanged: skip| NOOP[no-op]
end

AR -->|read| ASTORE
CC2[Claude Code JSONL] -->|read| JSONL

subgraph setup [ai-devkit setup --agent claude]
SS[setup.service.ts] -->|copy script| HK
SS -->|merge hook entry| SETTINGS[~/.claude/settings.json]
end
```

**Per-tick flow:**
1. If `sessionFilePath` known: read JSONL, forward new assistant/system messages to Telegram.
2. If `sessionId` known: read `~/.ai-devkit/agent-requests/<sessionId>.json`. If `entry.timestamp` differs from `lastAgentRequestTimestamp`, send the formatted message and update the cursor.

**Deduplication**: The agent-request file is overwritten on every hook invocation. The channel runner tracks `lastAgentRequestTimestamp` and only forwards entries whose `timestamp` field has changed since the previous tick.

## Data Models

### Agent-Request Store Entry (`~/.ai-devkit/agent-requests/<session-id>.json`)
```ts
// @ai-devkit/agent-manager — AgentRequest
interface AgentRequest {
sessionId: string;
toolName: string; // e.g. "Bash", "AskUserQuestion"
toolInput: Record<string, unknown>; // e.g. { command: "ls /tmp" } for Bash; { questions: [{question, header, options: [{label, description}], multiSelect}] } for AskUserQuestion
timestamp: string; // ISO 8601 — used as the dedup key
}
```
One flat file per session; the hook script overwrites it on each invocation. The `timestamp` field distinguishes distinct tool calls.

### Claude Code `PreToolUse` Hook Payload (stdin JSON)
```ts
interface PreToolUsePayload {
session_id: string; // UUID, confirmed present
tool_name: string; // e.g. "Bash", "AskUserQuestion"
tool_input: Record<string, unknown>; // e.g. { command: "ls /tmp" }
}
```

### `~/.claude/settings.json` after setup
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion",
"hooks": [{ "type": "command", "command": "node ~/.claude/hooks/claude-prompt-hook.js", "timeout": 10 }]
}
]
}
}
```

## Component Breakdown

### 1. `hooks/claude/claude-prompt-hook.js` (also copied to `packages/cli/assets/claude/`)
CJS script, dependency-free:
- Reads `process.stdin`; skips if TTY.
- Parses JSON; extracts `session_id`, `tool_name`, `tool_input`.
- Sanitizes `session_id` (alphanumeric + hyphens only).
- Creates `~/.ai-devkit/agent-requests/` directory.
- Overwrites `~/.ai-devkit/agent-requests/<session-id>.json` with the current entry.
- **Always exits 0** — never disrupts Claude Code.

### 2. `packages/cli/assets/claude/settings-hook.json`
Settings fragment for the `PreToolUse` hook (merged during setup):
```json
{ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion", "hooks": [{ "type": "command", "command": "node ~/.claude/hooks/claude-prompt-hook.js", "timeout": 10 }] }
```

### 3. `setup.service.ts` — `claude` agent definition
```
agent: 'claude', dotFolder: '.claude'
steps:
- claude-prompt-hook // copy script + merge PreToolUse entry (idempotent)
- built-in-skills
```

### 4. `packages/agent-manager/src/utils/agent-requests.ts`
Agent/session infrastructure — lives in `agent-manager`, not the CLI:
```ts
interface AgentRequest { sessionId, toolName, toolInput, timestamp }

function getAgentRequestPath(homeDir: string, sessionId: string): string
// returns path.join(homeDir, '.ai-devkit', 'agent-requests', `${sessionId}.json`)

function readLatestAgentRequest(homeDir: string, sessionId: string): AgentRequest | null
// reads the single flat file; returns null if absent or malformed

function writeAgentRequest(homeDir: string, entry: AgentRequest): void
// creates directory if needed; overwrites the file
```
All three are exported from `@ai-devkit/agent-manager`.

### 5. `channel-runner.ts` — `startOutputPolling()` extension

New state:
- `lastAgentRequestTimestamp: string | undefined`
- `home = options.homeDir ?? homedir()`

Per tick, after JSONL polling:
```
if agent.sessionId:
agentRequest = readLatestAgentRequest(home, agent.sessionId)
if agentRequest && agentRequest.timestamp !== lastAgentRequestTimestamp:
send formatPromptMessage(agentRequest.toolName, agentRequest.toolInput)
lastAgentRequestTimestamp = agentRequest.timestamp
```

`formatPromptMessage` output:
- `AskUserQuestion` with direct `question` field: `[Question] <question text>`
- `AskUserQuestion` with `questions` array (actual Claude Code payload): `[Question] <raw JSON>`
- Other tools: `[Tool prompt] <toolName>:\n<command or JSON>`

## Design Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Hook event | `PreToolUse` (Bash, Edit, Write, MultiEdit, NotebookEdit, AskUserQuestion) | Covers approval prompts for shell/file ops and question/selection prompts; read-only ops excluded to avoid noise |
| Agent-request store location | `packages/agent-manager` | Agent/session infrastructure belongs with the agent layer, not Telegram-specific CLI code |
| Store layout | Single flat file per session (`<sessionId>.json`) | Simpler than per-session directory; overwrite semantics natural for "latest invocation" |
| Deduplication | `timestamp` field comparison | Reliable; distinct hook invocations always produce distinct ISO timestamps |
| No PID guard | Removed | Claude-specific; makes the polling logic agent-agnostic and simpler to extend |
| Send ordering | JSONL first, agent-request store second | Avoids interleaving; assistant responses always precede the tool notification that caused them |
| `OutputPollingOptions.homeDir` | Injectable for tests | Keeps prod code clean; tests use temp dirs without touching real `~/.ai-devkit/` |

## Non-Functional Requirements

- **Latency**: Agent-request notification reaches Telegram within ≤ 4 s (hook write + one poll tick).
- **Reliability**: Hook always exits 0; errors silently swallowed.
- **Idempotency**: `ai-devkit setup --agent claude` re-run does not duplicate hook entry.
- **Security**: `session_id` sanitized before use as filename; writes only to `~/.ai-devkit/`.
- **No regression**: Existing JSONL polling path unchanged.
- **Agent-agnostic**: Polling logic uses no Claude-specific APIs (no PID files).
68 changes: 68 additions & 0 deletions docs/ai/implementation/2026-06-29-feature-claude-prompt-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
phase: implementation
title: Implementation Guide
description: Technical implementation notes, patterns, and code guidelines
---

# Implementation Guide

## Changed Files

| File | Change |
|---|---|
| `hooks/claude/claude-prompt-hook.js` | NEW — CJS hook; overwrites `~/.ai-devkit/agent-requests/<sessionId>.json` |
| `packages/cli/assets/claude/claude-prompt-hook.js` | NEW — identical copy (installer asset) |
| `packages/cli/assets/claude/settings-hook.json` | NEW — `PreToolUse` hook entry fragment (matcher: all tool types + AskUserQuestion) |
| `packages/agent-manager/src/utils/agent-requests.ts` | NEW — `getAgentRequestPath`, `readLatestAgentRequest`, `writeAgentRequest` |
| `packages/agent-manager/src/__tests__/utils/agent-requests.test.ts` | NEW — 6 unit tests |
| `packages/agent-manager/src/index.ts` | MODIFIED — exports `AgentRequest`, `getAgentRequestPath`, `readLatestAgentRequest`, `writeAgentRequest` |
| `packages/cli/src/services/setup/setup.service.ts` | MODIFIED — `claude` agent; `setupClaudePromptHook()` |
| `packages/cli/src/services/channel/channel-runner.ts` | MODIFIED — `startOutputPolling()` extended with agent-request polling |
| `packages/cli/src/__tests__/services/channel/channel-runner.test.ts` | NEW — agent-request polling tests + AskUserQuestion fixture tests |
| `packages/cli/src/__tests__/services/setup/setup.service.test.ts` | MODIFIED — claude-agent tests appended |

## Key Implementation Notes

### Hook Script — flat file overwrite
Writes to `~/.ai-devkit/agent-requests/<sessionId>.json`. Each invocation overwrites the previous entry. The `timestamp` field (ISO 8601) distinguishes distinct calls and is the dedup key.

### Agent-Request Store — `packages/agent-manager`
Owned by `agent-manager` (agent/session infrastructure), not the CLI. Three exports:
- `getAgentRequestPath(homeDir, sessionId)` — returns the flat file path
- `readLatestAgentRequest(homeDir, sessionId)` — reads the file; null on missing or malformed JSON
- `writeAgentRequest(homeDir, entry)` — creates dir if needed; overwrites file

The CLI imports these from `@ai-devkit/agent-manager`.

### `startOutputPolling()` — ordered sends, timestamp dedupe
1. JSONL block runs first (`if agent.sessionFilePath`).
2. Agent-request block runs second (`if agent.sessionId`).
3. `lastAgentRequestTimestamp` tracks the last forwarded entry; no send if timestamp unchanged.
4. Init seeds `lastAgentRequestTimestamp` from the pre-existing file so pre-existing entries are not replayed on connect.
5. No PID file / `waitingFor` guard — agent-agnostic by design.

### `formatPromptMessage` — AskUserQuestion stays raw
- `AskUserQuestion` with direct `question` string field: `[Question] <question text>`
- `AskUserQuestion` with `questions` array (actual Claude Code payload): `[Question] <raw JSON>` — richer formatting deferred to a future PR
- Other tools: `[Tool prompt] <toolName>:\n<command or JSON>`

### Settings matcher
`"Bash|Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion"` — covers all tool approval prompts and question/selection dialogs.

### Setup idempotency
`setupClaudePromptHook` checks whether any existing `hooks.PreToolUse[].hooks[].command` equals the hook command string before appending.

## Edge Cases

- `agent.sessionFilePath` undefined → JSONL block skipped; agent-request block still runs.
- Agent-request file absent → `readLatestAgentRequest` returns null; no send.
- Agent-request file malformed → returns null; no send; no crash.
- Same `timestamp` across ticks → `lastAgentRequestTimestamp` guard prevents duplicate sends.
- `agent.sessionId` absent → agent-request block skipped entirely.
- Non-Claude agents → no hook writes to `~/.ai-devkit/agent-requests/`; reads return null silently.

## Security

- `session_id` sanitized (`[a-zA-Z0-9\-]` only) before use as filename — prevents path traversal.
- All writes to `~/.ai-devkit/agent-requests/` (user's own home dir only).
- Hook always exits 0; errors swallowed — cannot disrupt Claude Code.
104 changes: 104 additions & 0 deletions docs/ai/planning/2026-06-29-feature-claude-prompt-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
phase: planning
title: Project Planning & Task Breakdown
description: Break down work into actionable tasks and estimate timeline
---

# Project Planning & Task Breakdown

## Milestones

- [x] M1: Hook script + assets — standalone Claude hook script, asset files in place
- [x] M2: Agent-request store — `agent-requests.ts` in `agent-manager`, unit-tested
- [x] M3: Setup service — `claude` agent added, idempotent settings.json merge
- [x] M4: Channel runner — agent-request store polling wired into `startOutputPolling`
- [x] M5: Tests green — all new and existing tests passing; no regressions

## Task Breakdown

### M1: Hook Script + Assets

- [x] **T1.1** — Create `hooks/claude/claude-prompt-hook.js`
- CJS, dependency-free; reads stdin JSON; extracts `session_id`, `tool_name`, `tool_input`
- Sanitizes `session_id` (strip non-`[a-zA-Z0-9\-]`); overwrites `~/.ai-devkit/agent-requests/<id>.json`
- Creates dir recursively; always exits 0
- Outcome: runnable standalone via `echo '{"session_id":"abc","tool_name":"Bash","tool_input":{"command":"ls"}}' | node hooks/claude/claude-prompt-hook.js`

- [x] **T1.2** — Copy hook script to `packages/cli/assets/claude/claude-prompt-hook.js`

- [x] **T1.3** — Create `packages/cli/assets/claude/settings-hook.json`
- `PreToolUse` hook entry; matcher: `"Bash|Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion"`

### M2: Agent-Request Store Module

- [x] **T2.1** — Create `packages/agent-manager/src/utils/agent-requests.ts`
- Export `AgentRequest` interface: `{ sessionId, toolName, toolInput, timestamp }`
- Export `getAgentRequestPath(homeDir, sessionId): string` → `~/.ai-devkit/agent-requests/<sessionId>.json`
- Export `readLatestAgentRequest(homeDir, sessionId): AgentRequest | null` — reads flat file; null on missing/malformed
- Export `writeAgentRequest(homeDir, entry): void` — creates dir; overwrites file
- All three exported from `@ai-devkit/agent-manager`

- [x] **T2.2** — Write unit tests for `agent-requests.ts`
- File: `packages/agent-manager/src/__tests__/utils/agent-requests.test.ts`
- Covers: path shape, write creates dir + file, overwrite replaces, read returns null for missing, null for malformed, correct entry for valid
- Outcome: `npx nx run agent-manager:test` passes

### M3: Setup Service — Claude Agent

- [x] **T3.1** — Add `claude` to `SUPPORTED_SETUP_AGENTS` in `setup.service.ts`

- [x] **T3.2** — Implement `setupClaudePromptHook(context, agent)` in `setup.service.ts`
- Copy `assets/claude/claude-prompt-hook.js` → `~/.claude/hooks/claude-prompt-hook.js`
- Idempotent merge of `settings-hook.json` entry into `~/.claude/settings.json`

- [x] **T3.3** — Add `claude` agent setup definition with steps `[claude-prompt-hook, built-in-skills]`

- [x] **T3.4** — Write/extend setup service tests for claude agent

### M4: Channel Runner — Agent-Request Store Polling

- [x] **T4.1** — Import `readLatestAgentRequest` from `@ai-devkit/agent-manager` in `channel-runner.ts`

- [x] **T4.2** — Extend `startOutputPolling()` with agent-request polling
- New state: `lastAgentRequestTimestamp: string | undefined`
- On each tick (after JSONL block): `readLatestAgentRequest(home, agent.sessionId)`
- If entry exists and `entry.timestamp !== lastAgentRequestTimestamp`: send `formatPromptMessage(toolName, toolInput)` and update cursor
- Init seeds `lastAgentRequestTimestamp` from pre-existing file (skip replay on connect)
- `formatPromptMessage`: `AskUserQuestion` → `[Question] <question or raw JSON>`; others → `[Tool prompt] ToolName:\n<command or JSON>`

- [x] **T4.3** — Write unit tests for agent-request store polling in `channel-runner.test.ts`

### M5: Validate & Tidy

- [x] **T5.1** — Full test suite passes (1,465 tests across 5 packages)
- [x] **T5.2** — TypeScript build clean across all packages
- [x] **T5.3** — All feature docs updated to match final implementation

## Dependencies & Sequencing

```
T1.1 → T1.2
T1.3 (independent)
T2.1 → T2.2
T3.1 → T3.2 → T3.3 → T3.4 (after T1 assets)
T4.1 → T4.2 → T4.3 (after T2)
T5.x after all above
```

## Risks & Mitigation

| Risk | Likelihood | Mitigation |
|---|---|---|
| Claude Code version changes `PreToolUse` payload shape | Low | Defensive key reads; `session_id` confirmed present |
| `~/.claude/settings.json` malformed on user's machine | Low | Try/catch; treat as `{}`; no destructive writes |
| Existing setup tests broken by adding claude agent | Very low | Existing tests use `agents: ['codex']` or `agents: ['pi']`; new tests use `agents: ['claude']` |

## Progress Summary

**All tasks complete.** Implementation shipped: M1 hook script + assets, M2 agent-request store in `agent-manager`, M3 claude setup agent, M4 channel-runner agent-request polling. 1,465 tests pass across 5 packages. TypeScript build clean.

**Key scope changes from initial design:**
- `Notification` hook event does not exist in Claude Code; pivoted to `PreToolUse` with matcher `Bash|Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion`.
- Agent-request store moved from CLI to `agent-manager` package (agent/session infrastructure).
- PID file / `waitingFor` guard removed (Claude-specific, complicates multi-agent support).
- `AskUserQuestion` rich formatting deferred; raw JSON forwarded for now.
Loading
Loading