# Phase 6: Per-Agent Hooks System — Implementation Plan

**Priority:** P5 · **Effort:** 2 days

---

## Step 1: Types

### 1.1. Modify `src/types.ts`

```typescript
/** A hook command registered against an agent lifecycle event. */
export interface HookCommand {
  /** Shell command to execute, or deny pattern for Stop hooks. */
  command: string;
  /** Hook type: command (run shell cmd), deny (block tool by pattern). */
  type: "command" | "deny";
  /** Tool name pattern for deny hooks (e.g. "edit" blocks all edit tools). */
  pattern?: string;
  /** Displayed reason for deny hooks. */
  reason?: string;
}

/** Agent frontmatter hooks configuration. */
export interface HooksSettings {
  SubagentStart?: HookCommand[];
  UserPromptSubmit?: HookCommand[];
  SubagentStop?: HookCommand[];
  PostSampling?: HookCommand[];
}
```

Add `hooks` to `AgentConfig`:
```typescript
hooks?: HooksSettings;
```

---

## Step 2: Create `src/agent-hooks.ts`

```typescript
import { execFileNoThrow } from "node:child_process";
import type { HooksSettings, HookCommand } from "./types.js";

/**
 * Per-agent hook registry. Hooks are scoped by agentId and
 * automatically cleaned up when the agent completes.
 */
export class AgentHookRegistry {
  private hooks = new Map<string, HooksSettings>();

  register(agentId: string, hooks: HooksSettings): void {
    this.hooks.set(agentId, hooks);
  }

  unregister(agentId: string): void {
    this.hooks.delete(agentId);
  }

  getHooks(agentId: string, type: keyof HooksSettings): HookCommand[] {
    const agentHooks = this.hooks.get(agentId);
    if (!agentHooks) return [];
    const hooks = agentHooks[type];
    return hooks ?? [];
  }

  /** Check if a stop hook denies the given tool name. */
  isToolDenied(agentId: string, toolName: string): { denied: boolean; reason?: string } {
    const stopHooks = this.getHooks(agentId, "SubagentStop");
    for (const hook of stopHooks) {
      if (hook.type !== "deny") continue;
      if (hook.pattern && toolName.includes(hook.pattern)) {
        return { denied: true, reason: hook.reason ?? `Tool "${toolName}" is denied by agent configuration` };
      }
    }
    return { denied: false };
  }

  /** Execute all command-type hooks of a given type. */
  async executeHooks(
    agentId: string,
    type: keyof HooksSettings,
    env?: Record<string, string>,
  ): Promise<HookExecutionResult[]> {
    const hooks = this.getHooks(agentId, type);
    const results: HookExecutionResult[] = [];

    for (const hook of hooks) {
      if (hook.type !== "command") continue;
      try {
        const { stdout, stderr } = await exec(hook.command, env);
        results.push({ success: true, command: hook.command, stdout, stderr });
      } catch (err) {
        results.push({ success: false, command: hook.command, error: String(err) });
      }
    }

    return results;
  }

  clearAll(): void {
    this.hooks.clear();
  }
}

export interface HookExecutionResult {
  success: boolean;
  command: string;
  stdout?: string;
  stderr?: string;
  error?: string;
}

/** Execute a shell command and return stdout/stderr. */
function exec(command: string, env?: Record<string, string>): Promise<{ stdout: string; stderr: string }> {
  return new Promise((resolve, reject) => {
    const child = execFileNoThrow("bash", ["-c", command], {
      env: { ...process.env, ...env },
      timeout: 30_000,
    });
    // Note: This is a simplified version. The actual implementation
    // should use pi's execFileNoThrow or similar utility.
    child.then(
      (result) => resolve({ stdout: result.stdout ?? "", stderr: result.stderr ?? "" }),
      (err) => reject(err),
    );
  });
}
```

---

## Step 3: Wire Into `src/agent-runner.ts`

### 3.1. At Agent Start

Call `SubagentStart` hooks and collect context:

```typescript
// After session created, before first LLM query
const hookRegistry = new AgentHookRegistry();
if (agentConfig.hooks) {
  hookRegistry.register(agentId, agentConfig.hooks);

  // Execute SubagentStart hooks
  const results = await hookRegistry.executeHooks(agentId, "SubagentStart", {
    AGENT_ID: agentId,
    AGENT_TYPE: agentType,
    CWD: cwd,
  });

  // Collect any stdout as additional context
  const contexts = results
    .filter(r => r.success && r.stdout?.trim())
    .map(r => `[Hook: ${r.command}]\n${r.stdout!.trim()}`);

  if (contexts.length > 0) {
    initialMessages.push(createUserMessage({
      content: [{ type: "text", text: contexts.join("\n\n") }],
      isMeta: true,
    }));
  }
}
```

### 3.2. Before Tool Execution

Check `SubagentStop` hooks:

```typescript
// In the tool execution loop:
const { denied, reason } = hookRegistry.isToolDenied(agentId, toolName);
if (denied) {
  // Return tool_result with error
  return { type: "tool_result", tool_use_id: toolId, content: [{ type: "text", text: `Error: ${reason}` }], is_error: true };
}
```

### 3.3. Hook Cleanup

In the `finally` block of agent execution:

```typescript
finally {
  hookRegistry.unregister(agentId);
}
```

---

## Step 4: Modify `src/custom-agents.ts`

Parse `hooks` from frontmatter YAML:

```typescript
import type { HooksSettings, HookCommand } from "./types.js";

function parseHooks(raw: unknown): HooksSettings | undefined {
  if (!raw || typeof raw !== "object") return undefined;

  const hooks: HooksSettings = {};
  for (const [hookType, commands] of Object.entries(raw as Record<string, unknown>)) {
    if (!["SubagentStart", "UserPromptSubmit", "SubagentStop", "PostSampling"].includes(hookType)) continue;

    if (Array.isArray(commands)) {
      const parsed = commands
        .map((cmd: unknown) => parseHookCommand(cmd))
        .filter((c): c is HookCommand => c !== undefined);
      if (parsed.length > 0) {
        hooks[hookType as keyof HooksSettings] = parsed;
      }
    }
  }

  return Object.keys(hooks).length > 0 ? hooks : undefined;
}

function parseHookCommand(raw: unknown): HookCommand | undefined {
  if (!raw || typeof raw !== "object") return undefined;
  const cmd = raw as Record<string, unknown>;

  if (typeof cmd.command !== "string" || cmd.command.length === 0) return undefined;
  const type = cmd.type === "deny" ? "deny" : "command";

  return {
    command: cmd.command,
    type,
    ...(cmd.pattern ? { pattern: String(cmd.pattern) } : {}),
    ...(cmd.reason ? { reason: String(cmd.reason) } : {}),
  };
}
```

---

## Step 5: Tests — `test/agent-hooks.test.ts`

| # | Test | Expected |
|---|---|---|
| 1 | Register and unregister | Hooks stored, then removed |
| 2 | Hook isolation | Two different agentIds don't share hooks |
| 3 | Empty hooks | `getHooks()` returns `[]` |
| 4 | Stop hook denies tool | `isToolDenied("edit")` returns `{ denied: true }` |
| 5 | Stop hook passes tool | `isToolDenied("read")` returns `{ denied: false }` |
| 6 | Frontmatter parsing | Valid YAML hooks parse to `HooksSettings` |
| 7 | Invalid hook syntax | Graceful handling, returns undefined |
| 8 | Clear all | `clearAll()` removes all hooks |

---

## File Change Summary

| Action | File |
|---|---|
| **MODIFY** | `src/types.ts` — add `HookCommand`, `HooksSettings`, `hooks` on `AgentConfig` |
| **CREATE** | `src/agent-hooks.ts` (~180 lines) |
| **MODIFY** | `src/agent-runner.ts` — call hooks at lifecycle points |
| **MODIFY** | `src/agent-manager.ts` — hook cleanup on completion |
| **MODIFY** | `src/custom-agents.ts` — parse hooks from frontmatter |
| **CREATE** | `test/agent-hooks.test.ts` (~120 lines) |
