# How to Write a minimal-agent Plugin

minimal-agent 的插件系统遵循 **drop-in** 原则：把目录扔进 `plugins/`，命令立即可用，**`src/` 一行不改**。

## 两种契约

### 1. 纯声明式（Anthropic 兼容，零 TS）

最小可用插件 = 一个 frontmatter 模板。框架替你拼 prompt 喂给 LLM。

```
plugins/hello-bot/
├── .claude-plugin/
│   └── plugin.json
└── commands/
    └── hi.md
```

**`.claude-plugin/plugin.json`**

```json
{
  "name": "hello-bot",
  "version": "0.1.0",
  "description": "Greets the user"
}
```

**`commands/hi.md`**

```markdown
---
description: "Greet the user"
argument-hint: "<name>"
---

你好 $ARGUMENTS！欢迎使用 minimal-agent。
```

**效果**：用户输入 `/hi 世界` → LLM 收到 `你好 世界！欢迎使用 minimal-agent。\n\n用户参数: 世界`。

支持的占位符：
- `$ARGUMENTS` / `${ARGUMENTS}` —— 用户在命令名后的全部文本
- `${CLAUDE_PLUGIN_ROOT}` —— 该插件目录的绝对路径（用于引用插件资源）

### 2. 声明式 + `plugin.ts`（富插件）

当模板不够（要跑循环 / 做 TS 校验 / 启动子进程 / 状态机），加一个 `plugin.ts`：

```
plugins/echo-bot/
├── .claude-plugin/plugin.json
├── commands/echo.md         # 仍要存在！frontmatter 用于 listAvailableCommands
└── plugin.ts                # PluginApi default export
```

**`plugin.ts`**

```typescript
import {
  runQuery,
  type PluginApi,
  type LoopEvent,
} from '../../src/plugin-sdk.ts';

const api: PluginApi = {
  async *runCommand(commandName, args, ctx): AsyncGenerator<LoopEvent> {
    if (commandName === 'echo') {
      // 直接 yield 自定义事件
      yield { type: 'text', delta: `你输入了：${args}` };
      yield { type: 'turn_done' };
      return;
    }

    // 或者改写 prompt 后丢给 LLM
    yield* runQuery(`请把以下内容重复 3 遍：${args}`, {
      provider: ctx.provider,
      history: ctx.history,
      signal: ctx.signal,
    });
  },
};

export default api;
```

**框架行为**：命中 `/echo abc` 时，`pluginRunner` 优先调用 `runCommand('echo', 'abc', ctx)`，不再走声明式 fallback。你完全接管事件流。

## `plugin-sdk.ts` 暴露的全部 API

**唯一稳定 import 路径**：`from '../../src/plugin-sdk.ts'`（相对你的 `plugin.ts`）。

| 名称 | 类型 | 用途 |
|------|------|------|
| `runQuery(input, opts)` | function | 标准 T-A-O-R 循环，AsyncGenerator |
| `chat(messages, provider, opts)` | function | 单次非流式 LLM 调用 |
| `executeTool(name, argsJson, ctx)` | function | 框架级工具调用入口 |
| `getToolByName(name)` | function | 取 Tool 定义 |
| `ALL_TOOLS` | const | 所有内置工具数组 |
| `getWorkingDir()` | function | 当前锁定的工作目录 |
| `getResourceSearchPaths(name, importMetaUrl)` | function | 双源资源路径（cwd + packageRoot） |
| `triggerHook(event, payload)` | function | 触发框架级 hook |
| `createSessionState()` | function | 新建 SessionState |
| `Message` / `Provider` / `LoopEvent` / `LlmStreamEvent` | type | 协议类型 |
| `Tool` / `ToolCall` / `ToolResult` | type | 工具协议类型 |
| `PluginApi` / `PluginContext` | type | 你的 plugin.ts 契约 |
| `ResourceName` / `HookEventName` / `HookDecision` / `SessionState` | type | 杂项 |

**不要**绕开 `plugin-sdk.ts` 直接 import `src/loop.ts` / `src/llm/client.ts` 等内部路径 —— 那些路径不是稳定 API，框架重构时会破坏你的插件。

## `PluginContext`

`runCommand` 收到的 ctx：

