From e786851396b022aa65838241d5a9c257e910de3c Mon Sep 17 00:00:00 2001 From: sergiogonzalez Date: Fri, 7 Aug 2026 09:31:18 +0200 Subject: [PATCH 1/3] Ignore claude-mem observer sessions The claude-mem plugin compresses conversations into its memory store by running headless observer Claude sessions in the background. They write real transcripts and fire real hooks, so every feed surfaced them as phantom agents that woke whenever a real session did. All observers run from ~/.claude-mem/observer-sessions, which stamps both the hook event's cwd and the transcript's munged project directory. A shared predicate now drops them at every entry point: the hook exits before contacting the app, the transcript scan skips their project directory entirely, event ingestion guards against anything that slips past, and persisted observer sessions are discarded on snapshot load. Co-Authored-By: Octupus Technologies S.L. --- .../MemoryObserverSessions.swift | 38 ++++++++++ Sources/NotchFlowHook/main.swift | 5 ++ Sources/NotchKit/AppModel.swift | 8 +- .../NotchKit/ClaudeTranscriptMonitor.swift | 6 ++ .../MemoryObserverSessionsTests.swift | 76 +++++++++++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 Sources/NotchFlowCore/MemoryObserverSessions.swift create mode 100644 Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift diff --git a/Sources/NotchFlowCore/MemoryObserverSessions.swift b/Sources/NotchFlowCore/MemoryObserverSessions.swift new file mode 100644 index 0000000..083d213 --- /dev/null +++ b/Sources/NotchFlowCore/MemoryObserverSessions.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The claude-mem plugin compresses conversations into its memory store by +/// running headless "observer" Claude sessions in the background. They write +/// real transcripts under ~/.claude/projects and fire real hooks, so every +/// feed would surface them — but they are the memory system's plumbing, not +/// the user's work, and each one lights up the notch as a phantom agent that +/// wakes whenever a real session does. Observers all run from +/// ~/.claude-mem/observer-sessions, which stamps both the event's cwd and +/// the munged project directory its transcript lands in. +public enum MemoryObserverSessions { + public static func matches(cwd: String?, transcriptPath: String?) -> Bool { + if let cwd, cwd.contains("/.claude-mem/") { return true } + if let transcriptPath, transcriptPath.contains("-claude-mem-observer-sessions/") { + return true + } + return false + } + + /// Claude Code munges the observer cwd into one project directory name + /// (`…--claude-mem-observer-sessions`), so the transcript scan can skip + /// the whole directory instead of parsing files it would only discard. + public static func matchesProjectDirectory(named name: String) -> Bool { + name.hasSuffix("-claude-mem-observer-sessions") + } +} + +public extension AgentEvent { + var isMemoryObserver: Bool { + MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) + } +} + +public extension AgentSession { + var isMemoryObserver: Bool { + MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) + } +} diff --git a/Sources/NotchFlowHook/main.swift b/Sources/NotchFlowHook/main.swift index 3300341..7f459cb 100644 --- a/Sources/NotchFlowHook/main.swift +++ b/Sources/NotchFlowHook/main.swift @@ -47,6 +47,11 @@ if hook.envelope.interaction != nil, ) } +// claude-mem's headless observers inherit the same hooks as a real +// session; exiting here keeps the memory system from watching itself +// into the notch, and Claude Code proceeds as if no hook were installed. +if hook.envelope.event.isMemoryObserver { exit(0) } + guard let encoded = try? JSONEncoder().encode(hook.envelope) else { if options.strict { exit(2) } exit(0) diff --git a/Sources/NotchKit/AppModel.swift b/Sources/NotchKit/AppModel.swift index 1cc109e..2db47ee 100644 --- a/Sources/NotchKit/AppModel.swift +++ b/Sources/NotchKit/AppModel.swift @@ -351,6 +351,10 @@ public final class AppModel: ObservableObject { } private func applyOne(_ event: AgentEvent, announce: Bool) { + // The hook and the transcript scan both drop claude-mem observers at + // the source; this guard catches whatever slips past them — an old + // helper binary still installed, a replayed snapshot event. + guard !event.isMemoryObserver else { return } let previousStatus = reducer.sessions[event.sessionId]?.status reducer.apply(event) @@ -643,7 +647,9 @@ public final class AppModel: ObservableObject { guard let data = try? Data(contentsOf: snapshotURL), var snapshot = try? JSONDecoder().decode(AppSnapshot.self, from: data) else { return } let cutoff = Date().addingTimeInterval(-24 * 60 * 60) - snapshot.sessions = snapshot.sessions.filter { $0.value.updatedAt >= cutoff } + snapshot.sessions = snapshot.sessions.filter { + $0.value.updatedAt >= cutoff && !$0.value.isMemoryObserver + } for key in snapshot.sessions.keys { guard let status = snapshot.sessions[key]?.status else { continue } // The process is the truth across restarts, but only for root diff --git a/Sources/NotchKit/ClaudeTranscriptMonitor.swift b/Sources/NotchKit/ClaudeTranscriptMonitor.swift index 54b75f6..a2b3d3d 100644 --- a/Sources/NotchKit/ClaudeTranscriptMonitor.swift +++ b/Sources/NotchKit/ClaudeTranscriptMonitor.swift @@ -91,6 +91,12 @@ final class ClaudeTranscriptMonitor { includingPropertiesForKeys: [.isDirectoryKey] )) ?? [] for directory in projectDirs { + // claude-mem observer transcripts fill a whole project directory + // with sessions the notch must ignore; skipping it here is what + // keeps the scan from paying for the memory system's chatter. + if MemoryObserverSessions.matchesProjectDirectory(named: directory.lastPathComponent) { + continue + } let files = (try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey] diff --git a/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift b/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift new file mode 100644 index 0000000..1a35a64 --- /dev/null +++ b/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import NotchFlowCore + +final class MemoryObserverSessionsTests: XCTestCase { + func testObserverCwdMatches() { + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions", + transcriptPath: nil + )) + } + + func testObserverTranscriptPathMatches() { + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: nil, + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio--claude-mem-observer-sessions/abc123.jsonl" + )) + } + + func testRegularProjectDoesNotMatch() { + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/PycharmProjects/NotchFlow", + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio-PycharmProjects-NotchFlow/abc123.jsonl" + )) + } + + /// Hacking on the plugin itself is real work: a checkout named after + /// claude-mem must not be swallowed by the filter. + func testClaudeMemCheckoutDoesNotMatch() { + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/PycharmProjects/claude-mem", + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio-PycharmProjects-claude-mem/abc123.jsonl" + )) + } + + func testProjectDirectoryNameMatchesOnlyObserverSessions() { + XCTAssertTrue(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio--claude-mem-observer-sessions" + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-NotchFlow" + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-claude-mem" + )) + } + + func testEventPredicateReadsBothFields() { + let byCwd = AgentEvent( + type: .toolStarted, + agent: .claude, + sessionId: "s1", + cwd: "/Users/sergio/.claude-mem/observer-sessions" + ) + XCTAssertTrue(byCwd.isMemoryObserver) + + let byTranscript = AgentEvent( + type: .turnCompleted, + agent: .claude, + sessionId: "s2", + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio--claude-mem-observer-sessions/s2.jsonl" + ) + XCTAssertTrue(byTranscript.isMemoryObserver) + + let regular = AgentEvent( + type: .toolStarted, + agent: .claude, + sessionId: "s3", + cwd: "/Users/sergio/PycharmProjects/NotchFlow" + ) + XCTAssertFalse(regular.isMemoryObserver) + } +} From 574303aa44f36a25769a50abcf264fed1e2a0463 Mon Sep 17 00:00:00 2001 From: Toni Soriano Date: Fri, 7 Aug 2026 11:50:31 +0200 Subject: [PATCH 2/3] Tighten claude-mem observer path matching --- .../MemoryObserverSessions.swift | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/Sources/NotchFlowCore/MemoryObserverSessions.swift b/Sources/NotchFlowCore/MemoryObserverSessions.swift index 083d213..dd4a254 100644 --- a/Sources/NotchFlowCore/MemoryObserverSessions.swift +++ b/Sources/NotchFlowCore/MemoryObserverSessions.swift @@ -9,30 +9,65 @@ import Foundation /// ~/.claude-mem/observer-sessions, which stamps both the event's cwd and /// the munged project directory its transcript lands in. public enum MemoryObserverSessions { - public static func matches(cwd: String?, transcriptPath: String?) -> Bool { - if let cwd, cwd.contains("/.claude-mem/") { return true } - if let transcriptPath, transcriptPath.contains("-claude-mem-observer-sessions/") { - return true + public static func matches( + cwd: String?, + transcriptPath: String?, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> Bool { + let observerDirectory = observerDirectory(in: homeDirectory) + if let cwd { + let candidate = URL(fileURLWithPath: cwd).standardizedFileURL.path + let root = observerDirectory.path + if candidate == root || candidate.hasPrefix(root + "/") { return true } } - return false + guard let transcriptPath else { return false } + let projectDirectory = URL(fileURLWithPath: transcriptPath) + .deletingLastPathComponent() + .lastPathComponent + return matchesProjectDirectory( + named: projectDirectory, + homeDirectory: homeDirectory + ) } /// Claude Code munges the observer cwd into one project directory name /// (`…--claude-mem-observer-sessions`), so the transcript scan can skip /// the whole directory instead of parsing files it would only discard. - public static func matchesProjectDirectory(named name: String) -> Bool { - name.hasSuffix("-claude-mem-observer-sessions") + public static func matchesProjectDirectory( + named name: String, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> Bool { + name == claudeProjectDirectoryName(for: observerDirectory(in: homeDirectory).path) + } + + private static func observerDirectory(in homeDirectory: URL) -> URL { + homeDirectory.standardizedFileURL + .appendingPathComponent(".claude-mem", isDirectory: true) + .appendingPathComponent("observer-sessions", isDirectory: true) + .standardizedFileURL + } + + /// Claude Code replaces path separators, dots, and other punctuation + /// with hyphens when naming a directory under ~/.claude/projects. + private static func claudeProjectDirectoryName(for path: String) -> String { + path.replacingOccurrences( + of: #"[^A-Za-z0-9_-]"#, + with: "-", + options: .regularExpression + ) } } public extension AgentEvent { var isMemoryObserver: Bool { - MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) + guard agent == .claude else { return false } + return MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) } } public extension AgentSession { var isMemoryObserver: Bool { - MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) + guard agent == .claude else { return false } + return MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) } } From 3ad06575f9389310274bb09a247900551837c4d5 Mon Sep 17 00:00:00 2001 From: Toni Soriano Date: Fri, 7 Aug 2026 11:50:40 +0200 Subject: [PATCH 3/3] Cover claude-mem observer edge cases --- .../MemoryObserverSessionsTests.swift | 94 +++++++++++++++++-- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift b/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift index 1a35a64..c94fa4c 100644 --- a/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift +++ b/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift @@ -1,11 +1,20 @@ +import Foundation import XCTest @testable import NotchFlowCore final class MemoryObserverSessionsTests: XCTestCase { + private let home = URL(fileURLWithPath: "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/Users/sergio", isDirectory: true) + func testObserverCwdMatches() { XCTAssertTrue(MemoryObserverSessions.matches( cwd: "/Users/sergio/.claude-mem/observer-sessions", - transcriptPath: nil + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions/nested", + transcriptPath: nil, + homeDirectory: home )) } @@ -13,7 +22,31 @@ final class MemoryObserverSessionsTests: XCTestCase { XCTAssertTrue(MemoryObserverSessions.matches( cwd: nil, transcriptPath: "/Users/sergio/.claude/projects/" - + "-Users-sergio--claude-mem-observer-sessions/abc123.jsonl" + + "-Users-sergio--claude-mem-observer-sessions/abc123.jsonl", + homeDirectory: home + )) + } + + func testOtherClaudeMemDirectoriesDoNotMatch() { + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/cache", + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/tmp/.claude-mem/observer-sessions", + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions-old", + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions/../cache", + transcriptPath: nil, + homeDirectory: home )) } @@ -21,7 +54,8 @@ final class MemoryObserverSessionsTests: XCTestCase { XCTAssertFalse(MemoryObserverSessions.matches( cwd: "/Users/sergio/PycharmProjects/NotchFlow", transcriptPath: "/Users/sergio/.claude/projects/" - + "-Users-sergio-PycharmProjects-NotchFlow/abc123.jsonl" + + "-Users-sergio-PycharmProjects-NotchFlow/abc123.jsonl", + homeDirectory: home )) } @@ -31,28 +65,46 @@ final class MemoryObserverSessionsTests: XCTestCase { XCTAssertFalse(MemoryObserverSessions.matches( cwd: "/Users/sergio/PycharmProjects/claude-mem", transcriptPath: "/Users/sergio/.claude/projects/" - + "-Users-sergio-PycharmProjects-claude-mem/abc123.jsonl" + + "-Users-sergio-PycharmProjects-claude-mem/abc123.jsonl", + homeDirectory: home )) } func testProjectDirectoryNameMatchesOnlyObserverSessions() { XCTAssertTrue(MemoryObserverSessions.matchesProjectDirectory( - named: "-Users-sergio--claude-mem-observer-sessions" + named: "-Users-sergio--claude-mem-observer-sessions", + homeDirectory: home )) XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( - named: "-Users-sergio-PycharmProjects-NotchFlow" + named: "-Users-sergio-PycharmProjects-NotchFlow", + homeDirectory: home )) XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( - named: "-Users-sergio-PycharmProjects-claude-mem" + named: "-Users-sergio-PycharmProjects-claude-mem", + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-claude-mem-observer-sessions", + homeDirectory: home )) } func testEventPredicateReadsBothFields() { + let actualHome = FileManager.default.homeDirectoryForCurrentUser + let observerCwd = actualHome + .appendingPathComponent(".claude-mem", isDirectory: true) + .appendingPathComponent("observer-sessions", isDirectory: true) + .path + let observerProjectDirectory = observerCwd.replacingOccurrences( + of: #"[^A-Za-z0-9_-]"#, + with: "-", + options: .regularExpression + ) let byCwd = AgentEvent( type: .toolStarted, agent: .claude, sessionId: "s1", - cwd: "/Users/sergio/.claude-mem/observer-sessions" + cwd: observerCwd ) XCTAssertTrue(byCwd.isMemoryObserver) @@ -60,8 +112,8 @@ final class MemoryObserverSessionsTests: XCTestCase { type: .turnCompleted, agent: .claude, sessionId: "s2", - transcriptPath: "/Users/sergio/.claude/projects/" - + "-Users-sergio--claude-mem-observer-sessions/s2.jsonl" + transcriptPath: "/tmp/.claude/projects/" + + "\(observerProjectDirectory)/s2.jsonl" ) XCTAssertTrue(byTranscript.isMemoryObserver) @@ -72,5 +124,27 @@ final class MemoryObserverSessionsTests: XCTestCase { cwd: "/Users/sergio/PycharmProjects/NotchFlow" ) XCTAssertFalse(regular.isMemoryObserver) + + let otherAgent = AgentEvent( + type: .toolStarted, + agent: .codex, + sessionId: "s4", + cwd: observerCwd + ) + XCTAssertFalse(otherAgent.isMemoryObserver) + + let otherAgentSession = AgentSession( + id: "s5", + agent: .codex, + cwd: observerCwd + ) + XCTAssertFalse(otherAgentSession.isMemoryObserver) + + let observerSession = AgentSession( + id: "s6", + agent: .claude, + cwd: observerCwd + ) + XCTAssertTrue(observerSession.isMemoryObserver) } }