co-mono/packages/coding-agent/docs/hooks-v2.md

8.2 KiB

Hooks v2: Commands + Context Control

Extends hooks with slash commands and context manipulation primitives.

Goals

  1. Hooks can register slash commands (/pop, /pr, /test)
  2. Hooks can save custom session entries
  3. Hooks can transform context before it goes to LLM
  4. All handlers get unified baseline access to state

Benchmark: /pop (session stacking) implementable entirely as a hook.

API Extensions

Commands

pi.command("pop", {
  description: "Pop to previous turn",
  handler: async (ctx) => {
    // ctx has full access (see Unified Context below)
    const selected = await ctx.ui.select("Pop to:", options);
    // ...
    return { status: "Done" };       // show status
    return "prompt text";            // send to agent
    return;                          // do nothing
  }
});

Custom Entries

// Save arbitrary entry to session
await ctx.saveEntry({
  type: "stack_pop",  // custom type, ignored by core
  backToIndex: 5,
  summary: "...",
  timestamp: Date.now()
});

Context Transform

// Fires when building context for LLM
pi.on("context", (event, ctx) => {
  // event.entries: all session entries (including custom types)
  // event.messages: core-computed messages (after compaction)
  
  // Return modified messages, or undefined to keep default
  return { messages: transformed };
});

Multiple context handlers chain: each receives previous handler's output.

Rebuild Trigger

// Force context rebuild (after saving entries)
await ctx.rebuildContext();

Unified Context

All handlers receive:

interface HookEventContext {
  // Existing
  exec(cmd: string, args: string[], opts?): Promise<ExecResult>;
  ui: { select, confirm, input, notify };
  hasUI: boolean;
  cwd: string;
  sessionFile: string | null;
  
  // New: State (read-only)
  model: Model<any> | null;
  thinkingLevel: ThinkingLevel;
  entries: readonly SessionEntry[];
  messages: readonly AppMessage[];
  
  // New: Utilities
  findModel(provider: string, id: string): Model<any> | null;
  availableModels(): Promise<Model<any>[]>;
  resolveApiKey(model: Model<any>): Promise<string | undefined>;
  
  // New: Mutation (commands only? or all?)
  saveEntry(entry: { type: string; [k: string]: unknown }): Promise<void>;
  rebuildContext(): Promise<void>;
}

Commands additionally get:

  • args: string[], argsRaw: string
  • setModel(), setThinkingLevel() (state mutation)

Benchmark: Stacking as Hook

export default function(pi: HookAPI) {
  // Command: /pop
  pi.command("pop", {
    description: "Pop to previous turn, summarizing substack",
    handler: async (ctx) => {
      // 1. Build turn list from entries
      const turns = ctx.entries
        .map((e, i) => ({ e, i }))
        .filter(({ e }) => e.type === "message" && e.message.role === "user")
        .map(({ e, i }) => ({ index: i, text: e.message.content.slice(0, 50) }));
      
      if (!turns.length) return { status: "No turns to pop" };
      
      // 2. User selects
      const selected = await ctx.ui.select("Pop to:", turns.map(t => t.text));
      if (!selected) return;
      const backTo = turns.find(t => t.text === selected)!.index;
      
      // 3. Summarize entries from backTo to now
      const toSummarize = ctx.entries.slice(backTo)
        .filter(e => e.type === "message")
        .map(e => e.message);
      const summary = await generateSummary(toSummarize, ctx);
      
      // 4. Save custom entry
      await ctx.saveEntry({
        type: "stack_pop",
        backToIndex: backTo,
        summary,
        timestamp: Date.now()
      });
      
      // 5. Rebuild
      await ctx.rebuildContext();
      return { status: "Popped stack" };
    }
  });
  
  // Context transform: apply stack pops
  pi.on("context", (event, ctx) => {
    const pops = event.entries.filter(e => e.type === "stack_pop");
    if (!pops.length) return; // use default
    
    // Build exclusion set
    const excluded = new Set<number>();
    const summaryAt = new Map<number, string>();
    
    for (const pop of pops) {
      const popIdx = event.entries.indexOf(pop);
      for (let i = pop.backToIndex; i <= popIdx; i++) excluded.add(i);
      summaryAt.set(pop.backToIndex, pop.summary);
    }
    
    // Build filtered messages
    const messages: AppMessage[] = [];
    for (let i = 0; i < event.entries.length; i++) {
      if (excluded.has(i)) continue;
      
      if (summaryAt.has(i)) {
        messages.push({
          role: "user",
          content: `[Subtask completed]\n\n${summaryAt.get(i)}`,
          timestamp: Date.now()
        });
      }
      
      const e = event.entries[i];
      if (e.type === "message") messages.push(e.message);
    }
    
    return { messages };
  });
}

async function generateSummary(messages, ctx) {
  const apiKey = await ctx.resolveApiKey(ctx.model);
  // Call LLM for summary...
}

Core Changes Required

session-manager.ts

// Allow saving arbitrary entries
saveEntry(entry: { type: string; [k: string]: unknown }): void {
  if (!entry.type) throw new Error("Entry must have type");
  this.inMemoryEntries.push(entry);
  this._persist(entry);
}

// buildSessionContext ignores unknown types (existing behavior works)

hooks/types.ts

// New event
interface ContextEvent {
  type: "context";
  entries: readonly SessionEntry[];
  messages: AppMessage[];
}

// Extended base context (see Unified Context above)

// Command types
interface CommandOptions {
  description?: string;
  handler: (ctx: CommandContext) => Promise<CommandResult | void>;
}

type CommandResult = 
  | string 
  | { prompt: string; attachments?: Attachment[] }
  | { status: string };

hooks/loader.ts

// Track registered commands
interface LoadedHook {
  path: string;
  handlers: Map<string, Handler[]>;
  commands: Map<string, CommandOptions>;  // NEW
}

// createHookAPI adds command() method

hooks/runner.ts

class HookRunner {
  // State callbacks (set by AgentSession)
  setStateCallbacks(cb: StateCallbacks): void;
  
  // Command invocation
  getCommands(): Map<string, CommandOptions>;
  invokeCommand(name: string, argsRaw: string): Promise<CommandResult | void>;
  
  // Context event with chaining
  async emitContext(entries, messages): Promise<AppMessage[]> {
    let result = messages;
    for (const hook of this.hooks) {
      const handlers = hook.handlers.get("context");
      for (const h of handlers ?? []) {
        const out = await h({ entries, messages: result }, this.createContext());
        if (out?.messages) result = out.messages;
      }
    }
    return result;
  }
}

agent-session.ts

// Expose saveEntry
async saveEntry(entry): Promise<void> {
  this.sessionManager.saveEntry(entry);
}

// Rebuild context
async rebuildContext(): Promise<void> {
  const base = this.sessionManager.buildSessionContext();
  const entries = this.sessionManager.getEntries();
  const messages = await this._hookRunner.emitContext(entries, base.messages);
  this.agent.replaceMessages(messages);
}

// Fire context event during normal context building too

interactive-mode.ts

// In setupEditorSubmitHandler, check hook commands
const commands = this.session.hookRunner?.getCommands();
if (commands?.has(commandName)) {
  const result = await this.session.invokeCommand(commandName, argsRaw);
  // Handle result...
  return;
}

// Add hook commands to autocomplete

Open Questions

  1. Mutation in all handlers or commands only?

    • saveEntry/rebuildContext in all handlers = more power, more footguns
    • Commands only = safer, but limits hook creativity
    • Recommendation: start with commands only
  2. Context event timing

    • Fire on every prompt? Or only when explicitly rebuilt?
    • Need to fire on session load too
    • Recommendation: fire whenever agent.replaceMessages is called
  3. Compaction interaction

    • Core compaction runs first, then context event
    • Hooks can post-process compacted output
    • Future: compaction itself could become a replaceable hook
  4. Multiple context handlers

    • Chain in load order (global → project)
    • Each sees previous output
    • No explicit priority system (KISS)