From 1ea8c2e5fe2a6ec3d8f9c77bdf8ddfca0053e846 Mon Sep 17 00:00:00 2001 From: Deshraj Yadav Date: Wed, 29 Jul 2026 20:22:43 -0700 Subject: [PATCH] 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 --- integrations/mem0-agent/hooks/hooks.json | 8 +-- integrations/mem0-agent/hooks/hooks.spec.yaml | 8 +-- integrations/mem0-agent/src/mem0_agent/cli.py | 71 +++++++++++++++++-- integrations/mem0-agent/src/mem0_agent/ctx.py | 13 +++- integrations/mem0-agent/tests/test_cli.py | 2 + .../mem0-agent/tests/test_generate.py | 13 +++- 6 files changed, 97 insertions(+), 18 deletions(-) diff --git a/integrations/mem0-agent/hooks/hooks.json b/integrations/mem0-agent/hooks/hooks.json index 2a444edb0..16cb11d16 100644 --- a/integrations/mem0-agent/hooks/hooks.json +++ b/integrations/mem0-agent/hooks/hooks.json @@ -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 } ] diff --git a/integrations/mem0-agent/hooks/hooks.spec.yaml b/integrations/mem0-agent/hooks/hooks.spec.yaml index 1483051b9..fdb67bdaa 100644 --- a/integrations/mem0-agent/hooks/hooks.spec.yaml +++ b/integrations/mem0-agent/hooks/hooks.spec.yaml @@ -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 diff --git a/integrations/mem0-agent/src/mem0_agent/cli.py b/integrations/mem0-agent/src/mem0_agent/cli.py index 2645b751b..57a934e06 100644 --- a/integrations/mem0-agent/src/mem0_agent/cli.py +++ b/integrations/mem0-agent/src/mem0_agent/cli.py @@ -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") diff --git a/integrations/mem0-agent/src/mem0_agent/ctx.py b/integrations/mem0-agent/src/mem0_agent/ctx.py index d00055e7e..ea3381bce 100644 --- a/integrations/mem0-agent/src/mem0_agent/ctx.py +++ b/integrations/mem0-agent/src/mem0_agent/ctx.py @@ -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, }) diff --git a/integrations/mem0-agent/tests/test_cli.py b/integrations/mem0-agent/tests/test_cli.py index 7b328f71c..c26fcfb46 100644 --- a/integrations/mem0-agent/tests/test_cli.py +++ b/integrations/mem0-agent/tests/test_cli.py @@ -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) diff --git a/integrations/mem0-agent/tests/test_generate.py b/integrations/mem0-agent/tests/test_generate.py index 90e80ef16..b99cc9484 100644 --- a/integrations/mem0-agent/tests/test_generate.py +++ b/integrations/mem0-agent/tests/test_generate.py @@ -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):