mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge pull request #174 from l0ng-ai/feat/grok-hooks
feat(agents): wire the rich status channel into Grok Build
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<!-- Grok 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="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 874 B |
+1
-1
@@ -31,7 +31,7 @@ Codex, Gemini CLI, Aider, Amp, OpenCode, and ~10 more) and adds around them —
|
||||
it never wraps or replaces the agent.
|
||||
|
||||
- **Brand avatars** — the tab chip / sidebar row shows which agent runs where; custom wrappers map in via `agent_commands` in `config.json`
|
||||
- **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; run *Agent: Install Claude Code Hooks* from the palette to wire Claude Code up
|
||||
- **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; Settings → Agents installs the hooks that feed it (Claude Code, Codex, Copilot CLI, OpenCode, Pi, Grok Build)
|
||||
- **Notifications** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy
|
||||
- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N −M`), refreshed on `cd` and when a command finishes
|
||||
- **Session resume** — panes lost to a reboot re-launch their agent conversation on restore, carrying the original launch flags (`claude --dangerously-skip-permissions --resume …`) (`restore_agent_sessions`, on by default)
|
||||
|
||||
@@ -30,7 +30,7 @@ tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、G
|
||||
Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包裹或替代 agent 本身。
|
||||
|
||||
- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json` 的 `agent_commands` 映射
|
||||
- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code
|
||||
- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在 设置 → Agents 一键装好对应 hooks(Claude Code、Codex、Copilot CLI、OpenCode、Pi、Grok Build)
|
||||
- **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略
|
||||
- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新
|
||||
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …`;`restore_agent_sessions`,默认开启)
|
||||
|
||||
+197
-19
@@ -4,10 +4,10 @@
|
||||
//! The rich agent-status channel ([`crate::core::cli_agent`]) needs the agent
|
||||
//! itself to say what it's doing. Each supported agent exposes that
|
||||
//! differently — Claude Code and Codex take a declarative hooks map, Copilot
|
||||
//! auto-loads JSON hook files from a directory, OpenCode loads JS plugins, Pi
|
||||
//! loads TS extensions — but every integration bottoms out in the same tiny
|
||||
//! emitter: `tty7 agent-hook <agent> <event>` reads the hook's JSON payload
|
||||
//! from stdin and writes one sentinel OSC 777 sequence to the controlling
|
||||
//! and Grok auto-load JSON hook files from a directory, OpenCode loads JS
|
||||
//! plugins, Pi loads TS extensions — but every integration bottoms out in the
|
||||
//! same tiny emitter: `tty7 agent-hook <agent> <event>` reads the hook's JSON
|
||||
//! payload from stdin and writes one sentinel OSC 777 sequence to the controlling
|
||||
//! terminal, where tty7's daemon-side sniffer picks it up and folds it into
|
||||
//! the pane's session state.
|
||||
//!
|
||||
@@ -24,6 +24,12 @@ use crate::core::cli_agent::AGENT_EVENT_SENTINEL;
|
||||
/// write escape sequences into terminals that aren't tty7.
|
||||
pub const TTY7_ENV_MARKER: &str = "TTY7";
|
||||
|
||||
/// Env var Grok Build's hook runner injects into every hook process it spawns.
|
||||
/// Its presence identifies *who ran us*, which matters because grok also scans
|
||||
/// `~/.claude/settings.json` for hooks (its Claude-compat layer) — see
|
||||
/// [`run_agent_hook`].
|
||||
const GROK_HOOK_ENV: &str = "GROK_HOOK_EVENT";
|
||||
|
||||
/// Cap on how much hook stdin we'll read: real payloads are a few hundred
|
||||
/// bytes of JSON; anything huge is not for us.
|
||||
const MAX_STDIN: u64 = 64 * 1024;
|
||||
@@ -46,6 +52,7 @@ pub fn run_agent_hook(agent: &str, event: &str) {
|
||||
if std::env::var_os(TTY7_ENV_MARKER).is_none() {
|
||||
return;
|
||||
}
|
||||
let agent = effective_agent(agent, std::env::var_os(GROK_HOOK_ENV).is_some());
|
||||
// Hook payload: the agent writes JSON ({"session_id": …, "message": …, …})
|
||||
// and closes stdin. Absent/malformed input still emits the bare event —
|
||||
// the state machine works without ids or messages. A tty stdin means the
|
||||
@@ -82,18 +89,35 @@ fn detach_console() {
|
||||
#[cfg(unix)]
|
||||
fn detach_console() {}
|
||||
|
||||
/// The agent slug an invocation really speaks for. Normally the one the
|
||||
/// installed hook passed, but Grok Build reads `~/.claude/settings.json` as
|
||||
/// well as its own hooks directory (a deliberate Claude-compat layer), so a
|
||||
/// tty7 Claude Code integration also fires inside grok panes. Grok's hook
|
||||
/// runner stamps every hook process with [`GROK_HOOK_ENV`], so those events are
|
||||
/// relabeled to the agent that actually ran them — otherwise a grok pane
|
||||
/// reports "Claude Code", and a user with both integrations installed emits
|
||||
/// each turn under two identities instead of one deduplicated stream.
|
||||
fn effective_agent(agent: &str, ran_by_grok: bool) -> &str {
|
||||
if ran_by_grok { "grok" } else { agent }
|
||||
}
|
||||
|
||||
/// The sentinel event one hook invocation maps onto, or `None` to stay silent.
|
||||
/// Most hooks pass their event through; the exception is Copilot's single
|
||||
/// `notification` hook, which fires for *every* notification type — only
|
||||
/// permission/elicitation prompts are the amber "needs you" moment, so those
|
||||
/// are escalated to `permission-request` and everything else is dropped
|
||||
/// rather than parroted as a block.
|
||||
/// Most hooks pass their event through; the exceptions are the single catch-all
|
||||
/// `notification` hook Copilot and Grok expose, which fires for *every*
|
||||
/// notification type. Only the types that always mean a real block escalate to
|
||||
/// `permission-request`; everything else is dropped rather than parroted as one.
|
||||
///
|
||||
/// The two agents draw that line differently. Copilot's `permission_prompt`
|
||||
/// only fires when it is actually asking, so it counts; grok dispatches the
|
||||
/// same type *before* its permission system decides — on essentially every tool
|
||||
/// call, auto-approved ones included — so only `elicitation_dialog` (grok
|
||||
/// asking the user a question) survives there. Grok's completions and errors
|
||||
/// (`task_complete`, `agent_error`) are never blocks for either.
|
||||
fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> {
|
||||
if agent == "copilot" && event == "notification" {
|
||||
if stdin_json.contains("permission_prompt") || stdin_json.contains("elicitation_dialog") {
|
||||
return Some("permission-request");
|
||||
}
|
||||
return None;
|
||||
if matches!(agent, "copilot" | "grok") && event == "notification" {
|
||||
let blocks = stdin_json.contains("elicitation_dialog")
|
||||
|| (agent == "copilot" && stdin_json.contains("permission_prompt"));
|
||||
return blocks.then_some("permission-request");
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
@@ -110,9 +134,19 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
|
||||
"agent": agent,
|
||||
"event": event,
|
||||
});
|
||||
for key in ["session_id", "message", "cwd"] {
|
||||
// The sentinel body is always snake_case, but the payloads are not: Claude
|
||||
// Code & friends send `session_id`, while Grok Build's envelope is
|
||||
// camelCase throughout (`sessionId`). Read both spellings of each field so
|
||||
// one vendor's convention doesn't cost us the session id that `--resume`
|
||||
// needs.
|
||||
for (key, alias) in [
|
||||
("session_id", "sessionId"),
|
||||
("message", "message"),
|
||||
("cwd", "cwd"),
|
||||
] {
|
||||
if let Some(v) = payload
|
||||
.get(key)
|
||||
.or_else(|| payload.get(alias))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
@@ -319,15 +353,19 @@ pub enum HookAgent {
|
||||
OpenCode,
|
||||
/// A tty7-owned TS extension in `~/.pi/agent/extensions/tty7/`.
|
||||
Pi,
|
||||
/// A tty7-owned hook file in `~/.grok/hooks/` (Grok Build loads every JSON
|
||||
/// file there, and global hooks need no folder-trust grant).
|
||||
Grok,
|
||||
}
|
||||
|
||||
impl HookAgent {
|
||||
pub const ALL: [HookAgent; 5] = [
|
||||
pub const ALL: [HookAgent; 6] = [
|
||||
HookAgent::Claude,
|
||||
HookAgent::Codex,
|
||||
HookAgent::Copilot,
|
||||
HookAgent::OpenCode,
|
||||
HookAgent::Pi,
|
||||
HookAgent::Grok,
|
||||
];
|
||||
|
||||
/// The `agent` slug in hook commands and sentinel events — matches
|
||||
@@ -339,6 +377,7 @@ impl HookAgent {
|
||||
HookAgent::Copilot => "copilot",
|
||||
HookAgent::OpenCode => "opencode",
|
||||
HookAgent::Pi => "pi",
|
||||
HookAgent::Grok => "grok",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +389,7 @@ impl HookAgent {
|
||||
HookAgent::Copilot => "Copilot CLI",
|
||||
HookAgent::OpenCode => "OpenCode",
|
||||
HookAgent::Pi => "Pi",
|
||||
HookAgent::Grok => "Grok Build",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +426,12 @@ impl HookAgent {
|
||||
.join("tty7")
|
||||
.join("index.ts"),
|
||||
),
|
||||
HookAgent::Grok => Some(
|
||||
home_dir()?
|
||||
.join(".grok")
|
||||
.join("hooks")
|
||||
.join(OWNED_FILE_STEM_JSON),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +467,7 @@ pub fn hooks_state(agent: HookAgent) -> HooksState {
|
||||
match agent {
|
||||
HookAgent::Claude => hook_map_state(&path, agent, CLAUDE_HOOK_EVENTS),
|
||||
HookAgent::Codex => hook_map_state(&path, agent, CODEX_HOOK_EVENTS),
|
||||
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => {
|
||||
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => {
|
||||
let Some(expected) = owned_file_content(agent) else {
|
||||
return HooksState::NotInstalled;
|
||||
};
|
||||
@@ -457,7 +503,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
|
||||
),
|
||||
})
|
||||
}
|
||||
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => {
|
||||
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => {
|
||||
let content = owned_file_content(agent)
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?;
|
||||
owned_file_install(&path, &content, &agent.marker())?;
|
||||
@@ -475,7 +521,7 @@ pub fn uninstall_hooks(agent: HookAgent) -> anyhow::Result<String> {
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?;
|
||||
match agent {
|
||||
HookAgent::Claude | HookAgent::Codex => hook_map_uninstall(&path, agent),
|
||||
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => {
|
||||
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => {
|
||||
owned_file_uninstall(&path, &agent.marker())
|
||||
}
|
||||
}
|
||||
@@ -603,6 +649,39 @@ const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[
|
||||
("Stop", "stop"),
|
||||
];
|
||||
|
||||
/// Seconds grok gives one tty7 hook before killing it. Set explicitly for two
|
||||
/// reasons: it keeps `Stop` off grok's 600-second gate default (that budget is
|
||||
/// for hooks that run test suites; ours writes a few bytes and returns), and it
|
||||
/// leaves headroom for the first hook of a session, which pays the cold-start
|
||||
/// cost of paging in the tty7 binary. A killed hook only loses that one event —
|
||||
/// the session id arrives again on the next `UserPromptSubmit`.
|
||||
const GROK_HOOK_TIMEOUT_SECS: u32 = 10;
|
||||
|
||||
/// Grok Build's hook events — Claude Code's vocabulary, because grok mirrors it
|
||||
/// deliberately (it even reads `~/.claude/settings.json`) — plus the matcher
|
||||
/// regex each subscription is narrowed by (grok tests it against the event's
|
||||
/// own discriminator: the notification type on `Notification`, the tool name on
|
||||
/// tool events, …). Written as an owned file rather than merged into a shared
|
||||
/// one; see [`grok_hooks_json`].
|
||||
///
|
||||
/// `Notification` is the one narrowed subscription. Grok dispatches its
|
||||
/// `permission_prompt` notification *before* the permission system decides, so
|
||||
/// it fires on essentially every tool call, auto-approved ones included —
|
||||
/// escalating that to the amber "needs you" state would flash the pane (and
|
||||
/// fire a desktop notification) on every tool a turn runs. `elicitation_dialog`
|
||||
/// — grok's ask-the-user question — is the type that always means a real block,
|
||||
/// so it is the only one subscribed. The emitter re-checks this (see
|
||||
/// [`effective_event`]), which is what covers the same events arriving through
|
||||
/// grok's Claude-compat scan, where the matcher isn't ours to set.
|
||||
const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[
|
||||
("SessionStart", "session-start", None),
|
||||
("UserPromptSubmit", "prompt-submit", None),
|
||||
("Notification", "notification", Some("elicitation_dialog")),
|
||||
("PostToolUse", "tool-complete", None),
|
||||
("Stop", "stop", None),
|
||||
("SessionEnd", "session-end", None),
|
||||
];
|
||||
|
||||
fn hook_map_state(path: &Path, agent: HookAgent, events: &[(&str, &str)]) -> HooksState {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return HooksState::NotInstalled;
|
||||
@@ -789,6 +868,7 @@ fn owned_file_content(agent: HookAgent) -> Option<String> {
|
||||
HookAgent::Copilot => copilot_hooks_json(),
|
||||
HookAgent::OpenCode => opencode_plugin_js(),
|
||||
HookAgent::Pi => pi_extension_ts(),
|
||||
HookAgent::Grok => grok_hooks_json(),
|
||||
HookAgent::Claude | HookAgent::Codex => None,
|
||||
}
|
||||
}
|
||||
@@ -875,6 +955,31 @@ fn copilot_hooks_json() -> Option<String> {
|
||||
serde_json::to_string_pretty(&root).ok()
|
||||
}
|
||||
|
||||
/// Grok Build hook file (`~/.grok/hooks/tty7.json`). Grok loads every JSON file
|
||||
/// in that directory and global hooks are always trusted (project hooks need a
|
||||
/// folder-trust grant; ours don't), so tty7 owns its own file and never touches
|
||||
/// the user's. Both the schema and the event names are Claude Code's — grok
|
||||
/// mirrors them deliberately — so this is the same wiring as
|
||||
/// [`CLAUDE_HOOK_EVENTS`] in owned-file form; see [`GROK_HOOK_EVENTS`] for the
|
||||
/// table and why `Notification` carries a matcher.
|
||||
fn grok_hooks_json() -> Option<String> {
|
||||
let mut hooks = serde_json::Map::new();
|
||||
for (event, sentinel, matcher) in GROK_HOOK_EVENTS {
|
||||
let mut group = serde_json::json!({
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": hook_command(HookAgent::Grok, sentinel)?,
|
||||
"timeout": GROK_HOOK_TIMEOUT_SECS,
|
||||
}]
|
||||
});
|
||||
if let Some(matcher) = matcher {
|
||||
group["matcher"] = serde_json::Value::String((*matcher).to_string());
|
||||
}
|
||||
hooks.insert((*event).to_string(), serde_json::json!([group]));
|
||||
}
|
||||
serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok()
|
||||
}
|
||||
|
||||
/// OpenCode plugin (`~/.config/opencode/plugins/tty7.js`). OpenCode has no
|
||||
/// declarative hooks — its extensibility surface is JS plugins auto-loaded
|
||||
/// from that directory — so the plugin bridges its events onto the same
|
||||
@@ -985,6 +1090,31 @@ mod tests {
|
||||
let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("bare event still parses");
|
||||
assert_eq!(ev.kind, AgentEventKind::Stop);
|
||||
assert_eq!(ev.session_id, None);
|
||||
|
||||
// Grok's envelope is camelCase throughout; the same fields must land in
|
||||
// the snake_case sentinel body, or restore loses the id `--resume` needs.
|
||||
let seq = build_hook_sequence(
|
||||
"grok",
|
||||
"session-start",
|
||||
r#"{"hookEventName":"session_start","sessionId":"g-42","cwd":"/w"}"#,
|
||||
);
|
||||
let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("daemon parses the grok event");
|
||||
assert_eq!(ev.agent, Some(CLIAgent::Grok));
|
||||
assert_eq!(ev.session_id.as_deref(), Some("g-42"));
|
||||
assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w")));
|
||||
}
|
||||
|
||||
/// Grok reads `~/.claude/settings.json` too, so a tty7 Claude Code
|
||||
/// integration fires inside grok panes. Those invocations must speak as
|
||||
/// grok — otherwise the pane reports the wrong agent, and having both
|
||||
/// integrations installed emits every turn twice under two identities.
|
||||
#[test]
|
||||
fn grok_run_hooks_are_relabeled_to_grok() {
|
||||
assert_eq!(effective_agent("claude", true), "grok");
|
||||
assert_eq!(effective_agent("grok", true), "grok");
|
||||
// Outside grok's hook runner nothing is rewritten.
|
||||
assert_eq!(effective_agent("claude", false), "claude");
|
||||
assert_eq!(effective_agent("grok", false), "grok");
|
||||
}
|
||||
|
||||
/// Every event name any installer writes must be one the daemon's parser
|
||||
@@ -997,6 +1127,7 @@ mod tests {
|
||||
.iter()
|
||||
.chain(CODEX_HOOK_EVENTS)
|
||||
.map(|(_, e)| *e)
|
||||
.chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e))
|
||||
.collect();
|
||||
// Owned-file integrations embed their events in generated source.
|
||||
events.extend([
|
||||
@@ -1037,12 +1168,37 @@ mod tests {
|
||||
effective_event("copilot", "notification", r#"{"type":"turn_summary"}"#),
|
||||
None
|
||||
);
|
||||
// Grok's catch-all Notification hook is filtered harder: only its
|
||||
// ask-the-user question is reliably a block. `permission_prompt` fires
|
||||
// ahead of the permission decision — verified against grok 0.2.112,
|
||||
// where an auto-approved `list_dir` emitted one — so escalating it
|
||||
// would flash amber on every tool call.
|
||||
assert_eq!(
|
||||
effective_event(
|
||||
"grok",
|
||||
"notification",
|
||||
r#"{"notificationType":"elicitation_dialog","message":"User question requested"}"#
|
||||
),
|
||||
Some("permission-request")
|
||||
);
|
||||
for noisy in ["permission_prompt", "task_complete", "agent_error"] {
|
||||
assert_eq!(
|
||||
effective_event(
|
||||
"grok",
|
||||
"notification",
|
||||
&format!(r#"{{"notificationType":"{noisy}"}}"#)
|
||||
),
|
||||
None,
|
||||
"grok {noisy} is not a block"
|
||||
);
|
||||
}
|
||||
// Other agents and events pass through untouched.
|
||||
assert_eq!(
|
||||
effective_event("claude", "notification", "{}"),
|
||||
Some("notification")
|
||||
);
|
||||
assert_eq!(effective_event("copilot", "stop", "{}"), Some("stop"));
|
||||
assert_eq!(effective_event("grok", "stop", "{}"), Some("stop"));
|
||||
}
|
||||
|
||||
/// The controlling-tty fallback (`ancestor_tty_device`) is what makes the
|
||||
@@ -1124,6 +1280,28 @@ mod tests {
|
||||
assert!(pi.contains("agent-hook pi"));
|
||||
assert!(pi.contains(&exe));
|
||||
assert!(pi.contains(r#"process.env["TTY7"]"#));
|
||||
|
||||
let grok = grok_hooks_json().expect("grok content builds");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&grok).expect("valid JSON");
|
||||
for (event, sentinel, matcher) in GROK_HOOK_EVENTS {
|
||||
let group = &parsed["hooks"][*event][0];
|
||||
let cmd = group["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("grok {event} carries a command"));
|
||||
assert!(
|
||||
cmd.ends_with(&format!("agent-hook grok {sentinel}")),
|
||||
"grok {event} runs the emitter with {sentinel}, got {cmd}"
|
||||
);
|
||||
// A narrowed subscription must carry its matcher — without it grok
|
||||
// fires the hook for every notification type, which is the amber
|
||||
// flash this integration exists to avoid.
|
||||
assert_eq!(
|
||||
group.get("matcher").and_then(|m| m.as_str()),
|
||||
*matcher,
|
||||
"grok {event} matcher"
|
||||
);
|
||||
}
|
||||
assert!(grok.contains(&exe));
|
||||
}
|
||||
|
||||
/// Owned-file lifecycle against a scratch path: install → Installed,
|
||||
|
||||
+63
-5
@@ -211,6 +211,9 @@ impl CLIAgent {
|
||||
// Copilot CLI: `copilot --resume <sessionId>` (`-r` shorthand) —
|
||||
// the one hooks-covered agent that was missing from this table.
|
||||
CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")),
|
||||
// Grok Build: `grok --resume <id-or-title>`; a UUID-shaped value
|
||||
// always takes the id path, which is what its hooks report.
|
||||
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -268,6 +271,27 @@ impl CLIAgent {
|
||||
// `--last` targets "the most recent session" and would contradict
|
||||
// the explicit id we inject.
|
||||
CLIAgent::Codex => &["--last"],
|
||||
// Beyond the session-targeting flags (`--load` is grok's hidden
|
||||
// alias for `--resume`; `--session-id` names a *new* session and
|
||||
// `--fork-session` would branch off the one we mean to continue),
|
||||
// the worktree pair goes too: `--worktree` with no value mints a
|
||||
// fresh git worktree on every relaunch, and `--worktree-ref`
|
||||
// requires `--worktree`, so leaving it behind would make grok
|
||||
// reject the resume outright.
|
||||
CLIAgent::Grok => &[
|
||||
"--resume",
|
||||
"-r",
|
||||
"--load",
|
||||
"--continue",
|
||||
"-c",
|
||||
"--session-id",
|
||||
"-s",
|
||||
"--fork-session",
|
||||
"--worktree",
|
||||
"-w",
|
||||
"--worktree-ref",
|
||||
"--ref",
|
||||
],
|
||||
_ => &[],
|
||||
};
|
||||
let mut i = 0;
|
||||
@@ -314,8 +338,11 @@ impl CLIAgent {
|
||||
|
||||
/// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility
|
||||
/// on both light and dark themes rather than exact brand black/white. A pure
|
||||
/// black or white dot vanishes against one theme, so monochrome vendors get
|
||||
/// a recognizable mid-tone hue instead; Codex keeps its black field.
|
||||
/// *white* field vanishes against a light theme, so vendors whose mark is a
|
||||
/// grey or gradient monochrome (Cursor) get a recognizable mid-tone hue
|
||||
/// instead. A black field is a different case: it stays darker than even the
|
||||
/// darkest theme background and the white mark on it carries the badge, so
|
||||
/// vendors who actually brand in black (Codex, Grok) keep it.
|
||||
pub fn accent_rgb(self) -> u32 {
|
||||
match self {
|
||||
CLIAgent::Claude => 0xD97757, // Claude terracotta
|
||||
@@ -333,7 +360,7 @@ impl CLIAgent {
|
||||
CLIAgent::Hermes => 0x8B5CF6, // violet
|
||||
CLIAgent::Vibe => 0xFF7000, // Mistral orange
|
||||
CLIAgent::Antigravity => 0x2563EB, // Google blue (darker than Gemini's)
|
||||
CLIAgent::Grok => 0x64748B, // xAI is monochrome → slate
|
||||
CLIAgent::Grok => 0x000000, // xAI brands in black
|
||||
CLIAgent::Qwen => 0x7C3AED, // Qwen purple
|
||||
}
|
||||
}
|
||||
@@ -355,6 +382,7 @@ impl CLIAgent {
|
||||
CLIAgent::Cursor => "icons/agents/cursor.svg",
|
||||
CLIAgent::Goose => "icons/agents/goose.svg",
|
||||
CLIAgent::Droid => "icons/agents/droid.svg",
|
||||
CLIAgent::Grok => "icons/agents/grok.svg",
|
||||
// No brand mark bundled → generic robot glyph.
|
||||
CLIAgent::Aider
|
||||
| CLIAgent::Pi
|
||||
@@ -362,7 +390,6 @@ impl CLIAgent {
|
||||
| CLIAgent::Hermes
|
||||
| CLIAgent::Vibe
|
||||
| CLIAgent::Antigravity
|
||||
| CLIAgent::Grok
|
||||
| CLIAgent::Qwen => "icons/bot.svg",
|
||||
}
|
||||
}
|
||||
@@ -916,9 +943,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two vendors who actually brand in black keep the black field rather
|
||||
/// than the mid-tone substitute monochrome marks otherwise get.
|
||||
#[test]
|
||||
fn codex_avatar_uses_its_black_brand_field() {
|
||||
fn black_branded_avatars_keep_their_brand_field() {
|
||||
assert_eq!(CLIAgent::Codex.accent_rgb(), 0x000000);
|
||||
assert_eq!(CLIAgent::Grok.accent_rgb(), 0x000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1371,6 +1401,34 @@ mod tests {
|
||||
CLIAgent::Copilot.resume_command("s-9", None).as_deref(),
|
||||
Some("copilot --resume s-9")
|
||||
);
|
||||
// Grok: mode flags survive, and every way of naming another session is
|
||||
// stripped so the injected id is the only target left.
|
||||
assert_eq!(
|
||||
CLIAgent::Grok
|
||||
.resume_command("g-2", Some(&argv(&["grok", "--model", "grok-code"])))
|
||||
.as_deref(),
|
||||
Some("grok --model grok-code --resume g-2")
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Grok
|
||||
.resume_command(
|
||||
"g-2",
|
||||
Some(&argv(&["grok", "--resume", "g-1", "--fork-session"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("grok --resume g-2")
|
||||
);
|
||||
// `--worktree` would mint a fresh git worktree on every restore, and
|
||||
// `--worktree-ref` can't survive without it.
|
||||
assert_eq!(
|
||||
CLIAgent::Grok
|
||||
.resume_command(
|
||||
"g-3",
|
||||
Some(&argv(&["grok", "-w", "--worktree-ref", "main", "--yolo"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("grok --yolo --resume g-3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -151,6 +151,11 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
|
||||
"icons/agents/cursor.svg" => include_bytes!("../../assets/icons/agents/cursor.svg"),
|
||||
"icons/agents/goose.svg" => include_bytes!("../../assets/icons/agents/goose.svg"),
|
||||
"icons/agents/droid.svg" => include_bytes!("../../assets/icons/agents/droid.svg"),
|
||||
// The one mark not taken from the vendor directly: xAI publishes its
|
||||
// symbol only as a ~2:1 landscape lockup that turns to mush as a 16px
|
||||
// silhouette, so this is lobehub/lobe-icons' square transcription (MIT),
|
||||
// drawn for exactly this avatar use. Its notice rides in the SVG.
|
||||
"icons/agents/grok.svg" => include_bytes!("../../assets/icons/agents/grok.svg"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(bytes)
|
||||
@@ -181,6 +186,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every agent avatar must resolve to real bytes. Adding a brand mark means
|
||||
/// touching two files — the SVG and the arm above — and forgetting the
|
||||
/// second one costs the agent its avatar with nothing to show for it.
|
||||
#[test]
|
||||
fn every_agent_icon_resolves() {
|
||||
for agent in crate::core::cli_agent::CLIAgent::ALL {
|
||||
let path = agent.icon_path();
|
||||
assert!(
|
||||
Assets.load(path).unwrap().is_some(),
|
||||
"{} points at {path}, which nothing serves",
|
||||
agent.display_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `stock/` path for a glyph tty7 never overrode still has to resolve —
|
||||
/// the prefix is a bypass, not a separate asset set.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user