Repository navigation
stop hook prematurely calls /agentmemory/session/end, causing sessions to be marked as ended before user actually exits #745
Description
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.mjsis supposed to triggerslot-reflect,graph-extract, and consolidation once at true session exit. Becausestop.mjsalready ended the session, these lifecycle events either fire prematurely or against an already-completed session. - Log noise: The old
triggerVoid failedwarning (pre-fix: replace non-existent sdk.triggerVoid with sdk.trigger + action: { type: 'void' } #731) was partly caused bystop.mjsrepeatedly firingsession/endand triggeringevent::session::stoppedin 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 CodeThe 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
- Start Claude Code CLI with the agentmemory plugin enabled.
- Send any message to the agent and wait for it to complete its response.
- Do not exit Claude Code — just let the agent finish and return to the prompt.
- Observe the agentmemory service logs:
(or pre-fix: replace non-existent sdk.triggerVoid with sdk.trigger + action: { type: 'void' } #731:
[agentmemory] warn event::session::stopped trigger failedtriggerVoid failed) - Check the session status in agentmemory state — it will show
status: "completed"andendedAtset, 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
- Commit
355124141625ccc0d740ae08ddaaf77fe2c165ae— intentionally addedsession/endtostop.mjsas a Codex workaround (fix(hooks): stop also closes session for Codex (closes #493) #579, fixes Codex Stop hook does not mark sessions ended #493). - v.0.9.24 sdk.triggerVoid is not a function #726 / fix: replace non-existent sdk.triggerVoid with sdk.trigger + action: { type: 'void' } #731 — the
triggerVoidmigration surfaced this becausestop.mjsrepeatedly triggeredsession/end, amplifying thetriggerVoid is not a functionnoise.
Activity
cbsincool commentedon Jul 15, 2026
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/sessionsshows sessions whereupdatedAt > endedAt— i.e. observations kept flowing after the session was already flipped tocompletedby the per-turnstophook:The
+25 min/+31 mingaps are the smoking gun: theStophook firedsession/endat the end of an early turn, but the user kept the CLI open and sent more messages for another 25–31 minutes. Status stayedcompletedthe whole time because theobserveendpoint only bumpsobservationCount/updatedAtfor an existing session and never resetsstatusback toactive.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
3551241is contained in v0.9.22 → v0.9.27; absent in v0.9.21 and earlier). Users on ≤ v0.9.21 saw correctactivestatus 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/endfetch fromplugin/scripts/stop.mjsand keeping only/agentmemory/summarizeresolves 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.mjson the realSessionEndhook 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.
rohitg00 commentedon Oct 8, 2026
@LeonemFu v0.9.30 makes a duplicate
session/endharmless (returnsended: falsefor 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.30aberenyi commentedon Oct 8, 2026
A side effect on 0.9.30, now that a repeated
session/enddoes nothing:event::session::stoppedthen summarizes and graph-extracts only the observations that exist at that point.mem::observereopens onlyabandonedsessions, notcompletedones. Later turns are stored, but they are never summarized or extracted.SessionEndcall then returnsalready_completed.So each session's summary and graph cover only its first turn.
Workaround here: drop the
Stopentry from the plugin'shooks.json, so thatSessionEndmakes the one call. Trade-off: a session killed withoutSessionEndis swept toabandonedand never summarized.