Merge branch 'pr-840' into release/2.5

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AkitaOnRails
2026-09-22 15:05:45 -03:00
16 changed files with 599 additions and 40 deletions
+14
View File
@@ -33,6 +33,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Documented producer provenance and stable retries through the existing hook
ingestion API, without changing its schema or standalone defaults. (#821)
### Changed
- Grok Build CLI shows a pending handoff, and an opted-in `[briefing]`, as
`PostToolUse` `additionalContext` on the first tool of a session.
`SessionStart` and `UserPromptSubmit` still do not accept the handoff (Grok
discards that stdout); a session that never calls a tool leaves the handoff
open for `memory_handoff_accept`. The note is clipped to 10,000 characters
(Grok's own cap). Because Grok reuses one session id across a
SessionEnd→restart, `memory_handoff_accept` now reopens an already-ended
receiver session (clears `ended_at`) instead of rejecting it — but only after
the exactly-once claim guard, so a session that already took a baton still
cannot take another (the multi-session claim-once invariant is preserved).
Delivery of this PostToolUse handoff is exempt from `AI_MEMORY_CAPTURE_OWNER`
capture suppression, like the other context-delivery events. (#840)
### Fixed
- `ai-memory serve` no longer leaked file descriptors from half-open HTTP
connections until `EMFILE`, breaking the healthcheck (an unauthenticated
+350 -7
View File
@@ -460,6 +460,55 @@ fn write_success_response<W: std::io::Write>(
}
}
const GROK_ADDITIONAL_CONTEXT_CHARS: usize = 10_000;
fn grok_post_tool_handoff_envelope(handoff: &str) -> serde_json::Value {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": clip_chars(handoff, GROK_ADDITIONAL_CONTEXT_CHARS),
}
})
}
fn payload_is_subagent(raw: &serde_json::Value) -> bool {
[
"subagentType",
"subagent_type",
"agent_type",
"agent_id",
"parentSessionId",
]
.iter()
.any(|key| {
raw.get(*key)
.and_then(|value| value.as_str())
.is_some_and(|text| !text.trim().is_empty())
})
}
fn clip_chars(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let keep = max_chars.saturating_sub(12);
let mut out: String = text.chars().take(keep).collect();
out.push_str("\n[truncated]");
out
}
fn handoff_shown_path(
data_dir: &Path,
agent: &str,
session_id: Option<&str>,
cwd: Option<&str>,
) -> PathBuf {
let key = briefed_marker_path(data_dir, agent, session_id, cwd);
data_dir
.join("handoff-shown")
.join(key.file_name().unwrap_or_default())
}
fn session_start_handoff_envelope(agent: AgentKind, handoff: String) -> serde_json::Value {
if agent == AgentKind::AntigravityCli {
serde_json::json!({
@@ -514,7 +563,11 @@ where
let external_capture = env_lookup(CAPTURE_OWNER_ENV).is_some_and(|v| !v.trim().is_empty());
let delivers_context = (hook_event == HookEvent::SessionStart
&& agent_kind.session_start_injects_handoff())
|| (hook_event == HookEvent::UserPrompt && agent_kind.user_prompt_injects_handoff());
|| (hook_event == HookEvent::UserPrompt && agent_kind.user_prompt_injects_handoff())
// Grok delivers the handoff on the first PostToolUse (its SessionStart /
// UserPromptSubmit stdout is discarded); that path is context delivery
// too, so external capture must not suppress it.
|| (hook_event == HookEvent::PostToolUse && agent_kind.post_tool_injects_handoff());
if external_capture && !args.check_capture && !delivers_context {
// Retiring the fallback session ID is lifecycle housekeeping, not
// capture. Preserve it even when no event is enqueued.
@@ -789,12 +842,12 @@ where
}
}
// user-prompt: agents whose SessionStart stdout is discarded (Kimi Code)
// receive the handoff here instead — kimi injects UserPromptSubmit stdout
// into the turn verbatim as a `hook_result` user message. The payload
// carries the native session id when available, so the destructive GET
// can also link the managed run to the native session, same as
// session-start does.
// user-prompt: agents whose SessionStart stdout is discarded AND whose
// UserPromptSubmit stdout is injected (Kimi Code) receive the handoff
// here. Grok discards both, so it must not take this path: the GET
// accepts the handoff. The payload carries the native session id when
// available, so the destructive GET can also link the managed run to the
// native session, same as session-start does.
// The installed kimi hook passes the script stem (`user-prompt-submit`)
// while the legacy shell path posts `user-prompt`; HookEvent::parse
// canonicalizes both (and the snake/native spellings) to UserPrompt.
@@ -864,6 +917,46 @@ where
return Ok(());
}
// post-tool-use: Grok shows hookSpecificOutput.additionalContext to the
// model after the tool result. SessionStart and UserPromptSubmit stdout
// are discarded, so this is the event that can carry a handoff without
// burning it. One fetch per session. A session that never calls a tool
// leaves the handoff open for memory_handoff_accept.
if HookEvent::parse(&args.event) == HookEvent::PostToolUse
&& AgentKind::from_wire(&args.agent).post_tool_injects_handoff()
{
let shown = handoff_shown_path(
&dd,
&args.agent,
canonical_session_id.as_deref(),
policy_cwd.as_deref(),
);
if !shown.is_file() && !payload_is_subagent(&json) {
let client = build_client();
let bearer = hook_spool::resolve_bearer(&client, &dd, effective_token).await;
let native_session_qs = canonical_session_id
.as_deref()
.map_or_else(String::new, |session_id| {
format!("&session_id={}", url_encode(session_id))
});
let handoff_url = format!(
"{base}/handoff?agent={}{qs}{managed_qs}{native_session_qs}",
args.agent
);
let handoff =
get_handoff(&client, &handoff_url, bearer.as_deref(), handoff_timeout()).await;
// Only a real body consumes this session's one chance. An empty
// or failed claim must retry on the next parent tool; a child
// session that errors must not burn the baton for the parent.
if let Some(handoff) = handoff {
mark_briefed(&shown);
let envelope = grok_post_tool_handoff_envelope(&handoff);
writeln!(stdout, "{envelope}")?;
return Ok(());
}
}
}
// Boundary drain trigger: enqueue first, then ask a detached native drainer
// to flush the shared spool. `session-end` remains the primary close path,
// but `stop` and `pre-compact` also trigger the helper so delivery does not
@@ -2452,6 +2545,19 @@ mod tests {
}
}
fn grok_hook_args(event: &str, server_url: &str) -> HookArgs {
HookArgs {
event: event.into(),
agent: "grok".into(),
server_url: server_url.into(),
auth_token: None,
project_strategy: None,
check_capture: false,
capture_assistant: false,
capture_mode: None,
}
}
fn kimi_hook_args(event: &str, server_url: &str) -> HookArgs {
HookArgs {
event: event.into(),
@@ -2930,6 +3036,243 @@ mod tests {
assert!(!data_dir.join("briefed").exists());
}
#[tokio::test]
async fn grok_session_start_never_fetches_the_handoff() {
let tmp = tempfile::tempdir().unwrap();
let (base, mut requests) = serve_requests("200 OK", "AMWS-HANDOFF-DELTA").await;
let mut stdout = Vec::new();
run_with_payload(
Some(tmp.path().to_path_buf()),
grok_hook_args("session-start", &base),
serde_json::json!({"session_id": "grok-session", "cwd": tmp.path()}).to_string(),
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
assert_eq!(stdout, b"{}\n");
while let Some(request) = first_request(&mut requests).await {
assert!(!request.starts_with("GET /handoff"), "{request}");
}
}
#[tokio::test]
async fn grok_user_prompt_does_not_fetch_the_handoff() {
let tmp = tempfile::tempdir().unwrap();
let (base, mut requests) = serve_requests("200 OK", "AMWS-HANDOFF-DELTA").await;
let mut stdout = Vec::new();
run_with_payload(
Some(tmp.path().to_path_buf()),
grok_hook_args("user-prompt", &base),
serde_json::json!({
"session_id": "grok-session",
"cwd": tmp.path(),
"prompt": "hello"
})
.to_string(),
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
// Grok discards allowing UserPromptSubmit stdout. Fetching would
// accept the handoff and then throw the body away.
assert_eq!(stdout, b"{}\n");
while let Some(request) = first_request(&mut requests).await {
assert!(
!request.starts_with("GET /handoff"),
"grok user-prompt must not accept the handoff: {request}"
);
}
}
#[tokio::test]
async fn grok_user_prompt_submit_stem_does_not_fetch_the_handoff() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().join("data");
let cwd = tmp.path().join("repo");
std::fs::create_dir(&cwd).unwrap();
write_briefing_marker(&cwd);
let (base, mut requests) = serve_requests("200 OK", "AMWS-HANDOFF-DELTA").await;
let mut stdout = Vec::new();
run_with_payload(
Some(data_dir),
grok_hook_args("user-prompt-submit", &base),
serde_json::json!({
"session_id": "grok-session",
"cwd": cwd,
"prompt": "hi"
})
.to_string(),
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
assert_eq!(stdout, b"{}\n");
while let Some(request) = first_request(&mut requests).await {
assert!(
!request.starts_with("GET /handoff"),
"briefing opt-in must not make grok fetch /handoff: {request}"
);
}
}
#[tokio::test]
async fn grok_post_tool_prints_additional_context_once() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().join("data");
let (base, mut requests) = serve_requests("200 OK", "AMWS-HANDOFF-DELTA").await;
let payload = serde_json::json!({
"session_id": "grok-session",
"cwd": tmp.path(),
"tool_name": "read_file"
})
.to_string();
let mut stdout = Vec::new();
run_with_payload(
Some(data_dir.clone()),
grok_hook_args("post-tool-use", &base),
payload.clone(),
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
let envelope: serde_json::Value = serde_json::from_slice(stdout.trim_ascii()).unwrap();
assert_eq!(
envelope["hookSpecificOutput"]["hookEventName"],
"PostToolUse"
);
assert_eq!(
envelope["hookSpecificOutput"]["additionalContext"],
"AMWS-HANDOFF-DELTA"
);
let first = first_request(&mut requests).await.unwrap();
assert!(first.starts_with("GET /handoff?"), "{first}");
assert!(first.contains("agent=grok"), "{first}");
assert!(first.contains("session_id=grok-session"), "{first}");
assert!(
data_dir
.join("handoff-shown")
.join("grok-session")
.is_file(),
"shown marker missing"
);
let mut stdout = Vec::new();
run_with_payload(
Some(data_dir),
grok_hook_args("post-tool-use", &base),
payload,
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
assert_eq!(stdout, b"{}\n");
assert!(
first_request(&mut requests).await.is_none(),
"second post-tool must not fetch again"
);
}
#[tokio::test]
async fn grok_post_tool_includes_briefing_when_the_marker_opts_in() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().join("data");
let cwd = tmp.path().join("repo");
std::fs::create_dir(&cwd).unwrap();
write_briefing_marker(&cwd);
let (base, mut requests) = serve_requests("200 OK", "AMWS-HANDOFF-DELTA").await;
let mut stdout = Vec::new();
run_with_payload(
Some(data_dir),
grok_hook_args("post-tool-use", &base),
serde_json::json!({
"session_id": "grok-session",
"cwd": cwd,
"tool_name": "read_file"
})
.to_string(),
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
let request = first_request(&mut requests).await.unwrap();
assert!(request.contains("&briefing=true"), "{request}");
assert!(request.contains("&briefing_budget=6000"), "{request}");
let envelope: serde_json::Value = serde_json::from_slice(stdout.trim_ascii()).unwrap();
assert_eq!(
envelope["hookSpecificOutput"]["additionalContext"],
"AMWS-HANDOFF-DELTA"
);
}
#[tokio::test]
async fn grok_post_tool_skips_a_subagent_payload() {
let tmp = tempfile::tempdir().unwrap();
let (base, mut requests) = serve_requests("200 OK", "AMWS-HANDOFF-DELTA").await;
let mut stdout = Vec::new();
run_with_payload(
Some(tmp.path().join("data")),
grok_hook_args("post-tool-use", &base),
serde_json::json!({
"session_id": "child-session",
"cwd": tmp.path(),
"subagentType": "goal-plan-writer",
"tool_name": "read_file"
})
.to_string(),
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
assert_eq!(stdout, b"{}\n");
while let Some(request) = first_request(&mut requests).await {
assert!(
!request.starts_with("GET /handoff"),
"a child session must not accept the parent handoff: {request}"
);
}
}
#[tokio::test]
async fn grok_post_tool_without_handoff_prints_empty_object() {
let tmp = tempfile::tempdir().unwrap();
let (base, mut requests) = serve_requests("404 Not Found", "").await;
let mut stdout = Vec::new();
run_with_payload(
Some(tmp.path().join("data")),
grok_hook_args("post-tool-use", &base),
serde_json::json!({"session_id": "grok-session", "cwd": tmp.path()}).to_string(),
&mut stdout,
|_, _| Ok(()),
)
.await
.unwrap();
assert_eq!(stdout, b"{}\n");
let request = first_request(&mut requests).await.unwrap();
assert!(request.starts_with("GET /handoff?"), "{request}");
}
#[test]
fn grok_additional_context_is_clipped_to_the_model_cap() {
let long = "x".repeat(GROK_ADDITIONAL_CONTEXT_CHARS + 50);
let envelope = grok_post_tool_handoff_envelope(&long);
let note = envelope["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap();
assert!(note.ends_with("\n[truncated]"));
assert!(note.chars().count() <= GROK_ADDITIONAL_CONTEXT_CHARS);
}
#[test]
fn briefed_markers_are_bounded_and_keep_current() {
let tmp = tempfile::tempdir().unwrap();
+31 -4
View File
@@ -362,8 +362,10 @@ impl AgentKind {
/// into the resuming session as context. Agents that consume it return
/// `true` (Claude Code reads `hookSpecificOutput.additionalContext`).
///
/// Grok ignores hook stdout on `SessionStart` (per Grok's hooks docs:
/// "For events like SessionStart or PostToolUse, stdout is ignored"), and
/// Grok ignores hook stdout on `SessionStart` (per Grok's hooks guide:
/// stdout for that event is ignored). `PostToolUse` stdout is read and
/// `additionalContext` is shown to the model after the tool result. See
/// [`Self::post_tool_injects_handoff`].
/// Zero's agent loop discards the sessionStart dispatch result entirely
/// (`internal/agent/loop.go` ignores `Dispatch`'s return there), so
/// the native hook must NOT fetch the handoff for it: the fetch is
@@ -414,10 +416,31 @@ impl AgentKind {
/// `session/hooks/user-prompt.ts`, verified in the v0.28.1 source).
/// Empty stdout injects nothing, so the hook prints the raw handoff body
/// or nothing at all — never a JSON envelope.
///
/// Grok Build also ignores `SessionStart` stdout, and it is not in this
/// set. An allowing `UserPromptSubmit` discards stdout and has no
/// `additionalContext`. `GET /handoff` marks the handoff accepted, so
/// fetching on that event would burn the baton. Grok shows
/// `PostToolUse` `additionalContext` to the model; that is
/// [`Self::post_tool_injects_handoff`].
#[must_use]
pub fn user_prompt_injects_handoff(self) -> bool {
matches!(self, Self::KimiCode)
}
/// Whether `PostToolUse` stdout is model-visible context.
///
/// Grok Build reads that stdout and delivers `hookSpecificOutput.additionalContext`
/// after the tool result (`10-hooks.md`, PostToolUse Output). The handoff
/// is accepted on the first such event of a session, not on `SessionStart`
/// or `UserPromptSubmit`, because those outputs never reach the model.
/// The model sees the handoff after the first tool, not before the first
/// prompt. A session that never calls a tool leaves the handoff open for
/// `memory_handoff_accept`.
#[must_use]
pub fn post_tool_injects_handoff(self) -> bool {
matches!(self, Self::Grok)
}
}
#[cfg(test)]
@@ -457,9 +480,13 @@ mod tests {
);
// Unknown tags still degrade to Other.
assert_eq!(AgentKind::from_wire("grok-2"), AgentKind::Other);
// Grok cannot inject the session-start handoff (ignores hook stdout);
// every other agent can.
// Grok cannot inject the session-start handoff (ignores hook stdout),
// and must not fetch on UserPromptSubmit either (that stdout is discarded).
assert!(!AgentKind::Grok.session_start_injects_handoff());
assert!(!AgentKind::Grok.user_prompt_injects_handoff());
assert!(AgentKind::Grok.post_tool_injects_handoff());
assert!(!AgentKind::KimiCode.post_tool_injects_handoff());
assert!(!AgentKind::ClaudeCode.post_tool_injects_handoff());
assert!(!AgentKind::Zero.session_start_injects_handoff());
assert!(AgentKind::ClaudeCode.session_start_injects_handoff());
assert!(AgentKind::Codex.session_start_injects_handoff());
+9 -5
View File
@@ -2945,11 +2945,6 @@ pub(crate) fn accept_handoff_in_transaction(
"handoff receiver session does not match the accepting scope and agent".into(),
));
}
if !open {
return Err(StoreError::InvalidState(
"an ended session cannot accept a handoff".into(),
));
}
let already_claimed: bool = tx.query_row(
"SELECT EXISTS( \
SELECT 1 FROM handoffs \
@@ -2964,6 +2959,15 @@ pub(crate) fn accept_handoff_in_transaction(
// the first accepted row.
return Ok(false);
}
if !open {
// Grok reuses the session id after SessionEnd when the same
// conversation restarts. The row is the receiver, not a corpse,
// as long as it has not already taken a baton.
tx.execute(
"UPDATE sessions SET ended_at = NULL WHERE id = ?1",
params![accepting_session.as_bytes()],
)?;
}
}
let metadata = tx
.query_row(
@@ -358,3 +358,94 @@ async fn an_owned_handoff_stays_with_its_owner_while_pages_stay_shared() {
operator("alice")
);
}
/// Grok reuses one session id across a SessionEnd→restart, so
/// `accept_handoff` must reopen an already-ended receiver session instead of
/// rejecting it (#840) — but only *after* the exactly-once claim guard, so the
/// resurrection can never become a way to steal an already-taken baton.
#[tokio::test]
async fn accept_reopens_an_ended_receiver_session_but_keeps_claim_once() {
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path()).unwrap();
let (ws, proj) = scope(&store).await;
let id = store
.writer
.insert_handoff(NewHandoff {
workspace_id: ws,
project_id: proj,
from_agent: AgentKind::Grok,
to_agent: None,
from_session_id: None,
summary: "resume after restart".into(),
next_steps: Vec::new(),
open_questions: Vec::new(),
files_touched: Vec::new(),
cwd: None,
owner_user: None,
})
.await
.unwrap();
let accept = |session: SessionId| HandoffAcceptance {
handoff_id: id,
workspace_id: ws,
project_id: proj,
accepting_agent: AgentKind::Grok,
accepting_session: Some(session),
accepting_user: None,
owner_filter: OwnerFilter::Any,
receiving_cwd: None,
};
// The same Grok session id: opened, then ended (SessionEnd), then reused
// when the conversation restarts and calls its first tool.
let grok = open_session(&store, ws, proj, AgentKind::Grok).await;
store.writer.end_session(grok, None).await.unwrap();
let claimed = store.writer.accept_handoff(accept(grok)).await.unwrap();
assert!(
claimed,
"an ended session that reuses its id must be able to accept the handoff"
);
// The receiver row was reopened (ended_at cleared), not left a corpse.
let grok_bytes = grok.as_bytes().to_vec();
let ended_at: Option<i64> = store
.reader
.with_conn(move |conn| {
Ok(conn.query_row(
"SELECT ended_at FROM sessions WHERE id = ?1",
rusqlite::params![grok_bytes],
|r| r.get(0),
)?)
})
.await
.unwrap();
assert!(
ended_at.is_none(),
"accepting a handoff must reopen the ended receiver session"
);
// Claim-once still holds: a *different* session cannot steal the baton the
// reopened session already took, even though the loser is wide open.
let loser = open_session(&store, ws, proj, AgentKind::Codex).await;
let stolen = store
.writer
.accept_handoff(HandoffAcceptance {
handoff_id: id,
workspace_id: ws,
project_id: proj,
accepting_agent: AgentKind::Codex,
accepting_session: Some(loser),
accepting_user: None,
owner_filter: OwnerFilter::Any,
receiving_cwd: None,
})
.await
.unwrap();
assert!(
!stolen,
"the resurrection path must not let a second session steal an accepted baton"
);
}
+8 -3
View File
@@ -1437,14 +1437,19 @@ Cursor, Gemini CLI, Antigravity CLI, Grok Build CLI, Kiro CLI, Command Code, and
`$GROK_HOME/config.toml` (default `~/.grok/config.toml`); its hooks live under
`$GROK_HOME/hooks` (default `~/.grok/hooks`). `install-hooks --agent grok`
captures lifecycle events.
Grok ignores `SessionStart` stdout, so handoffs must be accepted through MCP with
`memory_handoff_accept` when resuming. Claude Desktop, VS Code Copilot, Zed,
Grok ignores `SessionStart` stdout and discards an allowing `UserPromptSubmit`,
so those hooks do not accept the handoff. The first `PostToolUse` prints
`hookSpecificOutput.additionalContext` (pending handoff, plus an opted-in
`[briefing]`). The model sees it after that tool result, not before the
first prompt. A session with no tool call leaves the handoff for
`memory_handoff_accept`. Claude Desktop, VS Code Copilot, Zed,
and ZCode
are MCP-only here, so you'll need to nudge the model to call
`memory_query` / `memory_handoff_accept` itself.
For clients with `install-hooks` support, the capture path handles
handoff injection at session start or the client's closest equivalent, except
for Grok's (and Zero's) no-stdout SessionStart behavior (Antigravity CLI uses `PreInvocation`).
for Zero's no-stdout SessionStart behavior. Grok delivers on the first
`PostToolUse` instead (Antigravity CLI uses `PreInvocation`).
---
+11 -8
View File
@@ -47,7 +47,7 @@ endpoint. The trade-off:
| | What you get | What you don't get |
|---|---|---|
| **MCP only** | LLM can query the wiki, accept handoffs, run memory_consolidate, and run `memory_auto_improve` learning reviews | No automatic session-end summaries; no auto-handoff at session boundaries |
| **MCP + hooks** | All of the above *plus* bounded sanitized prompt/tool-lifecycle observations captured automatically; handoffs surface at SessionStart with no human prompting **only when the client consumes startup-hook output or an equivalent context-injection result** | Hook observations are not complete native transcripts. Grok and Zero discard SessionStart stdout; ask them to call `memory_handoff_accept` when resuming. |
| **MCP + hooks** | All of the above *plus* bounded sanitized prompt/tool-lifecycle observations captured automatically; handoffs surface at SessionStart with no human prompting **only when the client consumes startup-hook output or an equivalent context-injection result** | Hook observations are not complete native transcripts. Grok delivers the handoff on the first `PostToolUse`. Zero discards SessionStart stdout; ask it to call `memory_handoff_accept`. |
For MCP-only use, you can still cover the session-boundary gap by asking
the LLM to call `memory_handoff_begin` manually before quitting.
@@ -752,8 +752,10 @@ that file and preserves all unrelated MCP servers.
## Grok Build CLI
**Status:** ✅ MCP supported. ✅ Lifecycle hooks supported via
`ai-memory install-hooks --agent grok --apply`. ❌ No automatic handoff
injection (Grok ignores SessionStart stdout — same policy as Zero).
`ai-memory install-hooks --agent grok --apply`. Handoff injection is the
first `PostToolUse` (`additionalContext` after the tool result). Grok
ignores `SessionStart` stdout and discards an allowing `UserPromptSubmit`,
so those events do not accept the handoff.
**Config file:** `install-mcp --client grok --apply` writes the user config at
`$GROK_HOME/config.toml` (default `~/.grok/config.toml`). To use a project or
@@ -794,10 +796,11 @@ mirror Claude Code's vocabulary (`SessionStart`, `UserPromptSubmit`,
`PreToolUse`, `PostToolUse`, `PreCompact`, `Stop`, `SessionEnd`,
`SubagentStart`, `SubagentStop`) with a Grok-specific script bundle /
native `ai-memory hook --event … --agent grok` commands. Session-end
handoff *creation* works; handoff *injection* does not — ask Grok to
call `memory_handoff_accept` (or install the managed routing skills under
`.grok/skills` / `$GROK_HOME/skills` (default `~/.grok/skills`)) at the start
of a resumed session.
handoff *creation* works. Injection is the first `PostToolUse`: JSON
`additionalContext` with the pending handoff and an opted-in `[briefing]`.
`memory_handoff_accept` is still the path when the session has not called
a tool yet (skills live under `.grok/skills` / `$GROK_HOME/skills`, default
`~/.grok/skills`).
Grok can also load MCP from Claude Code / Cursor compat sources when those
compat flags are enabled, but first-party `install-mcp --client grok` is
@@ -1367,7 +1370,7 @@ that *starts* the next one - to play nicely with ai-memory:
| Side | What's needed | Covered by |
|---|---|---|
| **Ending side** | The agent must create a handoff through a true session-end hook, the manual finalizer, or `memory_handoff_begin`. | Built-in automatically for Claude Code, Codex (native `SessionEnd`, Codex CLI 0.145.0+), Devin CLI, Cursor, Gemini CLI, Grok Build CLI, Zero, Kimi Code, OpenClaw, OpenCode, OpenCode 2 beta, and OMP. Antigravity CLI, both Kiro CLI engines, and Command Code have no reliable true session-end event; run `ai-memory finalize-session` with the corresponding `--agent` after the final turn (also the fallback on Codex older than 0.145.0). MCP-only clients such as Swival must call `memory_handoff_begin` explicitly. |
| **Starting side** | Either (a) the session-start/plugin path injects the handoff via `/handoff`, OR (b) the model inspects with `memory_handoff_list` then claims with `memory_handoff_accept` (`handoff_id` from the list). | (a) is built-in for Claude Code / Codex / Devin CLI / Cursor / Gemini CLI / Antigravity CLI / Kimi Code / both Kiro CLI engines / Command Code / OpenClaw / OpenCode / OpenCode 2 beta / OMP. It requires a client that consumes startup-hook stdout or an equivalent context-injection result. Grok and Zero discard SessionStart stdout; Swival is MCP-only. Use (b) for those clients. (b) works for any MCP-capable client if you nudge the model - see [the managed routing package](usage.md#install-the-routing-snippet-and-agent-skills). |
| **Starting side** | Either (a) the session-start/plugin path injects the handoff via `/handoff`, OR (b) the model inspects with `memory_handoff_list` then claims with `memory_handoff_accept` (`handoff_id` from the list). | (a) is built-in for Claude Code / Codex / Devin CLI / Cursor / Gemini CLI / Antigravity CLI / Kimi Code / Grok Build CLI / both Kiro CLI engines / Command Code / OpenClaw / OpenCode / OpenCode 2 beta / OMP. Grok's (a) is the first `PostToolUse` `additionalContext`, not SessionStart. Zero discards SessionStart stdout; Swival is MCP-only. Use (b) for those. (b) works for any MCP-capable client if you nudge the model - see [the managed routing package](usage.md#install-the-routing-snippet-and-agent-skills). |
OpenCode uses its official `session.deleted` plugin event for true session-end
delivery. The OpenCode 2 beta plugin subscribes to the same event name on the
+1 -1
View File
@@ -25,7 +25,7 @@
| Claude Desktop | MCP-only | Uses `mcp-remote`; no lifecycle hooks. |
| OpenClaw | Supported | MCP config + native plugin lifecycle hooks; generated plugin enforces capture exclusions. |
| Antigravity CLI | Supported | MCP config (`serverUrl`) + lifecycle hooks (`agy` alias). Only `PreInvocation` with `invocationNum = 0` maps to SessionStart; later model calls cannot consume a next-session handoff. No automatic true session-end hook, so run `ai-memory finalize-session --agent antigravity-cli` after the final turn when you need a summary, handoff, and opt-in SessionEnd consolidation. `ai-memory run antigravity` (aliases `antigravity-cli`, `agy`) adds managed workstream resume via `--conversation`; conversation text is not decoded, so the ledger for this harness comes from hook capture. |
| Grok Build CLI | Supported | MCP config (`install-mcp --client grok` → `$GROK_HOME/config.toml`, default `~/.grok/config.toml`) + lifecycle hooks (`install-hooks --agent grok` → `$GROK_HOME/hooks/ai-memory.json`, default `~/.grok/hooks/ai-memory.json`, Grok-specific hook bundle). Capture works; no hook handoff injection — Grok ignores `SessionStart` stdout, so recover handoffs via MCP `memory_handoff_list` then `memory_handoff_accept` with that `handoff_id`. `ai-memory run grok` adds managed workstream resume with the context packet delivered natively through `--rules`. Skills root: `.grok/skills` / `$GROK_HOME/skills` (default `~/.grok/skills`). |
| Grok Build CLI | Supported | MCP config (`install-mcp --client grok` → `$GROK_HOME/config.toml`, default `~/.grok/config.toml`) + lifecycle hooks (`install-hooks --agent grok` → `$GROK_HOME/hooks/ai-memory.json`, default `~/.grok/hooks/ai-memory.json`, Grok-specific hook bundle). Capture works. `SessionStart` and `UserPromptSubmit` do not accept the handoff (Grok discards that stdout). The first `PostToolUse` prints `hookSpecificOutput.additionalContext` with the pending handoff and an opted-in `[briefing]`; the model sees it after that tool result. If no tool runs, recover via MCP `memory_handoff_list` then `memory_handoff_accept` with that `handoff_id`. `ai-memory run grok` adds managed workstream resume with the context packet delivered natively through `--rules`. Skills root: `.grok/skills` / `$GROK_HOME/skills` (default `~/.grok/skills`). |
| Swival CLI | MCP-only | `install-mcp --client swival --apply` merges a native HTTP entry into the project-root `.swival/mcp.json`, preserving sibling servers. Lifecycle and managed-workstream support are not claimed because Swival's callback contract does not expose a stable session identifier. |
| Zero | Supported | `install-mcp --client zero` (native HTTP + bearer in `~/.config/zero/config.json`) + lifecycle hooks via `install-hooks --agent zero --apply` (exec-form native commands in `~/.config/zero/hooks.json`, JSON payload on stdin, no shell). Capture works incl. specialist (subagent) events; no handoff injection — Zero discards `sessionStart` stdout, so recover handoffs via MCP `memory_handoff_list` then `memory_handoff_accept` with that `handoff_id`. |
| ZCode | Supported | `install-mcp --client zcode --apply` merges a native HTTP entry (strict schema: `type`/`url`/`headers` only) into the `mcp.servers` map of `~/.zcode/cli/config.json`, preserving sibling servers. ZCode (z.ai, `zcode`, alias `zai`). Lifecycle-hook capture via `install-hooks --agent zcode --apply`: exec-form native commands (`type: "process"`, no shell) merged into the root `hooks` block of `~/.zcode/cli/config.json` around any third-party hooks; native commands enforce capture exclusions. Six documented triggers (`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Stop`); `PostToolUseFailure` fires instead of `PostToolUse` when a tool throws and lands on the same capture channel with the error preserved. `PermissionRequest` is deliberately not installed — its hook chain races the interactive permission client, so passive capture of that event is unreliable by design. No true session-end — `Stop` is a per-turn boundary, so run `ai-memory finalize-session --agent zcode` after the final turn. Unlike Pool and Zero, `SessionStart` stdout injection works (`hookSpecificOutput.additionalContext`, verified live against the embedded engine v0.16.5), so the prior session's handoff is delivered automatically. No first-party `install-mcp` client and no managed workstream are claimed yet. |
+5 -3
View File
@@ -38,9 +38,11 @@ $ codex # in the same directory, later
If an agent has MCP but no lifecycle hook surface, ask it to call
`memory_handoff_begin` before quitting. The next hooked agent can still
consume that handoff automatically. No-stdout clients (Grok, Zero) should
call `memory_handoff_list` on resume, then `memory_handoff_accept` with
the listed `handoff_id`; listing does not claim the row.
consume that handoff automatically. Grok shows it as `PostToolUse`
`additionalContext` after the first tool. Until that tool runs, or if the
session never calls one, call `memory_handoff_list` then
`memory_handoff_accept` with the listed `handoff_id`. Zero should do that
on resume. Listing does not claim the row.
On a server that distinguishes operators, handoffs belong to their creator by
default: the next session for that operator sees their own plus deliberately
+4 -3
View File
@@ -117,9 +117,10 @@
- **"Quit at 4 PM, pick up at 9 AM in a different agent."** The
classic. SessionStart hook in the next supported hook client prepends a
typed handoff with open questions, next steps, and a session summary. Grok
captures lifecycle events but ignores SessionStart stdout, so ask it to call
`memory_handoff_accept` when resuming from a handoff. Zero has the same
no-stdout behavior and also must call `memory_handoff_accept`.
cannot show that text before the first prompt. The first tool's
`PostToolUse` hook adds it as `additionalContext`. If the session never
calls a tool, ask Grok to call `memory_handoff_accept`. Zero still must
call `memory_handoff_accept`.
- **"What did we decide about X six weeks ago?"** Use `memory_query X` from
the agent for FTS5 fused with entity matches and linked-page expansion (plus
vector similarity when an embedder is configured). For a quick terminal-only
+3 -1
View File
@@ -1,3 +1,5 @@
# First PostToolUse of a Grok session: additionalContext is what the
# model actually sees. SessionStart and UserPromptSubmit must not fetch.
. "$PSScriptRoot\..\lib\ai-memory-hook.ps1"
Invoke-AiMemoryHook -Event "post-tool-use" -Agent "grok"
Invoke-AiMemoryHook -Event "post-tool-use" -Agent "grok" -FetchHandoff -GrokPostTool
exit 0
+32
View File
@@ -1,5 +1,9 @@
#!/bin/sh
# Grok Build CLI post-tool-use hook.
# PostToolUse stdout is the channel Grok shows the model
# (hookSpecificOutput.additionalContext, after the tool result).
# SessionStart and UserPromptSubmit stdout are discarded, so they must
# not accept the handoff. This fetch runs once per session.
_lib_dir="$(dirname "$0")"
[ -f "$_lib_dir/_lib.sh" ] || _lib_dir="$_lib_dir/.."
. "$_lib_dir/_lib.sh"
@@ -8,8 +12,36 @@ SERVER="${AI_MEMORY_HOOK_URL:-http://127.0.0.1:49374}"
PAYLOAD=$(cat)
CWD=$(ai_memory_extract_cwd "$PAYLOAD")
QS=$(ai_memory_marker_qs "$CWD")
SESSION_ID=$(ai_memory_extract_session_id "$PAYLOAD")
SESSION_QS=""
[ -n "$SESSION_ID" ] && SESSION_QS="&session_id=$(ai_memory_url_encode "$SESSION_ID")"
printf '%s' "$PAYLOAD" \
| ai_memory_post_hook "$SERVER/hook?event=post-tool-use&agent=grok${QS}" >/dev/null 2>&1 || true
SHOWN_KEY="$SESSION_ID"
if [ -z "$SHOWN_KEY" ]; then
SHOWN_KEY="grok-post-$(printf '%s' "grok:$CWD" | cksum | awk '{print $1}')"
fi
SHOWN=$(ai_memory_briefed_file "post-$SHOWN_KEY")
if [ -f "$SHOWN" ]; then
printf '{}\n'
exit 0
fi
case "$PAYLOAD" in
*'"subagentType"'*|*'\"subagentType\"'*|*'"parentSessionId"'*)
printf '{}\n'
exit 0
;;
esac
BRIEF_QS=$(ai_memory_briefing_qs "$CWD")
HANDOFF=$(ai_memory_get_handoff "$SERVER/handoff?agent=grok${QS}${SESSION_QS}${BRIEF_QS}" 2>/dev/null || true)
if [ -n "$HANDOFF" ]; then
ai_memory_mark_briefed "$SHOWN"
CTX=$(printf '%s' "$HANDOFF" | ai_memory_json_string)
printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":%s}}\n' "$CTX"
exit 0
fi
printf '{}\n'
exit 0
+2 -1
View File
@@ -2,7 +2,8 @@
# Grok Build CLI SessionStart hook.
# Grok ignores SessionStart stdout, so this hook captures the event only.
# Do NOT fetch /handoff here: accepting a handoff is destructive and Grok
# would discard the returned context.
# would discard the returned context. UserPromptSubmit cannot deliver it
# either (allowing-hook stdout is discarded).
_lib_dir="$(dirname "$0")"
[ -f "$_lib_dir/_lib.sh" ] || _lib_dir="$_lib_dir/.."
. "$_lib_dir/_lib.sh"
+2
View File
@@ -1,3 +1,5 @@
# Do not fetch /handoff. Grok discards allowing UserPromptSubmit stdout,
# and GET /handoff would accept the handoff anyway.
. "$PSScriptRoot\..\lib\ai-memory-hook.ps1"
Invoke-AiMemoryHook -Event "user-prompt" -Agent "grok"
exit 0
+4
View File
@@ -1,5 +1,9 @@
#!/bin/sh
# Grok Build CLI user-prompt hook.
# Do NOT fetch /handoff. Grok discards stdout of an allowing
# UserPromptSubmit (no additionalContext), and GET /handoff marks the
# handoff accepted. SessionStart must not fetch either. Recover with
# MCP memory_handoff_list then memory_handoff_accept.
_lib_dir="$(dirname "$0")"
[ -f "$_lib_dir/_lib.sh" ] || _lib_dir="$_lib_dir/.."
. "$_lib_dir/_lib.sh"
+32 -4
View File
@@ -308,7 +308,10 @@ function Invoke-AiMemoryHook {
# first prompt — parity with Claude's once-per-SessionStart brief).
# Later fetches keep the handoff but drop the briefing params so the
# server does not recompose the brief per prompt.
[switch] $BriefingOncePerSession
[switch] $BriefingOncePerSession,
# Grok PostToolUse: wrap a fetched handoff as additionalContext and
# only fetch once per session. Other events must not set this.
[switch] $GrokPostTool
)
$Server = if ($env:AI_MEMORY_HOOK_URL) { $env:AI_MEMORY_HOOK_URL } else { "http://127.0.0.1:49374" }
@@ -362,6 +365,7 @@ function Invoke-AiMemoryHook {
if ($FetchHandoff) {
$NativeSessionQS = ""
$NativeSessionId = $null
try {
$ParsedPayload = $Payload | ConvertFrom-Json
$NativeSessionId = @(
@@ -376,6 +380,16 @@ function Invoke-AiMemoryHook {
}
} catch {
}
$Shown = $null
if ($GrokPostTool) {
$ShownKey = [string]$NativeSessionId
if (-not $ShownKey) { $ShownKey = "grok-post-$PID" }
$Shown = Get-AiMemoryBriefedFile -Key "post-$ShownKey"
if (Test-Path $Shown -PathType Leaf) {
[Console]::Out.Write("{}")
return
}
}
# Once-per-session briefing gate. Marker files are created only for
# repositories that opt in. Prefer the native session id when Kimi
# supplies one; otherwise use a stable hash of agent+cwd.
@@ -396,6 +410,9 @@ function Invoke-AiMemoryHook {
}
}
}
if ($GrokPostTool -and -not $BriefQS) {
$BriefQS = Get-AiMemoryBriefingQuery -Cwd $Cwd
}
try {
$Response = Invoke-WebRequest `
-UseBasicParsing `
@@ -403,7 +420,15 @@ function Invoke-AiMemoryHook {
-Uri "$Server/handoff?agent=$Agent$QS$NativeSessionQS$BriefQS" `
-Headers $Headers
if ($null -ne $Response -and $Response.Content) {
if ($AntigravityPreInvocationOutput) {
if ($GrokPostTool) {
$Wrapped = @{
hookSpecificOutput = @{
hookEventName = "PostToolUse"
additionalContext = $Response.Content
}
}
[Console]::Out.Write(($Wrapped | ConvertTo-Json -Depth 5 -Compress))
} elseif ($AntigravityPreInvocationOutput) {
$Payload = @{
injectSteps = @(@{ ephemeralMessage = $Response.Content })
}
@@ -411,14 +436,17 @@ function Invoke-AiMemoryHook {
} else {
[Console]::Out.Write($Response.Content)
}
} elseif ($AntigravityPreInvocationOutput) {
} elseif ($AntigravityPreInvocationOutput -or $GrokPostTool) {
[Console]::Out.Write("{}")
}
} catch {
if ($AntigravityPreInvocationOutput) {
if ($AntigravityPreInvocationOutput -or $GrokPostTool) {
[Console]::Out.Write("{}")
}
}
if ($Shown) {
Set-AiMemoryBriefed -Path $Shown
}
# Mark the session as briefed only AFTER the GET completed —
# success or error (fail-open: with the server down, re-sending the
# brief-flagged request on every prompt would deliver nothing