Wire hooks, resume and detection for Kimi Code CLI (#694)

* feat(agents): wire hooks, resume and detection for Kimi Code

Kimi Code CLI takes its hooks as [[hooks]] entries in the same
config.toml that holds the user's providers and models, so this adds a
third install strategy — a format-preserving TOML merge on toml_edit —
beside the JSON map merge and the owned files. Like Qwen it reports
permission requests first-class, so it gets no Notification hook.
Resume rides `kimi --session <id>`; fork stays unwired, Kimi
documents none.

Closes #693

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>

* fix(agents): harden the Kimi Code TOML hook merge and its resume flags

The TOML merge strategy the Kimi wiring introduces round-trips a shared
config.toml cleanly, but three gaps sat behind it.

`hooks_state` counted only the marked entries that still named an event,
so a hand-edit that dropped the key off one of nine entries left the
remaining eight matching the roster exactly and the file reported
Installed with a broken entry in it. Every marked entry now counts,
which is what the JSON merge already did and what `refresh_hooks` needs
to see.

A `hooks = []` spelled as an empty inline array made install fail
outright -- toml_edit keeps an empty array and an array of tables apart,
but the two say the same thing and neither carries any configuration. It
is now promoted rather than refused. Every other wrong-shaped `hooks`
key -- a string, a table, a non-empty inline array -- still refuses with
the file left byte-for-byte alone.

`Stop` is not the only way a Kimi turn ends: its own event reference says
`Stop` does not fire on interrupts and `Interrupt` fires instead, and a
turn that dies on an error reports `StopFailure`. Without those two an
Esc or a failed turn left the pane on "working" for good and `tty7 wait`
could only ever time out. Both are observation-only events and report
the same end of turn `Stop` does.

On resume, `--agent` and `--agent-file` join the stale flags: Kimi
rejects either next to `--session` at startup, and resuming rebinds the
session agent by itself, so replaying them turned a working resume into
a launch error.

Tests cover the wrong-shaped `hooks` keys, a config.toml that does not
parse on both install and uninstall, a file that does not exist yet, a
second install being byte-for-byte the first, mangled and surplus marked
entries, an uninstall threading between the user's own entries and the
tables after them, and the `--session=<id>`, bare `--session`,
`--continue` and `--agent` spellings on the resume path.

---------

Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Austin Spraggins
2026-08-20 09:51:05 +08:00
committed by GitHub
co-authored by l0ng-ai
parent e82a460794
commit 2cdc26f357
18 changed files with 630 additions and 15 deletions
Generated
+2
View File
@@ -9250,6 +9250,7 @@ dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
]
@@ -9852,6 +9853,7 @@ dependencies = [
"system-configuration-sys",
"tempfile",
"tokio",
"toml_edit 0.25.13+spec-1.1.0",
"ureq",
"uuid",
"windows-sys 0.59.0",
+1 -1
View File
@@ -50,7 +50,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com
|---|---|
| **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · nine themes · IME |
| **Agent-aware** | per-pane detection (18 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed |
| **Agent-aware** | per-pane detection (19 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed |
| **Remote workspaces** | remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off |
| **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/workspace control · real PTY commands · output, process, port, and agent status |
| **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install |
+1 -1
View File
@@ -50,7 +50,7 @@
|---|---|
| **编辑器级输入** | 历史影子建议 · 带说明的 Tab 补全 · 语法高亮 · 多行编辑 · 点击定位光标 · <kbd>⌃ R</kbd> 模糊历史搜索 |
| **窗口** | 标签页与分屏 · <kbd>⌘ P</kbd> 命令面板 · <kbd>⌘ F</kbd> 回滚搜索 · 9 套主题 · 输入法 |
| **Agent-aware** | 按 pane 识别 18 个 CLI agent:状态点 · 通知 · 分支 + diff · 重启后续上会话 · 托盘图标提醒需要输入 |
| **Agent-aware** | 按 pane 识别 19 个 CLI agent:状态点 · 通知 · 分支 + diff · 重启后续上会话 · 托盘图标提醒需要输入 |
| **远程工作区** | 远端文件、仓库、Changes、diff、worktree、标签页和 pane · 任意客户端重连后原地继续 |
| **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/工作区控制 · 真实 PTY 命令 · 输出、进程、端口和 agent 状态 |
| **SSH** | 原生 russh 栈:profile 凭据进 keychain · SFTP 面板 · 端口转发 · 跳板机 · 一次无 sudo 安装 `tty7-server` |
+6
View File
@@ -0,0 +1,6 @@
<!-- Kimi brand mark, from lobehub/lobe-icons (MIT, Copyright (c) 2023 LobeHub).
Normalized to tty7's 24x24 icon format; geometry unchanged. -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 864 B

+8
View File
@@ -48,6 +48,14 @@ sha2 = "0.11"
# implementation.
ignore = "0.4"
# Format-preserving TOML editing for `core::agent_hooks`: Kimi Code takes its
# hooks as `[[hooks]]` entries in the same `config.toml` that holds the user's
# providers, models and comments, so installing must edit that file in place
# without reformatting it — which rules out the plain `toml` crate's
# parse-and-reserialize round trip. Already in the tree transitively, so this
# pins no new code.
toml_edit = "0.25"
# Lane assignment for the commit graph (`core::git::log`) keeps a couple of
# parents and a handful of edges per row; a SmallVec keeps those off the heap
# for the shapes that make up almost all of a real history. Already in the tree
+499 -3
View File
@@ -223,10 +223,11 @@ pub enum HookAgent {
Droid,
Qwen,
Goose,
Kimi,
}
impl HookAgent {
pub const ALL: [HookAgent; 11] = [
pub const ALL: [HookAgent; 12] = [
HookAgent::Claude,
HookAgent::Codex,
HookAgent::Copilot,
@@ -238,6 +239,7 @@ impl HookAgent {
HookAgent::Droid,
HookAgent::Qwen,
HookAgent::Goose,
HookAgent::Kimi,
];
/// The hooks behind a detected agent process, if it has any.
@@ -258,6 +260,7 @@ impl HookAgent {
CLIAgent::Droid => Some(HookAgent::Droid),
CLIAgent::Qwen => Some(HookAgent::Qwen),
CLIAgent::Goose => Some(HookAgent::Goose),
CLIAgent::Kimi => Some(HookAgent::Kimi),
CLIAgent::Aider
| CLIAgent::Amp
| CLIAgent::Cursor
@@ -283,7 +286,18 @@ impl HookAgent {
| HookAgent::Pi
| HookAgent::Grok
| HookAgent::OhMyPi
| HookAgent::Goose => None,
| HookAgent::Goose
| HookAgent::Kimi => None,
}
}
/// The events this agent's hooks merge into a shared TOML config, if that
/// is how it takes them — the third strategy, for the agents whose hooks
/// live as `[[hooks]]` entries in a config file the user also hand-edits.
fn toml_hook_events(self) -> Option<&'static [(&'static str, &'static str)]> {
match self {
HookAgent::Kimi => Some(KIMI_HOOK_EVENTS),
_ => None,
}
}
@@ -300,6 +314,7 @@ impl HookAgent {
HookAgent::Droid => "droid",
HookAgent::Qwen => "qwen",
HookAgent::Goose => "goose",
HookAgent::Kimi => "kimi",
}
}
@@ -316,6 +331,7 @@ impl HookAgent {
HookAgent::Droid => "Droid",
HookAgent::Qwen => "Qwen Code",
HookAgent::Goose => "Goose",
HookAgent::Kimi => "Kimi Code",
}
}
@@ -346,6 +362,7 @@ impl HookAgent {
HookAgent::Goose => {
target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"])
}
HookAgent::Kimi => target.kimi_config_path(),
}
}
@@ -429,6 +446,15 @@ impl<'a> HookTarget<'a> {
self.under_home(&[".config"])
}
fn kimi_config_path(&self) -> PathBuf {
if self.is_local()
&& let Some(dir) = std::env::var_os("KIMI_CODE_HOME").filter(|d| !d.is_empty())
{
return PathBuf::from(dir).join("config.toml");
}
self.under_home(&[".kimi-code", "config.toml"])
}
fn hook_command(&self, agent: HookAgent, event: &str) -> String {
if let Some(exe) = self.hook_command_exe() {
return format!("{exe} agent-hook {} {event}", agent.slug());
@@ -498,6 +524,9 @@ pub enum HooksState {
pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState {
let path = agent.target_path(target);
if let Some(events) = agent.toml_hook_events() {
return toml_hooks_state(target, &path, agent, events);
}
if let Some(events) = agent.hook_map_events() {
return hook_map_state(target, &path, agent, events);
}
@@ -531,6 +560,10 @@ pub enum HookOutcome {
pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result<HookOutcome> {
let path = agent.target_path(target);
if let Some(events) = agent.toml_hook_events() {
toml_hooks_install(target, &path, agent, events)?;
return Ok(HookOutcome::Installed);
}
if let Some(events) = agent.hook_map_events() {
hook_map_install(target, &path, agent, events)?;
if agent != HookAgent::Codex {
@@ -552,6 +585,9 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result<Ho
pub fn uninstall_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result<HookOutcome> {
let path = agent.target_path(target);
if agent.toml_hook_events().is_some() {
return toml_hooks_uninstall(target, &path, agent);
}
match agent.hook_map_events() {
Some(_) => hook_map_uninstall(target, &path, agent),
None => owned_file_uninstall(target, &path, &agent.marker()),
@@ -663,6 +699,30 @@ const QWEN_HOOK_EVENTS: &[(&str, &str)] = &[
("SessionEnd", "session-end"),
];
/// Kimi Code's hooks live as `[[hooks]]` entries in its main `config.toml` —
/// the same file that holds the user's providers and models — so they go
/// through the TOML merge strategy rather than a JSON map or an owned file.
/// Like Qwen it has a first-class permission event, so it needs no
/// `Notification` hook and none of the sniffing in [`effective_event`].
///
/// `Stop` alone does not cover every way a turn ends here: Kimi's own event
/// reference says `Stop` "does not fire on interrupts, so this event fires
/// instead" of `Interrupt`, and a turn that dies on an error reports
/// `StopFailure`. Without those two an <kbd>Esc</kbd> or a failed turn would
/// leave the pane on "working" forever and `tty7 wait` would only ever time
/// out, so both report the same end-of-turn as `Stop` does. All three are
/// observation-only events, and a doubled `stop` is idempotent.
const KIMI_HOOK_EVENTS: &[(&str, &str)] = &[
("SessionStart", "session-start"),
("UserPromptSubmit", "prompt-submit"),
("PermissionRequest", "permission-request"),
("PostToolUse", "tool-complete"),
("Stop", "stop"),
("Interrupt", "stop"),
("StopFailure", "stop"),
("SessionEnd", "session-end"),
];
const GROK_HOOK_TIMEOUT_SECS: u32 = 10;
const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[
@@ -816,6 +876,145 @@ fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&'
})
}
fn toml_hooks_state(
target: &HookTarget,
path: &Path,
agent: HookAgent,
events: &[(&str, &str)],
) -> HooksState {
let Ok(text) = target.read(path) else {
return HooksState::NotInstalled;
};
let Ok(doc) = text.parse::<toml_edit::DocumentMut>() else {
return HooksState::NotInstalled;
};
let marker = agent.marker();
let marked: Vec<&toml_edit::Table> = doc
.get("hooks")
.and_then(|h| h.as_array_of_tables())
.into_iter()
.flatten()
.filter(|entry| toml_command_is_marked(entry, &marker))
.collect();
if marked.is_empty() {
return HooksState::NotInstalled;
}
// Every marked entry counts towards the total, `event` or no `event`: a
// hand-edit that drops the key leaves an entry that is ours and is broken,
// which is exactly what Outdated means. Reporting NotInstalled instead
// would hide it from `refresh_hooks`, which only ever revisits Outdated.
let complete = marked.len() == events.len()
&& events.iter().all(|(hook_event, tty7_event)| {
let command = target.hook_command(agent, tty7_event);
marked.iter().any(|entry| {
entry.get("event").and_then(|e| e.as_str()) == Some(*hook_event)
&& entry.get("command").and_then(|c| c.as_str()) == Some(command.as_str())
})
});
if complete {
HooksState::Installed
} else {
HooksState::Outdated
}
}
fn toml_hooks_install(
target: &HookTarget,
path: &Path,
agent: HookAgent,
events: &[(&str, &str)],
) -> anyhow::Result<()> {
let mut doc: toml_edit::DocumentMut = match target.read(path) {
Ok(text) => text.parse().map_err(|e| {
anyhow::anyhow!(
"{} is not valid TOML ({e}); not touching it",
path.display()
)
})?,
Err(e) if e.kind() == io::ErrorKind::NotFound => toml_edit::DocumentMut::new(),
Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())),
};
// `hooks = []` and no `hooks` key at all say the same thing, but toml_edit
// keeps an empty inline array and an array of tables apart. Promote the
// one to the other rather than refusing over a difference that carries no
// configuration and that the user cannot see.
if doc
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|a| a.is_empty())
{
doc.remove("hooks");
}
let hooks = doc.entry("hooks").or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
));
let Some(list) = hooks.as_array_of_tables_mut() else {
return Err(anyhow::anyhow!(
"\"hooks\" in {} is not an array of tables; not touching it",
path.display()
));
};
let marker = agent.marker();
list.retain(|entry| !toml_command_is_marked(entry, &marker));
for (hook_event, tty7_event) in events {
let mut entry = toml_edit::Table::new();
entry["event"] = toml_edit::value(*hook_event);
entry["command"] = toml_edit::value(target.hook_command(agent, tty7_event));
list.push(entry);
}
target.write(path, doc.to_string().as_bytes())
}
fn toml_hooks_uninstall(
target: &HookTarget,
path: &Path,
agent: HookAgent,
) -> anyhow::Result<HookOutcome> {
let text = match target.read(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Ok(HookOutcome::NothingInstalled);
}
Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())),
};
let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e| {
anyhow::anyhow!(
"{} is not valid TOML ({e}); not touching it",
path.display()
)
})?;
let marker = agent.marker();
let mut removed = 0;
if let Some(list) = doc
.get_mut("hooks")
.and_then(|h| h.as_array_of_tables_mut())
{
let before = list.len();
list.retain(|entry| !toml_command_is_marked(entry, &marker));
removed = before - list.len();
if list.is_empty() {
doc.remove("hooks");
}
}
if removed == 0 {
return Ok(HookOutcome::NoTty7Hooks);
}
target.write(path, doc.to_string().as_bytes())?;
Ok(HookOutcome::Removed)
}
fn toml_command_is_marked(entry: &toml_edit::Table, marker: &str) -> bool {
entry
.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains(marker))
}
/// Returns the bare file name of `exe` when resolving that name from PATH
/// yields the same binary. Returns `None` when the name does not resolve, or
/// when an earlier PATH entry contains a different file with the same name
@@ -877,7 +1076,8 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option<String> {
| HookAgent::Codex
| HookAgent::Gemini
| HookAgent::Droid
| HookAgent::Qwen => None,
| HookAgent::Qwen
| HookAgent::Kimi => None,
}
}
@@ -1236,6 +1436,7 @@ mod tests {
.chain(DROID_HOOK_EVENTS)
.chain(QWEN_HOOK_EVENTS)
.chain(GOOSE_HOOK_EVENTS)
.chain(KIMI_HOOK_EVENTS)
.map(|(_, e)| *e)
.chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e))
.collect();
@@ -1268,6 +1469,7 @@ mod tests {
HookAgent::Goose,
"/home/me/.agents/plugins/tty7/hooks/hooks.json",
),
(HookAgent::Kimi, "/home/me/.kimi-code/config.toml"),
] {
assert_eq!(
agent.target_path(&t),
@@ -1287,6 +1489,7 @@ mod tests {
HookAgent::Droid,
HookAgent::Qwen,
HookAgent::Goose,
HookAgent::Kimi,
] {
assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled);
install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug()));
@@ -1564,6 +1767,7 @@ mod tests {
HookAgent::OhMyPi,
"/home/me/.omp/agent/extensions/tty7/index.ts",
),
(HookAgent::Kimi, "/home/me/.kimi-code/config.toml"),
] {
assert_eq!(
agent.target_path(&target),
@@ -1878,4 +2082,296 @@ mod tests {
unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") };
let _ = std::fs::remove_dir_all(&dir);
}
/// Kimi's hooks share `config.toml` with the user's providers and models,
/// so the merge must leave everything that is not ours — including
/// comments and formatting — byte-for-byte alone.
#[test]
fn kimi_install_preserves_the_user_s_config_toml() {
let dir = std::env::temp_dir().join(format!("tty7-kimi-hooks-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = dir.join("config.toml");
let user_half = concat!(
"# my providers\n",
"default_model = \"kimi-k2\"\n",
"\n",
"[[hooks]]\n",
"event = \"Stop\"\n",
"command = \"afplay ding.aiff\"\n",
);
std::fs::write(&config, user_half).unwrap();
unsafe { std::env::set_var("KIMI_CODE_HOME", &dir) };
let host = local_host();
let t = HookTarget::local(&*host).expect("home resolves in tests");
assert_eq!(HookAgent::Kimi.target_path(&t), config);
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled);
install_hooks(&t, HookAgent::Kimi).expect("install succeeds");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed);
install_hooks(&t, HookAgent::Kimi).expect("re-install succeeds");
let written = std::fs::read_to_string(&config).unwrap();
assert!(
written.starts_with(user_half),
"the user's half of config.toml — comment included — survives untouched"
);
let doc: toml_edit::DocumentMut = written.parse().expect("still valid TOML");
let hooks = doc["hooks"].as_array_of_tables().unwrap();
assert_eq!(
hooks
.iter()
.filter(|e| toml_command_is_marked(e, "agent-hook kimi"))
.count(),
KIMI_HOOK_EVENTS.len(),
"exactly one tty7 entry per event after two installs"
);
for (event, _) in KIMI_HOOK_EVENTS {
assert!(
hooks.iter().any(|e| {
toml_command_is_marked(e, "agent-hook kimi")
&& e.get("event").and_then(|v| v.as_str()) == Some(*event)
}),
"{event} carries the tty7 hook"
);
}
let healthy = std::fs::read_to_string(&config).unwrap();
std::fs::write(
&config,
healthy.replace("agent-hook kimi stop", "agent-hook kimi stop --stale"),
)
.unwrap();
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated);
install_hooks(&t, HookAgent::Kimi).expect("reinstall over an outdated entry succeeds");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed);
uninstall_hooks(&t, HookAgent::Kimi).expect("uninstall succeeds");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled);
let after = std::fs::read_to_string(&config).unwrap();
assert!(
after.contains("afplay ding.aiff"),
"the user's own Stop hook survives uninstall"
);
assert!(!after.contains("agent-hook kimi"));
uninstall_hooks(&t, HookAgent::Kimi).expect("uninstall is idempotent");
std::fs::write(&config, "not = valid = toml").unwrap();
assert!(
install_hooks(&t, HookAgent::Kimi).is_err(),
"a config.toml that does not parse is left alone"
);
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
"not = valid = toml",
"and is not rewritten on the way out"
);
assert!(
uninstall_hooks(&t, HookAgent::Kimi).is_err(),
"uninstall refuses the same file"
);
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
"not = valid = toml"
);
unsafe { std::env::remove_var("KIMI_CODE_HOME") };
let _ = std::fs::remove_dir_all(&dir);
}
/// A `config.toml` that parses but spells `hooks` as something other than
/// an array of tables is a file we do not understand. Every one of these
/// must come back as a refusal with the file untouched — the one thing
/// that must never happen to the file holding the user's API keys is a
/// silent rewrite.
#[test]
fn kimi_refuses_a_hooks_key_of_the_wrong_toml_type() {
let host = FakeRemote::shared();
let base = std::env::temp_dir().join(format!("tty7-kimi-shapes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for (name, text) in [
("a_string", "hooks = \"nope\"\n"),
("a_table", "[hooks]\nfoo = 1\n"),
(
"an_inline_array",
"hooks = [{ event = \"Stop\", command = \"afplay a.aiff\" }]\n",
),
] {
let home = base.join(name);
let t = HookTarget::remote(&*host, home.clone());
let config = HookAgent::Kimi.target_path(&t);
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
std::fs::write(&config, text).unwrap();
assert_eq!(
hooks_state(&t, HookAgent::Kimi),
HooksState::NotInstalled,
"{name}: nothing of ours is in there"
);
assert!(
install_hooks(&t, HookAgent::Kimi).is_err(),
"{name}: install refuses"
);
assert_eq!(
uninstall_hooks(&t, HookAgent::Kimi).unwrap(),
HookOutcome::NoTty7Hooks,
"{name}: uninstall finds nothing of ours"
);
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
text,
"{name}: the file is byte-for-byte what it was"
);
}
// `hooks = []` is the one shape that carries no configuration at all,
// so it is promoted instead of refused.
let home = base.join("an_empty_array");
let t = HookTarget::remote(&*host, home.clone());
let config = HookAgent::Kimi.target_path(&t);
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
std::fs::write(&config, "model = \"k2\"\nhooks = []\n").unwrap();
install_hooks(&t, HookAgent::Kimi).expect("an empty inline array is promoted, not refused");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed);
let written = std::fs::read_to_string(&config).unwrap();
assert!(written.starts_with("model = \"k2\"\n"), "{written}");
assert!(!written.contains("hooks = []"), "{written}");
assert_eq!(
uninstall_hooks(&t, HookAgent::Kimi).unwrap(),
HookOutcome::Removed
);
assert!(
!std::fs::read_to_string(&config).unwrap().contains("hooks"),
"the key goes when the last entry in it does"
);
let _ = std::fs::remove_dir_all(&base);
}
/// The rest of the TOML merge contract: a file that does not exist yet, a
/// second install that changes nothing, entries a hand-edit has mangled,
/// and an uninstall that has to thread its removals between the user's own
/// entries and the tables that follow them.
#[test]
fn kimi_toml_merge_holds_up_across_the_awkward_shapes() {
let host = FakeRemote::shared();
let base = std::env::temp_dir().join(format!("tty7-kimi-merge-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let marker = "agent-hook kimi";
// Nothing there at all: install creates the directory and the file.
let t = HookTarget::remote(&*host, base.join("fresh"));
let config = HookAgent::Kimi.target_path(&t);
assert!(!config.exists());
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled);
assert_eq!(
uninstall_hooks(&t, HookAgent::Kimi).unwrap(),
HookOutcome::NothingInstalled,
"there is no file to take anything out of"
);
install_hooks(&t, HookAgent::Kimi).expect("install writes a fresh config.toml");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed);
let once = std::fs::read_to_string(&config).unwrap();
install_hooks(&t, HookAgent::Kimi).expect("re-install succeeds");
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
once,
"a second install is byte-for-byte the first — no churn, no growth"
);
// A hand-edit that drops `event` leaves an entry that is ours and is
// broken. That is Outdated, not NotInstalled: `refresh_hooks` only
// ever revisits Outdated, so anything else hides the damage.
let mangled = once.replacen("event = \"SessionStart\"\n", "", 1);
assert_ne!(mangled, once);
std::fs::write(&config, &mangled).unwrap();
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated);
install_hooks(&t, HookAgent::Kimi).expect("install repairs it");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed);
assert_eq!(std::fs::read_to_string(&config).unwrap(), once);
// One marked entry too many is Outdated too, and install prunes it.
// This one has no `event` at all, so counting only the entries that
// still name one would find the full roster and call it Installed
// while a broken ninth entry sat there.
let mut extra = std::fs::read_to_string(&config).unwrap();
extra.push_str("\n[[hooks]]\ncommand = \"tty7 agent-hook kimi stop\"\n");
std::fs::write(&config, &extra).unwrap();
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated);
install_hooks(&t, HookAgent::Kimi).expect("install prunes the stray");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed);
let doc: toml_edit::DocumentMut =
std::fs::read_to_string(&config).unwrap().parse().unwrap();
assert_eq!(
doc["hooks"]
.as_array_of_tables()
.unwrap()
.iter()
.filter(|e| toml_command_is_marked(e, marker))
.count(),
KIMI_HOOK_EVENTS.len()
);
// The user's own entry comes first and another table follows ours:
// uninstall has to take out the middle and leave both ends alone.
let t = HookTarget::remote(&*host, base.join("sandwich"));
let config = HookAgent::Kimi.target_path(&t);
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
let user_half = concat!(
"# mine\n",
"[[hooks]]\n",
"event = \"Stop\"\n",
"command = \"afplay a.aiff\" # ding\n",
"\n",
"[providers.moonshot]\n",
"api_key = \"secret\"\n",
);
std::fs::write(&config, user_half).unwrap();
install_hooks(&t, HookAgent::Kimi).expect("install");
assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed);
let merged = std::fs::read_to_string(&config).unwrap();
assert!(
merged.starts_with(
"# mine\n[[hooks]]\nevent = \"Stop\"\ncommand = \"afplay a.aiff\" # ding\n"
),
"the user's entry and its trailing comment come through verbatim:\n{merged}"
);
assert!(merged.contains("[providers.moonshot]\napi_key = \"secret\"\n"));
assert_eq!(
uninstall_hooks(&t, HookAgent::Kimi).unwrap(),
HookOutcome::Removed
);
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
user_half,
"uninstall puts the file back exactly as the user left it"
);
let _ = std::fs::remove_dir_all(&base);
}
/// Kimi's `Stop` does not fire when the user interrupts a turn or when one
/// dies on an error, so the pane would sit on "working" forever without
/// the two events that do.
#[test]
fn kimi_reports_every_way_a_turn_ends() {
for event in ["Stop", "Interrupt", "StopFailure"] {
assert_eq!(
KIMI_HOOK_EVENTS
.iter()
.find(|(hook_event, _)| *hook_event == event)
.map(|(_, tty7_event)| *tty7_event),
Some("stop"),
"{event} has to end the turn like Stop does"
);
}
assert!(
!KIMI_HOOK_EVENTS
.iter()
.any(|(hook_event, _)| *hook_event == "Notification"),
"Notification fires for background-task chatter and would strand the pane on waiting"
);
}
}
+82 -1
View File
@@ -22,10 +22,11 @@ pub enum CLIAgent {
Grok,
Qwen,
OhMyPi,
Kimi,
}
impl CLIAgent {
pub const ALL: [CLIAgent; 18] = [
pub const ALL: [CLIAgent; 19] = [
CLIAgent::Claude,
CLIAgent::Codex,
CLIAgent::Gemini,
@@ -44,6 +45,7 @@ impl CLIAgent {
CLIAgent::Grok,
CLIAgent::Qwen,
CLIAgent::OhMyPi,
CLIAgent::Kimi,
];
fn aliases(self) -> &'static [&'static str] {
@@ -72,6 +74,10 @@ impl CLIAgent {
// Oh My Pi is a fork of Pi, but it ships one binary of its own and
// never installs a `pi`, so the two names stay disjoint.
CLIAgent::OhMyPi => &["omp"],
// Both the standalone Kimi Code CLI and the legacy open-source
// kimi-cli install a `kimi` — same vendor, same brand, so one
// detection covers them. Only the standalone one has hooks.
CLIAgent::Kimi => &["kimi", "kimi-code"],
}
}
@@ -95,6 +101,7 @@ impl CLIAgent {
CLIAgent::Grok => "grok",
CLIAgent::Qwen => "qwen",
CLIAgent::OhMyPi => "omp",
CLIAgent::Kimi => "kimi",
}
}
@@ -123,6 +130,7 @@ impl CLIAgent {
CLIAgent::Grok => "Grok",
CLIAgent::Qwen => "Qwen Code",
CLIAgent::OhMyPi => "Oh My Pi",
CLIAgent::Kimi => "Kimi Code",
}
}
@@ -155,6 +163,7 @@ impl CLIAgent {
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")),
CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")),
CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")),
CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")),
_ => None,
}
}
@@ -342,6 +351,21 @@ impl CLIAgent {
// `--resume`, `-r` and `--session` are three spellings of one flag
// in Oh My Pi; `--session-dir` is a different one and survives.
CLIAgent::OhMyPi => &["--resume", "-r", "--session", "--fork", "--continue", "-c"],
// `--resume`/`-r` is Kimi's hidden alias for `--session`/`-S`.
// `--agent`/`--agent-file` bind the main agent at session creation
// and Kimi rejects either next to `--session` outright; resuming
// restores the bound agent by itself, so replaying them would only
// turn a working resume into a startup error.
CLIAgent::Kimi => &[
"--session",
"-S",
"--resume",
"-r",
"--continue",
"-c",
"--agent",
"--agent-file",
],
CLIAgent::Grok => &[
"--resume",
"-r",
@@ -414,6 +438,9 @@ impl CLIAgent {
CLIAgent::Grok => 0x000000,
CLIAgent::Qwen => 0x6D44E8,
CLIAgent::OhMyPi => 0xF97316,
// The blue of the flame in Kimi's brand mark; the glyph itself is
// black, which Codex and Grok already have covered.
CLIAgent::Kimi => 0x027AFF,
}
}
@@ -432,6 +459,7 @@ impl CLIAgent {
CLIAgent::Pi => "icons/agents/pi.svg",
CLIAgent::OhMyPi => "icons/agents/omp.svg",
CLIAgent::Qwen => "icons/agents/qwen.svg",
CLIAgent::Kimi => "icons/agents/kimi.svg",
CLIAgent::Aider
| CLIAgent::Auggie
| CLIAgent::Hermes
@@ -865,6 +893,8 @@ mod tests {
("hermes", CLIAgent::Hermes),
("omp", CLIAgent::OhMyPi),
("/opt/homebrew/bin/omp", CLIAgent::OhMyPi),
("kimi", CLIAgent::Kimi),
("/usr/local/bin/kimi", CLIAgent::Kimi),
] {
assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent));
}
@@ -1155,6 +1185,10 @@ mod tests {
.as_deref(),
Some("pi --session 0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17")
);
assert_eq!(
CLIAgent::Kimi.resume_command("abc-123", None).as_deref(),
Some("kimi --session abc-123")
);
assert_eq!(CLIAgent::Aider.resume_command("abc", None), None);
assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /", None), None);
assert_eq!(CLIAgent::Claude.resume_command("$(boom)", None), None);
@@ -1180,6 +1214,53 @@ mod tests {
.as_deref(),
Some("claude --model opus --resume abc")
);
assert_eq!(
CLIAgent::Kimi
.resume_command(
"abc-123",
Some(&argv(&["kimi", "--session", "old", "--yolo"]))
)
.as_deref(),
Some("kimi --yolo --session abc-123"),
"a stale --session flag comes off before the new one goes on"
);
assert_eq!(
CLIAgent::Kimi
.resume_command("abc-123", Some(&argv(&["kimi", "--session=old", "--yolo"])))
.as_deref(),
Some("kimi --yolo --session abc-123"),
"and so does the one-token spelling of it"
);
assert_eq!(
CLIAgent::Kimi
.resume_command(
"abc-123",
Some(&argv(&["kimi", "--resume", "--model", "kimi-k2"]))
)
.as_deref(),
Some("kimi --model kimi-k2 --session abc-123"),
"`--session` takes an optional id, so a bare one must not eat the flag after it"
);
assert_eq!(
CLIAgent::Kimi
.resume_command(
"abc-123",
Some(&argv(&["kimi", "--continue", "--model", "kimi-k2"]))
)
.as_deref(),
Some("kimi --model kimi-k2 --session abc-123"),
"`--continue` is mutually exclusive with `--session` and takes no value"
);
assert_eq!(
CLIAgent::Kimi
.resume_command(
"abc-123",
Some(&argv(&["kimi", "--agent", "reviewer", "--yolo"]))
)
.as_deref(),
Some("kimi --yolo --session abc-123"),
"Kimi rejects `--agent` next to `--session`, and resume rebinds the agent itself"
);
assert_eq!(
CLIAgent::Claude
.resume_command(
+4 -3
View File
@@ -1,6 +1,6 @@
---
title: "Coding agents"
description: "What tty7 does around Claude Code, Codex, and 16 others — without ever wrapping them."
description: "What tty7 does around Claude Code, Codex, and 17 others — without ever wrapping them."
---
tty7 recognises coding agents running in a pane and builds around them. It does
@@ -15,7 +15,7 @@ need, and what changed.
## Which agents
Eighteen CLIs are recognised on sight, by the command running in the pane:
Nineteen CLIs are recognised on sight, by the command running in the pane:
| Agent | Command |
|---|---|
@@ -31,6 +31,7 @@ Eighteen CLIs are recognised on sight, by the command running in the pane:
| Droid | `droid` |
| Grok | `grok` |
| Qwen Code | `qwen`, `qwen-code` |
| Kimi Code | `kimi`, `kimi-code` |
| Auggie | `auggie` |
| Hermes | `hermes` |
| Vibe | `vibe`, `vibe-acp` |
@@ -59,7 +60,7 @@ If you launch agents through a wrapper script, map its name to an agent in
The key is your command's name; the value is one of the slugs above (`claude`,
`codex`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`,
`droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`,
`omp`).
`omp`, `kimi`).
## What you get for free
+2 -2
View File
@@ -19,8 +19,8 @@ claude --dangerously-skip-permissions --resume 8f3c…
The original launch flags are replayed, so the pane comes back the way you
started it, not the way the defaults would.
Supported for Claude Code, Codex, Gemini, OpenCode, Amp, Cursor, Copilot, Grok,
Pi, and Oh My Pi. Turn it off with `restore_agent_sessions: false`.
Supported for every recognised agent except Aider. Turn it off with
`restore_agent_sessions: false`.
<Note>
Resume needs the agent's hooks installed, since the session id comes from
+2 -2
View File
@@ -14,8 +14,8 @@ the agent say which one it is.
| Agent | |
|---|---|
| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi | Hooks available |
| Gemini · Aider · Amp · Cursor · Goose · Droid · Auggie · Hermes · Vibe · Antigravity · Qwen Code | Detected and labelled, but no status channel yet |
| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code | Hooks available |
| Aider · Amp · Cursor · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet |
Installing writes into that agent's own configuration directory. Once installed
the row grows a second **Uninstall** button beside the first, which itself
+1 -1
View File
@@ -54,7 +54,7 @@ leave it off if you type accented characters.
## 4. If you use coding agents, install the hooks
**Settings → Agents.** tty7 detects 18 coding CLIs by process name on its own —
**Settings → Agents.** tty7 detects 19 coding CLIs by process name on its own —
you get brand avatars and tab labels for free. The *status dots*, the "needs
your permission" notifications, and `tty7 wait` all need one more thing: a small
hook the agent calls to report what it is doing.
+1 -1
View File
@@ -31,7 +31,7 @@ something floods the screen.
syntax highlighting, click-to-place-caret, real multi-line editing.
</Card>
<Card title="Agent-aware" icon="robot" href="/agents/overview">
18 coding CLIs are recognised on sight. Per-pane status dots, notifications
19 coding CLIs are recognised on sight. Per-pane status dots, notifications
when one needs you, git context, and session resume after a reboot.
</Card>
<Card title="Remote work that feels local" icon="server" href="/remote/workspaces">
+1
View File
@@ -60,6 +60,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"),
"icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"),
"icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"),
"icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"),
_ => return None,
};
Some(bytes)
+4
View File
@@ -718,6 +718,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsAgentDroid => "Droid",
L10nKey::SettingsAgentQwenCode => "Qwen Code",
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github",
L10nKey::SettingsSearchAppHttpProxyKeywords => {
"proxy http https socks socks5 clash v2ray network download update"
@@ -804,6 +805,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"agent integration hooks install qwen code qwen-code"
}
L10nKey::SettingsSearchGooseKeywords => "agent integration hooks plugin install goose",
L10nKey::SettingsSearchKimiCodeKeywords => {
"agent integration hooks install kimi code kimi-code moonshot"
}
L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi",
L10nKey::SettingsSearchPortForwardingKeywords => {
"ssh tunnel local remote dynamic socks forward rule"
+4
View File
@@ -727,6 +727,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentDroid => "Droid",
L10nKey::SettingsAgentQwenCode => "Qwen Code",
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
L10nKey::SettingsSearchAboutKeywords => {
"バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check"
}
@@ -855,6 +856,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchGooseKeywords => {
"エージェント 統合 フック プラグイン インストール goose agent integration hooks plugin install"
}
L10nKey::SettingsSearchKimiCodeKeywords => {
"エージェント 統合 フック インストール kimi code moonshot agent integration hooks install"
}
L10nKey::SettingsSearchPiKeywords => {
"エージェント 統合 拡張 インストール pi agent integration extension install"
}
+3
View File
@@ -573,6 +573,7 @@ l10n_keys! {
SettingsAgentDroid,
SettingsAgentQwenCode,
SettingsAgentGoose,
SettingsAgentKimiCode,
SettingsSearchAppHttpProxyKeywords,
SettingsSearchAboutKeywords,
SettingsSearchAutoDownloadKeywords,
@@ -611,6 +612,7 @@ l10n_keys! {
SettingsSearchItalicFontKeywords,
SettingsSearchKeybindingsKeywords,
SettingsSearchKeybindingsTitle,
SettingsSearchKimiCodeKeywords,
SettingsSearchLineHeightKeywords,
SettingsSearchNewTabPositionKeywords,
SettingsSearchNotifyOnCommandFinishKeywords,
@@ -1521,6 +1523,7 @@ mod tests {
L10nKey::SettingsAgentGemini,
L10nKey::SettingsAgentGoose,
L10nKey::SettingsAgentGrokBuild,
L10nKey::SettingsAgentKimiCode,
L10nKey::SettingsAgentOhMyPi,
L10nKey::SettingsAgentOpencode,
L10nKey::SettingsAgentPi,
+4
View File
@@ -634,6 +634,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentDroid => "Droid",
L10nKey::SettingsAgentQwenCode => "Qwen Code",
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
L10nKey::SettingsSearchAboutKeywords => {
"关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update"
}
@@ -760,6 +761,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchGooseKeywords => {
"Goose agent 集成 钩子 插件 安装 goose agent integration hooks plugin install"
}
L10nKey::SettingsSearchKimiCodeKeywords => {
"Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install"
}
L10nKey::SettingsSearchPiKeywords => {
"Pi agent 集成 扩展 安装 pi agent integration extension install"
}
+5
View File
@@ -628,6 +628,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsAgentGoose,
keywords: SettingsSearchGooseKeywords,
},
SearchEntry {
section: Agents,
title: SettingsAgentKimiCode,
keywords: SettingsSearchKimiCodeKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsStartupWindow,