Skip to content

stop hook prematurely calls /agentmemory/session/end, causing sessions to be marked as ended before user actually exits #745

Description

@LeonemFu

Bug Description

The agentStop hook (plugin/scripts/stop.mjs / src/hooks/stop.ts) sends a request to /agentmemory/session/end every time the agent pauses after completing a response. In Claude Code CLI, this causes agentmemory to mark the session as completed while the user is still actively in the conversation — the session should only end when the user actually exits the CLI (which is the sessionEnd hook's responsibility).

Impact

  • Context loss / session fragmentation: agentmemory believes the session is over and treats subsequent user messages as a new session.
  • Downstream pipeline breakage: session-end.mjs is supposed to trigger slot-reflect, graph-extract, and consolidation once at true session exit. Because stop.mjs already ended the session, these lifecycle events either fire prematurely or against an already-completed session.
  • Log noise: The old triggerVoid failed warning (pre-fix: replace non-existent sdk.triggerVoid with sdk.trigger + action: { type: 'void' } #731) was partly caused by stop.mjs repeatedly firing session/end and triggering event::session::stopped in a partially-teardown state.

Root Cause

In src/hooks/stop.ts, the agentStop event handler performs two requests:

fetch(`${REST_URL}/agentmemory/summarize`, ...)   // ✅ reasonable: summarize the turn
fetch(`${REST_URL}/agentmemory/session/end`, ...) // ❌ breaks Claude Code

The plugin's hook configuration (plugin/hooks/hooks.copilot.json) clearly separates the two events:

Hook Script Intended semantics
agentStop stop.mjs Agent finished a response, pausing for next user input
sessionEnd session-end.mjs User exited the CLI, true end of session

Calling session/end in agentStop conflates "agent paused" with "session ended" for Claude Code.

Historical Context

The /agentmemory/session/end call was intentionally added in commit 355124141625ccc0d740ae08ddaaf77fe2c165ae (#579, fixes #493) as a workaround for Codex, which does not fire a separate SessionEnd event. The commit message explicitly states:

"Codex does not fire a separate SessionEnd event, so its Stop hook is the only signal we get when a Codex session terminates."

"For Claude Code this is a harmless idempotent second call (session-end.mjs runs on the dedicated SessionEnd hook and sets the same endedAt + status fields). For Codex it's the only path that closes the lifecycle."

However, the assumption that this is "harmless idempotent" for Claude Code is incorrect in practice. Claude Code CLI fires agentStop after every assistant turn (not just at session exit), so the session gets repeatedly marked as completed mid-conversation.

Reproduction Steps

  1. Start Claude Code CLI with the agentmemory plugin enabled.
  2. Send any message to the agent and wait for it to complete its response.
  3. Do not exit Claude Code — just let the agent finish and return to the prompt.
  4. Observe the agentmemory service logs:
    [agentmemory] warn event::session::stopped trigger failed
    
    (or pre-fix: replace non-existent sdk.triggerVoid with sdk.trigger + action: { type: 'void' } #731: triggerVoid failed)
  5. Check the session status in agentmemory state — it will show status: "completed" and endedAt set, even though the CLI is still open.

Expected Behavior

  • Claude Code: stop.mjs (agentStop) should only call /agentmemory/summarize (to capture the turn's context) and must not call /agentmemory/session/end. session-end.mjs (sessionEnd) is the sole caller.
  • Codex: Still needs a path to close the session lifecycle, since Codex lacks SessionEnd.

Suggested Fix

The fix needs to preserve Codex support while fixing Claude Code.

Option A: Agent-type guard (recommended)

Only call session/end in stop.mjs when running under Codex (or more generally, when no SessionEnd hook is available). The hook payload may contain an entrypoint or agent-type field that can be used to distinguish Codex from Claude Code.

// In stop.ts — only end session for agents that lack a dedicated SessionEnd hook
const isCodex = data.entrypoint === "codex" || /* other Codex identifiers */;
if (isCodex) {
  fetch(`${REST_URL}/agentmemory/session/end`, {
    method: "POST",
    headers: authHeaders(),
    body: JSON.stringify({ sessionId }),
    signal: AbortSignal.timeout(5000),
  }).catch(() => {});
}

Option B: Move Codex session-end to a Codex-specific hook/config

If the plugin system supports per-agent hook mappings, map Codex's agentStop to a separate script that includes session/end, while Claude Code continues to use the current stop.mjs without it.

Option C: Remove session/end from stop.mjs and accept Codex regression

Remove the call entirely. This fixes Claude Code but regresses Codex session lifecycle (sessions will show as active in the viewer until Codex adds a SessionEnd event).

Affected Files

  • src/hooks/stop.ts (source)
  • plugin/scripts/stop.mjs (build artifact, auto-generated)

Related

Activity

  1. cbsincool commented on Jul 15, 2026

    @cbsincool

    Adding corroborating evidence + a version-boundary note + a usable workaround, to complement what's already in the report.

    Reproduced on v0.9.27 (latest), server-side data proof

    Deployed agentmemory 0.9.27 in Docker, Claude Code plugin from marketplace. Querying /agentmemory/sessions shows sessions where updatedAt > endedAt — i.e. observations kept flowing after the session was already flipped to completed by the per-turn stop hook:

    session (prefix) project startedAt endedAt (marked completed) updatedAt (last obs) gap
    89800ea5… claudecodetest 09:08:29 09:27:56 09:53:08 +25 min
    8680d26f… claudecodetest 06-25 02:15:36 02:27:35 02:58:39 +31 min
    8582b46b… claudecodetest 06-25 08:24:45 08:35:31 08:35:31 —

    The +25 min / +31 min gaps are the smoking gun: the Stop hook fired session/end at the end of an early turn, but the user kept the CLI open and sent more messages for another 25–31 minutes. Status stayed completed the whole time because the observe endpoint only bumps observationCount/updatedAt for an existing session and never resets status back to active.

    Status distribution across 121 sessions: 120 completed / 1 active — consistent with every running session getting prematurely closed on its first turn.

    Version boundary

    The regression landed in v0.9.22 (commit 3551241 is contained in v0.9.22 → v0.9.27; absent in v0.9.21 and earlier). Users on ≤ v0.9.21 saw correct active status during a running session — this matches reports that it "used to work". So this is a regression from #579, not a long-standing behavior.

    A usable local workaround (today, before an upstream fix)

    For Claude Code users, removing the session/end fetch from plugin/scripts/stop.mjs and keeping only /agentmemory/summarize resolves it immediately:

       fetch(`${REST_URL}/agentmemory/summarize`, { ... }).catch(() => {});
    -  fetch(`${REST_URL}/agentmemory/session/end`, { ... }).catch(() => {});

    This does not regress Codex for Claude Code users (Claude Code users don't run Codex), and session-end.mjs on the real SessionEnd hook still closes the lifecycle on CLI exit. Caveat: the file lives under the plugin cache (.../plugins/cache/agentmemory/agentmemory/<ver>/scripts/stop.mjs), so a plugin reinstall overwrites it — needs re-patching until an upstream guard (Option A in the report) ships.

    Happy to test any candidate fix against this dataset if needed.

  2. rohitg00 commented on Oct 8, 2026

    @rohitg00
    Owner

    @LeonemFu v0.9.30 makes a duplicate session/end harmless (returns ended: false for an already completed session and writes nothing), and hooks no longer exit before their request is sent. The stop-hook ordering itself is unchanged, so this stays open. https://github.com/rohitg00/agentmemory/releases/tag/v0.9.30

  3. aberenyi commented on Oct 8, 2026

    @aberenyi

    A side effect on 0.9.30, now that a repeated session/end does nothing:

    • The per-turn Stop hook ends the session after turn 1.
    • event::session::stopped then summarizes and graph-extracts only the observations that exist at that point.
    • mem::observe reopens only abandoned sessions, not completed ones. Later turns are stored, but they are never summarized or extracted.
    • The real SessionEnd call then returns already_completed.

    So each session's summary and graph cover only its first turn.

    Workaround here: drop the Stop entry from the plugin's hooks.json, so that SessionEnd makes the one call. Trade-off: a session killed without SessionEnd is swept to abandoned and never summarized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions