feat: implement hooks, plugins, session forks, and vim motions

Add 4 major features to codetyper-cli:

- Hooks System: Lifecycle hooks (PreToolUse, PostToolUse, SessionStart,
  SessionEnd, UserPromptSubmit, Stop) with exit code control flow
- Plugin System: Custom tools, commands, and hooks via plugin manifest
- Session Forking: Snapshots, rewind, fork, and switch between branches
- Vim Motions: Normal/Insert/Command/Visual modes with keyboard navigation

New files:
- src/types/{hooks,plugin,session-fork,vim}.ts
- src/constants/{hooks,plugin,session-fork,vim}.ts
- src/services/{hooks-service,plugin-loader,plugin-service,session-fork-service}.ts
- src/stores/vim-store.ts (vanilla)
- src/tui/hooks/{useVimMode,useVimStore,useTodoStore,useThemeStore}.ts
- src/tui/components/VimStatusLine.tsx

Modified:
- src/services/agent.ts (hook integration)
- src/tools/index.ts (plugin tool registration)
- src/stores/{todo-store,theme-store}.ts (converted to vanilla)
- TUI components (updated hook imports)
This commit is contained in:
2026-01-31 22:22:04 -05:00
parent 37d4a43154
commit a3c407d89a
56 changed files with 7507 additions and 90 deletions

View File

@@ -23,6 +23,11 @@ import { chat as providerChat } from "@providers/index";
import { getTool, getToolsForApi, refreshMCPTools } from "@tools/index";
import type { ToolContext, ToolCall, ToolResult } from "@/types/tools";
import { initializePermissions } from "@services/permissions";
import {
loadHooks,
executePreToolUseHooks,
executePostToolUseHooks,
} from "@services/hooks-service";
import { MAX_ITERATIONS } from "@constants/agent";
import { usageStore } from "@stores/usage-store";
@@ -130,12 +135,40 @@ const callLLM = async (
};
/**
* Execute a tool call
* Execute a tool call with hook support
*/
const executeTool = async (
state: AgentState,
toolCall: ToolCall,
): Promise<ToolResult> => {
// Execute PreToolUse hooks
const hookResult = await executePreToolUseHooks(
state.sessionId,
toolCall.name,
toolCall.arguments,
state.workingDir,
);
// Handle hook results
if (hookResult.action === "block") {
return {
success: false,
title: "Blocked by hook",
output: "",
error: hookResult.message,
};
}
if (hookResult.action === "warn") {
state.options.onWarning?.(hookResult.message);
}
// Apply modified arguments if hook returned them
const effectiveArgs =
hookResult.action === "modify"
? { ...toolCall.arguments, ...hookResult.updatedInput }
: toolCall.arguments;
const tool = getTool(toolCall.name);
if (!tool) {
@@ -160,19 +193,34 @@ const executeTool = async (
},
};
let result: ToolResult;
try {
// Validate arguments
const validatedArgs = tool.parameters.parse(toolCall.arguments);
return await tool.execute(validatedArgs, ctx);
const validatedArgs = tool.parameters.parse(effectiveArgs);
result = await tool.execute(validatedArgs, ctx);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
result = {
success: false,
title: "Tool error",
output: "",
error: errorMessage,
};
}
// Execute PostToolUse hooks (fire-and-forget, don't block on result)
executePostToolUseHooks(
state.sessionId,
toolCall.name,
effectiveArgs,
result,
state.workingDir,
).catch(() => {
// Silently ignore post-hook errors
});
return result;
};
/**
@@ -190,6 +238,9 @@ export const runAgentLoop = async (
// Initialize permissions
await initializePermissions();
// Load hooks
await loadHooks(state.workingDir);
// Refresh MCP tools if available
await refreshMCPTools();