fix(mem0-agent): detached hooks lost their payload, so nothing was ever written

Observed live: real sessions flagged durable candidates (a preference, two
insights, a convention) but every session showed 'flush events: 0'. The manifest
backgrounded flush and assist-error with '( cmd &)', so the parent exited and
closed stdin before the child could read it -- the child then had no session_id,
fell back to 'no-session', and drained an empty buffer.

Now the CLI reads the payload itself and re-execs a detached worker with
start_new_session, so the hook still returns in ~0.27s. Verified end to end:
sent=1, session_state=created.

Also records a surface hint per event and shows it in 'sessions', since the
manifest pins MEM0_EDITOR to the same value on every client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Deshraj Yadav
2026-07-29 20:22:43 -07:00
co-authored by Claude Fable 5
parent 10b8fc4bed
commit 1ea8c2e5fe
6 changed files with 97 additions and 18 deletions
+4 -4
View File
@@ -30,7 +30,7 @@
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error >/dev/null 2>&1 &)",
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error",
"timeout": 5
}
]
@@ -41,7 +41,7 @@
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush >/dev/null 2>&1 &)",
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush",
"timeout": 5
}
]
@@ -52,7 +52,7 @@
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact >/dev/null 2>&1 &)",
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact",
"timeout": 5
}
]
@@ -63,7 +63,7 @@
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end >/dev/null 2>&1 &)",
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end",
"timeout": 5
}
]
@@ -95,7 +95,7 @@ hooks:
matcher: "Bash"
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error"
timeout: 5
background: true
background: false
blocking: false
local_only: false
status_message: null
@@ -106,7 +106,7 @@ hooks:
matcher: null
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush"
timeout: 5
background: true
background: false
blocking: false
local_only: false
status_message: null
@@ -117,7 +117,7 @@ hooks:
matcher: null
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact"
timeout: 5
background: true
background: false
blocking: false
local_only: false
status_message: null
@@ -128,7 +128,7 @@ hooks:
matcher: null
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end"
timeout: 5
background: true
background: false
blocking: false
local_only: false
status_message: null
+65 -6
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
@@ -69,6 +70,47 @@ def emit(text: str) -> None:
PENDING_FILE = "pending_context.jsonl"
def detach_worker(subcommand: str, payload: dict, extra: list[str] | None = None) -> bool:
"""Read the hook payload here, then hand it to a detached child.
A hook backgrounded in the manifest (`cmd &`) loses its stdin the moment the parent
exits, so the child sees no session_id and no transcript_path -- it then drains the
wrong buffer and writes nothing. Reading the payload first and passing it by FILE is
what makes a detached write reliable.
"""
import subprocess
import tempfile
import sys as _sys
try:
fd, path = tempfile.mkstemp(prefix="mem0-hook-", suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(payload, fh)
cmd = [_sys.executable, "-m", "mem0_agent.cli", subcommand,
"--worker", "--payload-file", path, *(extra or [])]
subprocess.Popen(cmd, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True) # survives the hook's process group
return True
except Exception:
return False
def worker_payload(args, fallback: dict) -> dict:
if getattr(args, "payload_file", None):
try:
with open(args.payload_file) as fh:
data = json.load(fh)
try:
os.unlink(args.payload_file)
except Exception:
pass
return data
except Exception:
return fallback
return fallback
def queue_context(c, block: str) -> None:
"""Park a block for the next prompt hook to deliver.
@@ -172,7 +214,13 @@ def cmd_observe(args) -> int:
def cmd_flush(args) -> int:
"""Stop / PreCompact / SessionEnd: the only place writes happen."""
payload = hook_input()
payload = worker_payload(args, hook_input()) if args.worker else hook_input()
if not args.worker:
# Return to the editor in milliseconds; the child does the network work.
if detach_worker("flush", payload, ["--reason", args.reason]):
return 0
# Could not fork: do it inline rather than lose the session's memories.
c = build(args, payload)
if not c.ready:
return 0
@@ -201,7 +249,11 @@ def cmd_flush(args) -> int:
def cmd_assist_error(args) -> int:
"""PostToolUse on Bash: a targeted lookup, or silence."""
payload = hook_input()
payload = worker_payload(args, hook_input()) if args.worker else hook_input()
if not args.worker and not args.emit:
if detach_worker("assist-error", payload):
return 0
c = build(args, payload)
if not c.ready:
return 0
@@ -308,12 +360,14 @@ def cmd_sessions(args) -> int:
if not events and not args.all:
continue
editors = sorted({e.get("editor") or "?" for e in events})
surfaces = sorted({e.get("surface") for e in events if e.get("surface")})
packs = [e for e in events if e.get("event") == "context"]
flushes = [e for e in events if e.get("event") == "flush"]
obs = [e for e in events if e.get("event") == "capture_observe"]
rows.append({
"session": d.name,
"editor": ",".join(editors) or "-",
"surface": ",".join(surfaces) or "?",
"app": (events[-1].get("app_id") if events else "-") or "-",
"last": (events[-1].get("at") if events else "") or "",
"packs": len(packs),
@@ -328,12 +382,13 @@ def cmd_sessions(args) -> int:
print("no sessions with recorded activity yet")
return 0
print(f"{'session':22s} {'editor':14s} {'project':18s} {'packs':>5s} {'served':>6s} {'turns':>5s} {'wrote':>5s} last")
print(f"{'session':20s} {'ran via':12s} {'project':17s} {'packs':>5s} {'served':>6s} {'turns':>5s} {'wrote':>5s} last")
for r in rows:
print(f"{r['session'][:22]:22s} {r['editor'][:14]:14s} {r['app'][:18]:18s} "
print(f"{r['session'][:20]:20s} {r['surface'][:12]:12s} {r['app'][:17]:17s} "
f"{r['packs']:5d} {r['rows']:6d} {r['turns']:5d} {r['sent']:5d} {r['last']}")
editors = sorted({e for r in rows for e in r["editor"].split(",") if e and e != "-"})
print(f"\nsurfaces seen: {', '.join(editors) or 'none'}")
seen = sorted({s for r in rows for s in r["surface"].split(",") if s and s != "?"})
print(f"\nsurfaces seen: {', '.join(seen) or 'unknown'} "
f"(editor: {', '.join(sorted({r['editor'] for r in rows}))})")
return 0
@@ -468,12 +523,16 @@ def main(argv: list[str] | None = None) -> int:
sp.add_argument("--transcript")
sp.add_argument("--reason", default="stop")
sp.add_argument("--json", action="store_true")
sp.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
sp.add_argument("--payload-file", help=argparse.SUPPRESS)
sp.set_defaults(fn=cmd_flush)
sp = sub.add_parser("assist-error", help="look up a past fix for an error")
sp.add_argument("--text")
sp.add_argument("--emit", action="store_true",
help="print the block instead of queueing it for the next prompt")
sp.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
sp.add_argument("--payload-file", help=argparse.SUPPRESS)
sp.set_defaults(fn=cmd_assist_error)
sp = sub.add_parser("remember", help="store a fact verbatim")
+12 -1
View File
@@ -34,6 +34,16 @@ class Ctx:
def editor(self) -> str:
return os.environ.get("MEM0_EDITOR", "claude-code")
@property
def surface(self) -> str:
"""A hint at WHICH client ran the hook, since the manifest pins `editor` to the
same value everywhere. The desktop app launches hooks without a terminal, so
TERM_PROGRAM/TERM is the practical discriminator. A hint, not a guarantee."""
prog = os.environ.get("TERM_PROGRAM") or ""
if prog:
return prog.lower()
return "terminal" if os.environ.get("TERM") else "app"
def provenance(self, mtype: str) -> dict:
"""Metadata stamped on every write. `type` is what reads filter on -- platform
categories arrive hours later and cannot be relied on at read time."""
@@ -55,7 +65,8 @@ class Ctx:
import time
self.state.append("events.jsonl", {
"event": event, "editor": self.editor, "app_id": self.app_id,
"event": event, "editor": self.editor, "surface": self.surface,
"app_id": self.app_id,
"at": time.strftime("%Y-%m-%dT%H:%M:%S"), **fields,
})
@@ -13,6 +13,8 @@ from mem0_agent.settings import SessionState, Settings
class Args:
def __init__(self, **kw):
self.session_id = "sess-cli"
self.worker = True # tests exercise the worker path directly
self.payload_file = None
for k, v in kw.items():
setattr(self, k, v)
+10 -3
View File
@@ -106,11 +106,18 @@ def test_user_prompt_submit_is_declared_local_only(spec, manifest):
assert entry["background"] is False and entry["blocking"] is True
def test_flush_and_assist_hooks_are_detached(spec, manifest):
def test_write_hooks_are_not_shell_backgrounded(spec, manifest):
"""These hooks must NOT be wrapped in `( ... &)`.
A shell-backgrounded hook loses stdin the instant the parent exits, so the child
sees no session_id and no transcript_path and drains the wrong buffer -- observed
live as flagged candidates that were never written. The CLI now reads the payload
first and re-execs itself detached, so the hook still returns in milliseconds.
"""
for event in ("PostToolUse", "Stop", "PreCompact", "SessionEnd"):
command = manifest["hooks"][event][0]["hooks"][0]["command"]
assert command.startswith("(") and command.endswith("&)"), event
assert ">/dev/null" in command, event
assert not command.strip().endswith("&)"), f"{event} must not be backgrounded by the shell"
assert "mem0-agent" in command, event
def test_editor_env_is_pinned(manifest):