```typescript
interface PluginContext {
  provider: Provider;          // 当前 LLM provider 配置
  history: Message[];          // 对话历史（你可以读 / 修改 / 替换）
  signal?: AbortSignal;        // 用户 ESC / Ctrl+C 时触发
  sessionState?: SessionState; // 可选共享 session 状态
  maxTurns?: number;           // 工具循环最大轮数（runQuery 透传用）
}
```

## 事件 (`LoopEvent`)

你的 `runCommand` 必须 yield `LoopEvent`，UI 据此渲染。常用的有：

| type | 字段 | 用途 |
|------|------|------|
| `text` | `delta: string` | 流式文本片段 |
| `assistant_message` | (无) | 一次完整 assistant 消息已 push 进 history |
| `tool_start` | `toolName`, `argsPreview` | 工具开始执行 |
| `tool_end` | (无) | 工具结束 |
| `turn_done` | (无) | 整轮交互结束 |
| `interrupted` | (无) | 用户中断 |
| `error` | `error: string` | 红色错误提示 |
| `plugin_progress` | `pluginId`, `current`, `max?`, `message?` | 循环进度条（替代旧的 plugin_start / plugin_iteration） |

通常做法：复杂插件直接 `yield* runQuery(...)` 透传所有事件，自己额外 yield `plugin_progress` 做进度展示。

## 工作目录与持久化

- 状态写到 `getWorkingDir()/.minimal-agent-<your-plugin-id>/` 下，避免污染主 context 目录
- `/new` 命令会自动清这些 `.minimal-agent*` 目录，你不用管 cleanup
- 跨平台：Windows 没 bash，hooks/*.sh 静默跳过；其它 .ts 代码完全平台无关

## 命令默认值

框架**不再**注入命令默认参数（旧版 `COMMAND_DEFAULTS` 已删除）。如果你的命令需要默认值（如 `--max-iterations 50`），自己在 `plugin.ts` 里解析 args 时兜底。这样默认值的语义属于插件契约，不属于框架。

## 测试

- 把测试放在 `plugins/<id>/test/*.test.ts`
- `bunfig.toml` 的 `[test].root = ["./test", "./plugins"]` 让 `bun test` 自动发现
- 测试里 mock LLM / 工具时，用 `mock.module('../../../src/loop.ts', ...)` 等绝对到 src 的相对路径

## 参考实现

- **声明式 + plugin.ts 简单例**：`plugins/workflow-runner/plugin.ts`（~25 行接管 `/workflow` 与 `/workflows`）
- **重型循环例**：`plugins/ralph-wiggum/plugin.ts`（~270 行 do-while loop + FSM + sentinel + verification + stop-hook）

## Transcript 与 Hook：当前状态

框架提供了两套基础设施 —— 都**就绪、有测试、可独立调用**，但**主循环 `src/loop.ts` 不自动触发**：

| 模块 | 暴露 | 当前主循环触发？ |
|------|------|------|
| `src/plugins/transcript.ts` | `initTranscript / appendUserMessage / appendAssistantMessage / appendToolResult` | 否 |
| `src/plugins/hookEngine.ts` | `loadHooks / triggerHook('UserPromptSubmit' \| 'PreToolUse' \| 'PostToolUse' \| 'SessionStart' \| 'SessionEnd', payload)` | 否 |

**插件如何使用**：
- 想写自己的 JSONL transcript → 在 `runCommand` 内 `import { initTranscript, appendUserMessage } from '../../src/plugin-sdk.ts'` 自己调
- 想响应 hook 事件 → 在 `runCommand` 内 `triggerHook('PreToolUse', { tool_name, ... })` 自己调；ralph-wiggum 走的就是这条路（vendored stop-hook 执行器在 `plugins/ralph-wiggum/src/stopHookRunner.ts`）

**为什么不在主循环自动触发**：当前没有 Anthropic 风格 `hooks/*.sh` 的真实用例驱动；自动触发会改主循环热路径但不解决实际问题。等真有跨插件的全局 hook 需求时再接也不迟。届时只需在 `src/loop.ts` 的 user/assistant/tool 三处 push 点加 `appendXxx` 与 `triggerHook` 调用即可，**不影响插件契约**。

## 不支持的 Anthropic 字段

minimal-agent 的插件契约是 Anthropic 上游的**子集**。当前未实现的字段：

- `mcp-servers/` —— MCP 服务器配置
- `agents/` —— 子 agent 定义
- `skills/` 在插件目录下 —— minimal-agent 的 skill 系统是独立顶层目录

未实现字段在 plugin.json 中存在不会报错，但被忽略。
