Plugin Installation and Usage
Cerberus Plugin (Optional)
The optional plugins/hookers/cerberus_bash_control/ plugin wraps Bash commands
with an external cerberus executable. Cerberus core and CLI source code are
maintained outside this repository and are not built by the xiaoO workspace.
Install a compatible Cerberus CLI separately before enabling the plugin; see
the plugin README for setup.
Plugins
Pre-built hookers and skills are placed in <your_xiaoO>/plugins. They are not installed by default.
To install hookers, run:
cd <your_xiaoO>/plugins/hookers
./install.sh
You can also develop your own hookers and place them in <your_xiaoO>/plugins/hookers. See how-to-develop-a-plugin-hooker.md for details.
Skills
Built-in Skills
When you run cargo install --path apps/endside, builtin skills are automatically installed. They provide security policy enforcement and other built-in capabilities. They land in the system level directory, which is the lowest-priority skill source and can be shadowed by a same-named skill at project, config, or user level (see "Skill Directory Priority" below).
Installation locations (automatic fallback):
- System level (preferred):
/usr/lib/.xiaoo/skills/- requires root privileges - User level (fallback):
~/.xiaoo/skills/- used if system-level installation fails
Builtin skills (located in <xiaoO>/plugins/skills/):
xiaoo-guardian- Security policy enforcementblock-analyzer- Block analysis capabilitiessecurity-rules-tester- Systematic verification/regression testing of agent_moss security rules
Note:
cargo builddoes NOT install skills. Onlycargo installtriggers skill installation.Installation Behavior:
- First attempts to install all builtin skills to system-level directory (requires root privileges)
- If system-level installation fails (e.g., permission denied), automatically falls back to user-level directory
- Without these skills, security features and other capabilities may be unavailable.
For system-wide installation (recommended for multi-user environments):
- Run
cargo installwith root privileges:sudo cargo install --path apps/endside
Skill Directory Priority (Four Levels)
Directories are scanned in this order and skills are deduplicated by name, first match wins — so a higher level shadows a same-named skill at a lower level (crates/skill/src/loading/loader.rs; apps/endside/src/cli/skills.rs):
- Project level (highest):
./.xiaoo/skills/- Project-specific skills - Config level (medium): Directories specified in
[skills].dirs- Team/user shared skills - User level:
~/.xiaoo/skills/- Personal skills available everywhere - System level (lowest):
/usr/lib/.xiaoo/skills/- Built-in skills only
Because the system level is last, a user or project skill with the same name as a builtin skill (e.g. xiaoo-guardian) wins and the builtin is never loaded. This is the intended override mechanism — but it means a locally-installed skill can silently replace a builtin security skill, so treat custom skills named like builtins with care.
Disabling Skills
A skill can be excluded by name without deleting it, via [skills].disabled (crates/skill/src/types/config.rs; crates/skill/src/loading/loader.rs):
[skills]
disabled = ["block-analyzer"]
The name is the skill's name (as declared in its SKILL.md / SKILL.toml), not the directory name. Disabled skills are skipped during loading regardless of which level they came from.
Security Audit of Skill Directories
[skills].audit_enabled (default false) runs a security audit on each skill directory and skips any skill whose audit is not clean (crates/skill/src/types/config.rs; crates/skill/src/loading/loader.rs):
[skills]
audit_enabled = true
Related knobs in the same struct: allow_scripts (default false, gates whether scripts inside a skill are permitted by the audit), prompt_injection_mode, and prompt_budget_ratio (crates/skill/src/types/config.rs).
Custom Skills
Custom skills can be installed to user-level directory using the xiaoo --cli skill install command:
# Install from local directory (installs to ~/.xiaoo/skills by default)
xiaoo --cli skill install ./my-skill/
# Install from Git repository
xiaoo --cli skill install https://github.com/user/my-skill.git
Note: User-installed skills go to
~/.xiaoo/skills/(user level), NOT/usr/lib/.xiaoo/skills/(system level is reserved for built-in skills).
See skill_usage.md for detailed skill documentation.
Uninstalling Skills
# Remove a user-installed skill
xiaoo --cli skill remove <skill-name>
# Or manually remove from user level
rm -rf ~/.xiaoo/skills/<skill-name>
# Remove built-in guardian skill (requires root)
sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian
To completely uninstall xiaoO and all associated skills:
# Uninstall the application
cargo uninstall xiaoo-endside
# Remove system-level skills (requires root)
sudo rm -rf /usr/lib/.xiaoo/skills
# Remove user-level skills
rm -rf ~/.xiaoo/skills
RPM 安装环境下的插件管理
通过 RPM 安装 xiaoO-hookers 后,所有插件安装到 /usr/lib/.xiaoo/hookers/ 目录下。插件可以通过以下两种方式启用和关闭。
方式一:使用软链接命令(推荐)
RPM 安装时会创建两个全局软链接,可直接在任意目录执行:
# 交互式安装/启用插件
xiaoo-hookers-install
# 卸载/禁用插件
xiaoo-hookers-uninstall
也支持非交互模式:
# 启用 agent_moss(不交互,直接写入配置)
xiaoo-hookers-install --non-interactive agent_moss
方式二:直接编辑 config.toml
手动编辑 ~/.config/xiaoo/config.toml 的 [hooker] 段。
场景 1:启用所有插件
[hooker]
default = "All"
plugins = [
"/usr/lib/.xiaoo/hookers/agent_moss/plugin.json",
]
default = "All" 表示所有已注册的插件默认启用,plugins 列表中的 plugin.json 会被加载注册。
场景 2:启用所有插件,但排除个别不需要的
[hooker]
default = "All"
plugins = [
"/usr/lib/.xiaoo/hookers/agent_moss/plugin.json",
"/usr/lib/.xiaoo/hookers/tool_post_secret_guard/plugin.json",
]
disabled = [
"pluginguard_secret_like_output",
]
disabled 列表填写 plugin.json 中定义的 id 字段值。
插件自身 install.sh 的行为(以 agent_moss 为例)
plugins/hookers/install.sh 会递归调用各插件子目录下的 install.sh(plugins/hookers/install.sh)。agent_moss 的 install.sh 当前行为:
- 创建 Python 虚拟环境(默认
/opt/agent_moss/venv,非 sudo 场景 fallback 到用户目录),从 PyPI 安装/升级agent-moss包(plugins/hookers/agent_moss/install.sh) - 写入
AGENT_MOSS_INSTANCE=xiaoo(systemd 模式写/etc/agent_moss/agent_moss.env,nohup 模式 export)并注册/启动 systemd 服务(plugins/hookers/agent_moss/install.sh)
agent_moss 是瘦 bridge 插件(bridge.py 仅依赖 stdlib,转发到常驻 AgentMoss HTTP 服务),判定逻辑在常驻服务进程里,不随每次 hook spawn 子进程。AGENT_MOSS_URL/AGENT_MOSS_PORT 可指向远端服务,AGENT_MOSS_LOG_PATH 指定桥接日志路径。
install.sh 不区分 RPM 安装与源码安装:无论 xiaoO 本体以何种方式安装,脚本一律创建 venv 并从 PyPI 安装/升级 agent-moss 包,仓库内的 install.sh 没有任何 RPM 分支或检测。RPM 打包方如需改用系统 Python,请自行适配脚本。
验证插件是否生效
# 查看 xiaoo 运行日志,确认插件已加载
xiaoo run -p "echo hello"
# 查看 agent_moss 桥接日志(路径由 AGENT_MOSS_LOG_PATH 指定)
cat "$AGENT_MOSS_LOG_PATH"
注意事项
- 路径使用绝对路径:
plugins列表中的路径必须是 plugin.json 的完整绝对路径,RPM 安装后路径为/usr/lib/.xiaoo/hookers/<plugin>/plugin.json disabled使用 hooker id:disabled列表填写的是 plugin.json 中id字段的值(如plugin_audit_tool_input、pluginguard_secret_like_output),不是文件路径- 修改配置后无需重启:xiaoo 每次启动时重新读取配置
Chat & Session Lifecycle Hooks
除 Tool 层 hook(*.Tool.*.pre/post/error)外,xiaoo 还提供 chat 层 与 session 层 两个挂载点,让外部插件能在用户输入、system prompt 组装、会话状态流转等阶段介入。它们与 Tool hook 共用同一套 plugin.json + sh -c 子进程 + stdin/stdout JSON 协议,可注册在同一个 plugin.json 数组里。
重要:hooker 不会被自动发现。 只有
[hooker].plugins列表里显式列出的 plugin.json 文件才会被读取并注册(crates/hook/src/framework/registry/builder.rs)。把 plugin.json 放到~/.xiaoo/hookers/<name>/或任意目录本身不会生效——该目录只是社区约定放置 hooker 的位置,仍必须在[hooker].plugins中写入它的完整绝对路径。未列出的文件永远不会被读取,也不会报错。
[hooker]
default = "None" # None: 只启用 enabled 列表;All: 启用所有已注册 hooker
plugins = [
"/home/me/.xiaoo/hookers/js-test/plugin.json", # 必须显式列出绝对路径
]
enabled = ["js_test_chat_message"]
注册后是否生效还取决于 default:default = "None" 时只有 enabled 里列出的 hooker id 处于启用状态,default = "All" 时所有已注册 hooker 默认启用、再由 disabled 排除(crates/hook/src/framework/registry/builder.rs)。enabled / disabled / policies 里出现未注册的 hooker id 会直接构建失败(crates/hook/src/framework/registry/builder.rs)。
Hook points overview
| Hook point | 触发时机 | 插件可返回结果 |
|---|---|---|
*.Chat.command.before |
用户输入命中 ~/.xiaoo/commands/<name>.md 斜杠命令、模板展开为 body 之后、body 提交为 user turn 之前 |
Allow / Transform { body } / Deny { reason } |
*.Chat.message.received |
user 消息构造完成、写入消息历史之前 | Accept / Transform { message } |
*.Chat.system.transform |
PromptBuilder 组装完 system: Vec<String> 分段、合并成单条 system 消息之前 |
Allow / Transform { system } |
*.Session.lifecycle.created |
会话新建(首次 open / 新会话首个 turn / 从 checkpoint 派生 runtime) | Ack(事件型,无可变输出) |
*.Session.lifecycle.closed |
会话被关闭(幂等:已 Closed 的会话不会重复触发) | Ack(事件型,无可变输出) |
*.Session.lifecycle.state |
root turn 生命周期状态切换:idle(一次非错误 root turn 结束,Complete/MaxTurnsReached/BudgetExhausted/Cancelled 四种 Ok 结局,会话回到 idle)/ failed(turn 以 Err 结束) |
Ack(事件型,无可变输出) |
*.Llm.pre |
单次 LLM 请求发出之前 | Allow / Transform { modified_request } |
*.Llm.post |
LLM 响应返回之后 | Accept / Transform { modified_response } |
*.Llm.error |
LLM 调用失败 | Propagate / Recover { response } |
hook point 的
detail段(第 3 段)是自由文本,不参与匹配。 上表的message/system/command/lifecycle/complete只是可读性约定;真正决定路由的是action+stage(crates/hook/src/hookers/hook_point_category.rs)。所以*.Llm.complete.pre与*.Llm.whatever.pre等价。段数必须正好 4 段。
前三个 chat hook 是「可变 hook」——插件可以改写或拒绝输入;三个 session hook 是「事件型观察者」——只能确认收到事件,没有
transform/deny路径。idle状态下actions(create_session/switch_session/send_prompt)会被收集并执行;failed状态下actions被丢弃(fire-and-forget);created/closed不收集actions(gateway 忽略其输出)。
与 Tool hook 的差异
-
执行模型相同:都是
sh -c <command>,stdin 写入一次 JSON payload、stdout 读取一次 JSON 结果,非零退出视为失败。 -
payload.stage不同:chat/session hook 的 stage 字符串是command_before/chat_message/system_transform/session_state(不是pre/post/error),脚本据此分发。 -
hook 族共四个:
tool/llm/chat/session(crates/hook/src/hookers/plugin/builder.rs)。除了本文的 chat/session 挂载点,还有*.Llm.pre/*.Llm.post/*.Llm.error一族,包裹单次 LLM 请求/响应往返,可返回allow/transform { modified_request }(pre)、accept/transform { modified_response }(post)、propagate/recover { response }(error)。仓库内的llm_pre_secret_guard就是活例子(plugins/hookers/llm_pre_secret_guard/plugin.json)。注意 Llm 族 payload 不带session_id/workspace。 -
超时不同:chat / session 子进程上限 30s,tool / llm 为 10 分钟(
crates/hook/src/hookers/plugin/core.rs;crates/hook/src/hookers/plugin/tool/adaptor.rs;crates/hook/src/hookers/plugin/llm/adaptor.rs)。超时后子进程被kill_on_drop杀掉,该 hook 记为失败。 -
路由只在 (action, stage) 上:hook point 第 3 段
detail是自由文本,不参与匹配也不做校验——*.Chat.message.received与*.Chat.whatever.received命中同一类目;决定族与类目的是action与stage(crates/hook/src/hookers/hook_point_category.rs)。段数不等于 4、action/stage为空、或(action, stage)不在支持列表内,都会在注册构建期直接报错。 -
调度差异:chat hook 在 agent loop 同步执行,单个插件报错只
tracing::warn!不中断整轮,只有command.before的Deny会短路;session state hook 在 gateway 后台执行:idle状态在run_turn返回后等待所有 hooker 完成(30s 整体上限,便于收集actions注入Done事件),failed状态走tokio::spawn的 fire-and-forget 路径(不阻塞错误返回),错误一律走tracing::warn!,绝不影响主流程或run_turn返回值。 -
交互机制:
action: "ask_user"由四个族共用的 plugin hooker core 实现,因此 chat / tool / llm 三类 hook 都支持——插件可发起Confirm/TextInput/Choice交互,用户回答后 xiaoo 会带着interaction字段再次调用同一命令,直到插件返回final(crates/hook/src/hookers/plugin/core.rs;chatchat/adaptor.rs、tooltool/adaptor.rs、llmllm/adaptor.rs)。session lifecycle hook(created/closed/state)不支持该机制,它走一次性子进程调用(session/adaptor.rs)。回调 payload 中
interaction.response按kind区分四种取值(注意响应的 kind 命名与请求的confirm/text_input/choice不同名):response.kindJSON 形状 含义 confirmed{"kind":"confirmed","allowed":true|false}confirm 问题的明确回答: allowed:true= 用户选「是」,allowed:false= 用户选「否」unanswered{"kind":"unanswered"}用户未作答(turn 被取消、提示框被关闭、超时或无可用交互后端)。不能视为「否」——插件不得将其当作拒绝处理;若该回答决定后续动作,应重新发起 ask_usertext{"kind":"text","value":...}text_input 的回答: value为用户输入原文,null表示未作答(超时场景可能是[INTERACTION_TIMEOUT]哨兵串)choice{"kind":"choice","value":...}choice 的回答: value为选中选项原文或自定义输入,null表示未作答(超时哨兵同上)取值定义见
crates/agent-types/src/interaction/types.rs的InteractionResponse(含Unanswered变体与unanswered()辅助构造)。兼容性提示:confirm 的未作答/超时以
{"kind":"unanswered"}下发,与明确回答({"kind":"confirmed","allowed":true|false})是不同的kind。用!response.allowed之类取反判断的插件会把「未作答」误读为「否」,请改为先判response.kind。完整协议见plugins/hookers/how-to-develop-a-plugin-hooker.md第 14.6 节。
Minimal plugin.json
~/.xiaoo/hookers/js-test/plugin.json 一次性注册六个 hooker,全指向同一个脚本(该文件仍需按上文加入 [hooker].plugins 才会被读取):
[
{ "id": "js_test_system_transform", "hook_point": "*.Chat.system.transform", "command": "node ~/.xiaoo/hookers/js-test/xiaoo-hook-test.js" },
{ "id": "js_test_chat_message", "hook_point": "*.Chat.message.received", "command": "node ~/.xiaoo/hookers/js-test/xiaoo-hook-test.js" },
{ "id": "js_test_command_before", "hook_point": "*.Chat.command.before", "command": "node ~/.xiaoo/hookers/js-test/xiaoo-hook-test.js" },
{ "id": "js_test_session_created", "hook_point": "*.Session.lifecycle.created", "command": "node ~/.xiaoo/hookers/js-test/xiaoo-hook-test.js" },
{ "id": "js_test_session_closed", "hook_point": "*.Session.lifecycle.closed", "command": "node ~/.xiaoo/hookers/js-test/xiaoo-hook-test.js" },
{ "id": "js_test_session_state", "hook_point": "*.Session.lifecycle.state", "command": "node ~/.xiaoo/hookers/js-test/xiaoo-hook-test.js" }
]
stage 取值表
payload.stage 是 xiaoo 在构造 payload 时硬编码写入的判别字符串,不等于 hook_point 的最后一段:
hook_point(plugin.json 里写的) |
payload 里的 stage |
|---|---|
*.Chat.system.transform |
system_transform |
*.Chat.message.received |
chat_message |
*.Chat.command.before |
command_before |
*.Session.lifecycle.created |
session_created |
*.Session.lifecycle.closed |
session_closed |
*.Session.lifecycle.state |
session_state |
session_id 与 workspace 获取
插件需要当前会话 id 时,直接读 payload.session_id 即可,无需区分 local / remote 模式:
- local 模式:TUI 进程内跑 agent loop,hooker 子进程由 TUI 拉起,
payload.session_id就是 TUI 的state.session_id(UUID)。 - remote 模式:TUI 把自己的
session_id通过RuntimeTurnRequest发给 daemon,daemon 原样使用;hooker 子进程由 daemon 拉起,payload.session_id与 local 模式下是同一个值。
因此插件不必判断"我现在是被 TUI 还是 daemon 拉起的",payload.session_id 永远是「当前会话」的正确 id。
各 hook 点 payload.session_id / payload.workspace 可用性:
| hook_point | payload.session_id | payload.workspace | 类型 |
|---|---|---|---|
*.Chat.command.before |
✓ | ✓ | String |
*.Chat.message.received |
✓ | ✓ | String |
*.Chat.system.transform |
✓ | ✓ | String | null(session_id) |
*.Session.lifecycle.created / closed |
✓ | ✓ | String(workspace 可能为 null) |
*.Session.lifecycle.state |
✓ | ✓ | String(workspace 可能为 null) |
*.Tool.*.pre |
✓ | ✓ | String |
*.Tool.*.post / error |
✓ | ✓ | String |
*.Llm.*.pre / post / error |
✗ | ✗ | payload 不带 session_id / workspace |
system_transform的 session_id 理论上可能为null(ChatSystemTransformInput.session_id是Option<String>),脚本里建议payload.session_id || "(unknown)"兜底。payload.workspace是会话工作区根目录的绝对路径:Tool/Chat hook 从 runtime 上下文取、Session hook 从会话记录取,未绑定工作区时为null;hooker 子进程的 cwd 继承自宿主进程(可能随 bashcd漂移),需要工作区路径时应以payload.workspace为准,不要依赖 cwd。*.Llm.*hook 的 payload 目前不带 session_id / workspace。payload 里没有
cwd字段。 Tool pre/post/error payload 的工作区字段名是workspace(crates/hook/src/hookers/plugin/tool/adaptor.rs,值由core.rs生成)。读payload.cwd会永远得到undefined/""——不要在插件里用这个名字。
Minimal demo script
最简 demo(.js):每个 hook 都演示如何获取 session id 并做一次用户可感知的改写——[sid=<session_id>] 标记会出现在用户消息、system 指令和 /tmp/xiaoo-demo.log 日志里,便于核对每个钩子各自拿到的会话 id(command_before 额外拼 payload.command/payload.arguments,chat_message 额外拼 payload.prior_message_count 判定首条消息):
#!/usr/bin/env node
// xiaoo hooker demo (js-test): 在每个 hook 点演示如何获取当前 session id。
// 直接读 payload.session_id 即可,无需区分 local/remote 模式(两种模式下
// 是同一个值——TUI 把 session_id 发给 daemon,daemon 原样使用)。
// stdout 只写结果 JSON;日志走 stderr 或文件。
const fs = require("fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
// 统一取 session_id:system_transform 的该字段可能为 null,做兜底。
const SID = payload.session_id || "(unknown)";
// 每个 hook 触发时都往日志写一行,便于核对各钩子各自拿到的 sid。
fs.appendFileSync("/tmp/xiaoo-demo.log",
`[${new Date().toISOString()}] stage=${payload.stage} sid=${SID}\n`);
let result;
switch (payload.stage) {
case "command_before": {
// payload.session_id = 当前会话 id
// payload.command = 斜杠命令名(如 "hook-test"),仅斜杠命令入口进此 stage
// payload.arguments = 原始参数串(如 "aaa",无参为空串)
// payload.body = 模板展开后的 body
const cmd = payload.command || "";
const args = payload.arguments || "";
const tag = cmd ? `[HOOK:command_before:${cmd}${args ? ` ${args}` : ""}]` : "[HOOK:command_before]";
result = { result: "transform", body: `${tag} [sid=${SID}] ${payload.body || ""}` };
break;
}
case "chat_message": {
// payload.session_id = 当前会话 id
// payload.prior_message_count = 当前用户消息落库前的历史消息数:
// 全新 session 首条 = 0;首轮完成后再发 = 2;只回放了 user 的恢复 = 1。
// 用 <= 1 判定「会话首条有效输入」(兼容 retry/中断恢复)。
const count = payload.prior_message_count ?? 0;
const tag = count <= 1
? `[HOOK:chat_message:first#${count}]`
: `[HOOK:chat_message:follow-up#${count}]`;
const blocks = (payload.message?.blocks || []).map((b) =>
b.type === "text" ? { ...b, text: `${tag} [sid=${SID}] ${b.text}` } : b
);
result = { result: "transform", message: { ...payload.message, blocks } };
break;
}
case "system_transform": {
// payload.session_id = 当前会话 id(可能为 null,已在顶部兜底)
// 往 system 数组追加一条指令 → LLM 回复会以 [HOOK:system_transform] 开头
const sys = Array.isArray(payload.system) ? payload.system : [];
result = { result: "transform", system: [...sys, `回复必须以 [HOOK:system_transform] 开头(sid=${SID})`] };
break;
}
case "session_state": {
// payload.session_id = 当前会话 id(事件型、只读)
// payload.state = "idle" / "failed"
// - idle:一轮非错误结束(actions 会被收集执行)
// - failed:turn 以 Err 结束(fire-and-forget,actions 被丢弃)
// payload.outcome =
// - idle -> complete / max_turns_reached / budget_exhausted / cancelled
// - failed -> "error"
if (payload.state === "idle" && payload.outcome === "max_turns_reached") {
const newSession = `cont-${Date.now()}`;
result = {
result: "ack",
actions: [
{ kind: "create_session", session_id: newSession },
{ kind: "switch_session", session_id: newSession }
]
};
} else {
result = { result: "ack" };
}
break;
}
case "session_created":
case "session_closed": {
// payload.session_id = 当前会话 id(事件型、只读)
// payload.workspace = 会话工作区根目录(可能为 null)
result = { result: "ack" };
break;
}
default:
result = { result: "allow" };
}
// 当某个 case 被注释掉、result 仍为 undefined 时,按 stage 回退到对应
// 的 no-op 结果,避免 JSON.stringify(undefined) → process.stdout.write(undefined)
// 抛 ERR_INVALID_ARG_TYPE 让整个 hooker 退出状态 1、被宿主当成失败跳过。
if (result === undefined) {
const noop = {
command_before: { result: "allow" },
chat_message: { result: "accept" },
system_transform: { result: "allow" },
session_state: { result: "ack" },
session_created: { result: "ack" },
session_closed: { result: "ack" },
};
result = noop[payload.stage] ?? { result: "allow" };
}
process.stdout.write(JSON.stringify(result));
运行后核对 session id:
# 各钩子每次触发都会写一行,可直接看到各钩子拿到的 sid
cat /tmp/xiaoo-demo.log
# [2026-...Z] stage=command_before sid=550e8400-e29b-...
# [2026-...Z] stage=chat_message sid=550e8400-e29b-...
# [2026-...Z] stage=system_transform sid=550e8400-e29b-...
# [2026-...Z] stage=session_created sid=550e8400-e29b-...
# [2026-...Z] stage=session_state sid=550e8400-e29b-...
# [2026-...Z] stage=session_closed sid=550e8400-e29b-...
local 与 remote 模式下
sid值一致;remote 模式下日志写在 daemon 所在机器(hooker 由 daemon 进程拉起),不是 TUI 机器。
完整的 payload 字段、各 hook 合法输出 JSON 形状、ask_user 交互协议细节,请参考 plugins/hookers/how-to-develop-a-plugin-hooker.md 中的 Chat hook 与 Session lifecycle state hook 章节。
Plugin-requested session actions
*.Session.lifecycle.state hook 的响应里除了 result: "ack",还可以携带一个 actions 数组,让插件请求宿主在 turn 结束之后执行副作用——目前支持三种:
kind |
必填字段 | daemon 侧行为 | TUI 侧行为 |
|---|---|---|---|
create_session |
session_id |
调用 open_session(幂等恢复)打开/恢复该会话 |
切换焦点到该会话并恢复历史 transcript |
switch_session |
session_id |
调用 open_session(幂等恢复)确保目标会话存在 |
切换焦点到该会话并恢复历史 transcript(已聚焦则跳过) |
send_prompt |
session_id、text |
调用 open_session(幂等恢复)确保目标会话存在;并给 action 盖戳 chain_depth = 当前 turn 深度 + 1 |
切换焦点到该会话(已聚焦则跳过)、本地回显 text 为用户消息、并向 daemon 的 POST /api/v1/runtimes/input 提交该 turn 以拉取 SSE 流自动执行。仅 remote 模式生效,本地模式丢弃 |
响应 JSON 形状:
{
"result": "ack",
"actions": [
{ "kind": "create_session", "session_id": "debug-1" },
{ "kind": "switch_session", "session_id": "debug-1" },
{ "kind": "send_prompt", "session_id": "debug-1", "text": "Investigate the failure you just saw." }
]
}
send_prompt的chain_depth字段由宿主盖戳,插件不要设置:daemon 在转发前用当前 turn 深度 + 1覆盖,TUI 再把它原样回填到新 turn 请求里,让 daemon 在那一轮结束时再次校验。详见下方「跨 turn 深度上限」。
要点:
- 只有
*.Session.lifecycle.statehook 的 adaptor 会解析actions;chat/tool hook 的响应里写actions会被忽略。 - session state hook 从 fire-and-forget 改为 awaited:dispatcher 会等所有 hooker 跑完、收集
actions后才把Done事件发给 TUI,所以慢的 hooker 会延迟Done——actions-emitting hooker 务必保持快。 - daemon 侧先
open_session,失败的 action 被tracing::warn!记录并过滤,不会转发到 TUI;TUI 侧再异步切换焦点并恢复 transcript。 - 整条链路 best-effort:任何一层失败都不会向 hook 调用者回传错误。
kind用snake_case,未知kind或缺字段的条目会被静默跳过——单个坏条目不会污染数组其它条目。- 宿主强制
max action depth = 3防hook→action→hook单批递归:超过上限的批次会被截断(保留前 3 条,多余的条目被tracing::warn!记录后丢弃)。因此create_session + switch_session + send_prompt三件套恰好填满预算——若要批量发多条send_prompt,需要拆到多个 turn。插件作者请按此硬上限设计。
跨 turn send_prompt 深度上限
send_prompt 触发的 turn 结束时会再次 fire *.Session.lifecycle.state hook,插件可再发 send_prompt,形成跨 turn 链。xiaoo 不约束链的语义终止条件(那是插件脚本自己的职责),但提供一条宿主侧硬性深度兜底:
- 配置项:
[hooker].max_prompt_chain_depth,默认128。 - 语义:
N表示一条链累积最多跑 N 个 turn——用户发起的 turn(depth 0)+N - 1个send_prompt触发的 turn(depth1..=N-1)。第 N 个send_prompt触发的 turn(depthN)会被丢弃。 - daemon 在
fire_session_state_hook_and_collect_actions收集到send_prompt后,盖戳chain_depth = 当前 turn 深度 + 1;若该值>= max_prompt_chain_depth,丢弃该 action(不转发到 TUI)并tracing::warn!,链在此终止。 - 用户手动输入的 turn 永远带
chain_depth = 0,立即重置链——人机协作时随时可以打断。 - 插件无法伪造低深度绕过上限:daemon 一律覆盖插件可能填写的
chain_depth值。
[hooker]
max_prompt_chain_depth = 128 # 默认值;按需调小便于调试
send_prompt 的使用约束
- 数组顺序:TUI 顺序处理
actions,send_prompt必须排在数组最后且每批最多一条。否则后续的switch_session会重置 TUI state 并打断刚 start 的 turn 的流渲染。max action depth = 3与「三件套」模式天然约束了这一点。 - 同会话去重:当
send_prompt的session_id等于当前已聚焦会话时,TUI 跳过 switch 步骤,直接回显并提交 turn,避免重载 transcript 与插入冗余的 "Switched to session" 系统消息。 - 并发:若 TUI 收到
send_prompt时已有 turn 在跑(is_loading=true,常见于上一轮的 reveal buffer 还未排空),提示词会被入队pending_turns,由start_next_queued_turn在 idle 后消费——与用户手输并发时的行为一致。 - 仅 remote 模式:本地(非 daemon)模式下
send_prompt与create_session/switch_session一样会被 TUI 丢弃并tracing::warn!,因为可见执行需要 remote backend 才能发起/runtimes/input。 - TUI 断连:daemon 转发
send_prompt后若 TUI 未在读 SSE 流,该 action 实质丢失(daemon 只做了open_session,未自跑 turn)。这是「可见执行」设计的必然代价——turn 的 SSE 流只能由 TUI 发起请求拿到。 - turn B 的 chat hook 重入:
send_prompt触发的 turn B 是正常 turn,daemon 的agent_loop会对其 fire*.Chat.message.received,插件会看到自己刚发出的text。若同一插件兼注册chat_message与session_state,chat_message分支不要再基于此发 action,避免二次放大。 prior_message_count语义:新建 session 的首条send_prompt在chat_messagehook 里prior_message_count = 0,会命中「首条消息」逻辑(如上下文注入),可能符合或不符合预期。- 回显 vs 改写:若
chat_messagehook 改写了send_prompt的text,TUI 本地回显原文、daemon 实际跑改写版,下次 transcript 恢复会显改写版。 reasoning_effort/llm配置:切焦点时reset_for_new_session会重置状态,turn B 用目标 session 的默认配置,而非原 turn 的(用户 Ctrl+T 调的强度会丢,与手动/sessions切换一致)。
完整字段定义、daemon/TUI 两端执行流程、最小示例与排错清单,请参考 plugins/hookers/how-to-develop-a-plugin-hooker.md 第 16 章。