feat(hook): accept kimi hook events over stdin
kimi's [[hooks]] commands stream event JSON on stdin exactly like claude, so the source whitelist and the stdin-draining payload path take "kimi" alongside "claude". The foreign-runner gate stays claude-only: kimi hooks live in kimi's own config.toml, no other runner reads it.
This commit is contained in:
1 parent
75dc0b9481
commit
3d19ec05b0
1 file changed
+76
-26
+76
-26
@@ -1,27 +1,30 @@
|
||||
//! pebrel-hook — the bridge between AI-CLI lifecycle hooks and Pebrel.
|
||||
//!
|
||||
//! Claude Code (`Stop` / `Notification` / `UserPromptSubmit` hooks), Codex
|
||||
//! (`notify` program), Pi and opencode (bundled extensions/plugins, shelling out on
|
||||
//! `session.idle` / `permission.updated` / user-prompt) invoke this for every
|
||||
//! turn event. It forwards the raw payload to the hosting Nebula instance over
|
||||
//! a named pipe and exits.
|
||||
//! Claude Code (`Stop` / `Notification` / `UserPromptSubmit` hooks), Kimi Code
|
||||
//! (`[[hooks]]` commands in `config.toml`, event JSON streamed on stdin like
|
||||
//! claude), Codex (`notify` program), Pi and opencode (bundled extensions/
|
||||
//! plugins, shelling out on `session.idle` / `permission.updated` / user-prompt)
|
||||
//! invoke this for every turn event. It forwards the raw payload to the hosting
|
||||
//! Nebula instance over a named pipe and exits.
|
||||
//! Design constraints, in order:
|
||||
//!
|
||||
//! 1. INVISIBLE: a Stop hook's exit code is meaningful to claude (non-zero
|
||||
//! surfaces an error banner, 2 even blocks the turn). Every path —
|
||||
//! including panic — must exit 0, fast. Claude also writes the payload to
|
||||
//! our stdin, so claude mode always drains stdin even when the message
|
||||
//! goes nowhere: an unread pipe could surface as a hook write error.
|
||||
//! 2. SCOPED: the hook config is global (settings.json), but the effect must
|
||||
//! be Nebula-only. The scope guard is the environment: NEBULA_NOTIFY_PIPE
|
||||
//! only exists for processes spawned inside Nebula. Anywhere else this is
|
||||
//! an invisible ~10 ms no-op.
|
||||
//! surfaces an error banner, 2 even blocks the turn), and kimi's `Stop` is
|
||||
//! likewise a blockable event. Every path — including panic — must exit 0,
|
||||
//! fast. Claude and kimi also write the payload to our stdin, so those modes
|
||||
//! always drain stdin even when the message goes nowhere: an unread pipe
|
||||
//! could surface as a hook write error.
|
||||
//! 2. SCOPED: the hook config is global (settings.json / kimi's config.toml),
|
||||
//! but the effect must be Nebula-only. The scope guard is the environment:
|
||||
//! NEBULA_NOTIFY_PIPE only exists for processes spawned inside Nebula.
|
||||
//! Anywhere else this is an invisible ~10 ms no-op.
|
||||
//! 3. FAST: pure std, no JSON handling (Nebula parses), one pipe write.
|
||||
//! Keeps the whole claude→toast chain under ~50 ms.
|
||||
//!
|
||||
//! Usage (installed by `nebula setup-ai` / Nebula's boot self-heal):
|
||||
//! ```text
|
||||
//! nebula-hook claude # payload on stdin
|
||||
//! nebula-hook kimi # payload on stdin
|
||||
//! nebula-hook codex <json> # payload as last arg
|
||||
//! nebula-hook codex --chain <exe> <fixed…> <json> # + exec previous notifier
|
||||
//! nebula-hook opencode <json> # payload as last arg
|
||||
@@ -175,38 +178,60 @@ fn read_payload(mut reader: impl Read) -> std::io::Result<Option<Vec<u8>>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已知调用方白名单。不在名单里的第一参数视为误调用:约束 2 要求在 Nebula 之外
|
||||
/// 也必须是无声 no-op。新增 provider 时同步在 `payload_on_stdin` 声明载荷通道。
|
||||
fn known_source(source: &str) -> bool {
|
||||
matches!(source, "claude" | "codex" | "opencode" | "pi" | "kimi")
|
||||
}
|
||||
|
||||
/// 载荷通道。claude 与 kimi 都把事件 JSON 写到我们的 stdin(kimi 的 `[[hooks]]`
|
||||
/// command 与 claude 的 hook 同形态,事件经 stdin 传入);走 stdin 的 source 必须
|
||||
/// 无论如何都抽干管道(约束 1:未读的管道会在 CLI 侧变成 hook write error)。
|
||||
/// 其余 CLI 把载荷追加为末位参数。
|
||||
fn payload_on_stdin(source: &str) -> bool {
|
||||
matches!(source, "claude" | "kimi")
|
||||
}
|
||||
|
||||
/// 侧信道信封:一行 `nebula-hook/1 source=<s> pane=<p>` 头加原始载荷。helper 不重
|
||||
/// 编码,Nebula 侧按 source 路由到对应 provider 的解析分支。
|
||||
fn envelope(source: &str, pane: &str, payload: &[u8]) -> Vec<u8> {
|
||||
let mut message = format!("nebula-hook/1 source={source} pane={pane}\n").into_bytes();
|
||||
message.extend_from_slice(payload);
|
||||
message
|
||||
}
|
||||
|
||||
fn run() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(source) =
|
||||
args.first().filter(|s| matches!(s.as_str(), "claude" | "codex" | "opencode" | "pi"))
|
||||
else {
|
||||
let Some(source) = args.first().filter(|s| known_source(s)) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Payload: claude streams JSON on stdin; codex and opencode append it as
|
||||
// the last arg.
|
||||
let payload = match source.as_str() {
|
||||
"claude" => match read_payload(std::io::stdin().lock()) {
|
||||
// Payload: claude and kimi stream JSON on stdin; codex, opencode and pi
|
||||
// append it as the last arg.
|
||||
let payload = if payload_on_stdin(source) {
|
||||
match read_payload(std::io::stdin().lock()) {
|
||||
Ok(Some(bytes)) => bytes,
|
||||
Ok(None) => {
|
||||
log_outcome(source, "", MAX_PAYLOAD_BYTES + 1, &Outcome::PayloadTooLarge);
|
||||
return;
|
||||
},
|
||||
Err(_) => return,
|
||||
},
|
||||
_ => args.last().cloned().unwrap_or_default().into_bytes(),
|
||||
}
|
||||
} else {
|
||||
args.last().cloned().unwrap_or_default().into_bytes()
|
||||
};
|
||||
|
||||
// 串台门。放在读完 stdin 之后:约束 1 要求 claude 模式无论如何都把 stdin
|
||||
// 抽干(未读的管道会在 CLI 侧变成 hook write error),所以先读再退。
|
||||
// 串台门。放在读完 stdin 之后:约束 1 要求 stdin 模式的 source 无论如何都把
|
||||
// stdin 抽干(未读的管道会在 CLI 侧变成 hook write error),所以先读再退。
|
||||
// kimi 的 hook 只装在它自己的 config.toml 里,别家 runner 不读那份配置,
|
||||
// 没有串台路径,这道门保持 claude 专属。
|
||||
if source == "claude" && foreign_hook_runner().is_some() {
|
||||
log_outcome(source, "", payload.len(), &Outcome::ForeignRunner);
|
||||
return;
|
||||
}
|
||||
|
||||
let pane = hook_env("PANE_ID").and_then(|value| value.into_string().ok()).unwrap_or_default();
|
||||
let mut message = format!("nebula-hook/1 source={source} pane={pane}\n").into_bytes();
|
||||
message.extend_from_slice(&payload);
|
||||
let message = envelope(source, &pane, &payload);
|
||||
|
||||
// 本地 Pane 使用命名管道;远端 Pane 没有本地管道时,把同一信封写入控制终端的私有 OSC。
|
||||
let mut outcome = Outcome::NotHosted;
|
||||
@@ -314,6 +339,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// kimi 与 claude 共用 stdin 载荷通道,但信封头签自己的 source——Nebula 侧
|
||||
/// 靠这个字段把载荷路由给 kimi 的解析分支,签错名字会被静默丢弃。
|
||||
#[test]
|
||||
fn kimi_reads_stdin_and_signs_its_own_envelope() {
|
||||
for source in ["claude", "codex", "opencode", "pi", "kimi"] {
|
||||
assert!(super::known_source(source), "{source} 必须仍在白名单里");
|
||||
}
|
||||
assert!(!super::known_source("kimi-code"), "白名单只认精确的 source 名");
|
||||
assert!(super::payload_on_stdin("claude"));
|
||||
assert!(super::payload_on_stdin("kimi"));
|
||||
for source in ["codex", "opencode", "pi"] {
|
||||
assert!(!super::payload_on_stdin(source), "{source} 的载荷在末位参数");
|
||||
}
|
||||
|
||||
let stdin_json: &[u8] =
|
||||
br#"{"hook_event_name":"Stop","session_id":"s1","client_type":"kimi_code_cli"}"#;
|
||||
let payload = super::read_payload(stdin_json).unwrap().unwrap();
|
||||
assert_eq!(payload, stdin_json);
|
||||
|
||||
let message = super::envelope("kimi", "11", &payload);
|
||||
let header: &[u8] = b"nebula-hook/1 source=kimi pane=11\n";
|
||||
assert!(message.starts_with(header));
|
||||
assert_eq!(&message[header.len()..], stdin_json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_remote_hook_payload() {
|
||||
assert_eq!(base64_encode(b"abc"), "YWJj");
|
||||
|
||||
Reference in new issue
Block a user