Compare commits

..

29 Commits

Author SHA1 Message Date
Alex Newman 69080dc291 chore: bump version to 12.1.6
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 14:31:43 -07:00
Alex Newman c76a439491 fix: drop orphan flag when filtering empty-string spawn args (#2049)
Observations were 100% failing on Claude Code 2.1.109+ because the Agent
SDK emits ["--setting-sources", ""] when settingSources defaults to [].
The existing Bun-workaround filter stripped the empty string but left
the orphan --setting-sources flag, which then consumed --permission-mode
as its value, crashing the subprocess with:

  Error processing --setting-sources:
  Invalid setting source: --permission-mode.

Make the filter pair-aware: when an empty arg follows a --flag, drop
both so the SDK default (no setting sources) is preserved by omission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 14:30:54 -07:00
Alex Newman 70a150db74 docs: update CHANGELOG.md for v12.1.5 2026-04-15 14:41:31 -07:00
Alex Newman d7b4610e27 chore: bump version to 12.1.5
Publish to npm / publish (push) Has been cancelled
2026-04-15 14:40:44 -07:00
Alex Newman 88bb4e589e docs: update CHANGELOG.md for v12.1.4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:10:29 -07:00
Alex Newman ebefae864e chore: bump version to 12.1.4
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:06:52 -07:00
Alex Newman 0cd931bb06 Merge pull request #1865 from thedotmack/thedotmack/find-cmem-refs
fix: revert unauthorized $CMEM branding in context header
2026-04-15 12:06:10 -07:00
Alex Newman 4c792f026d build: rebuild plugin artifacts after $CMEM header revert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:05:49 -07:00
Alex Newman aa7cdb6d9f fix: revert unauthorized $CMEM branding in context header
A prior Claude instance snuck in a `$CMEM` token branding header
during a context compression refactor (7e072106). Reverts back to
the original descriptive format: `# [project] recent context, datetime`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:04:27 -07:00
Alex Newman 5db90f2ea0 docs: update CHANGELOG.md for v12.1.3 2026-04-15 11:43:49 -07:00
Alex Newman 4ddf57610a chore: bump version to 12.1.3
Publish to npm / publish (push) Has been cancelled
2026-04-15 04:26:29 -07:00
Alex Newman d0fc68c630 revert: remove overengineered summary salvage logic (#1718) (#1850)
The synthetic summary salvage feature created fake summaries from observation
data when the AI returned <observation> instead of <summary> tags. This was
overengineered — missing a summary is preferable to fabricating one from
observation fields that don't map cleanly to summary semantics.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 04:22:41 -07:00
Alex Newman 1d7500604f chore: bump version to 12.1.2
Publish to npm / publish (push) Has been cancelled
2026-04-15 01:00:38 -07:00
Ben Younes 05232ff091 fix: reap stuck generators in reapStaleSessions (fixes #1652) (#1698)
* fix: reap stuck generators in reapStaleSessions (fixes #1652)

Sessions whose SDK subprocess hung would stay in the active sessions
map forever because `reapStaleSessions()` unconditionally skipped any
session with a non-null `generatorPromise`.  The generator was blocked
on `for await (const msg of queryResult)` inside SDKAgent and could
never unblock itself — the idle-timeout only fires when the generator
is in `waitForMessage()`, and the orphan reaper skips processes whose
session is still in the map.

Add `MAX_GENERATOR_IDLE_MS` (5 min).  When `reapStaleSessions()` sees
a session whose `generatorPromise` is set but `lastGeneratorActivity`
has not advanced in over 5 minutes, it now:
1. SIGKILLs the tracked subprocess to unblock the stuck `for await`
2. Calls `session.abortController.abort()` so the generator loop exits
3. Calls `deleteSession()` which waits up to 30 s for the generator to
   finish, then cleans up supervisor-tracked children

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: freeze time in stale-generator test and import constants from production source

- Export MAX_GENERATOR_IDLE_MS, MAX_SESSION_IDLE_MS, StaleGeneratorCandidate,
  StaleGeneratorProcess, and detectStaleGenerator from SessionManager.ts so
  tests no longer duplicate production constants or detection logic.
- Use setSystemTime() from bun:test to freeze Date.now() in the
  "exactly at threshold" test, eliminating the flaky double-Date.now() race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:58:35 -07:00
Ben Younes b411d91885 fix: add circuit breaker to OpenClaw worker client (#1636) (#1697)
* fix: add circuit breaker to OpenClaw worker client (#1636)

When the claude-mem worker is unreachable, every plugin event (before_agent_start,
before_prompt_build, tool_result_persist, agent_end) triggered a new fetch that
failed and logged a warning, causing CPU-spinning and continuous log spam.

Add a CLOSED/OPEN/HALF_OPEN circuit breaker: after 3 consecutive network errors
the circuit opens, silently drops all worker calls for 30 s, then sends one probe.
Individual failures are only logged while the circuit is still CLOSED; once open
it logs once ("disabling requests for 30s") and goes quiet until recovery.

Generated by Claude Code
Vibe coded by Ousama Ben Younes

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: limit HALF_OPEN to single probe and move circuitOnSuccess after response.ok check

- Add _halfOpenProbeInFlight flag so only one probe is allowed in HALF_OPEN state;
  concurrent callers are silently dropped until the probe completes (success or failure)
- Move circuitOnSuccess() to after the response.ok check in workerPost, workerPostFireAndForget,
  and workerGetText so non-2xx HTTP responses no longer close the circuit
- Clear _halfOpenProbeInFlight in both circuitOnSuccess and circuitOnFailure, and in circuitReset
- Add regression test covering HALF_OPEN one-probe behavior: non-2xx keeps circuit open,
  2xx closes it

* chore: trigger CodeRabbit re-review

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 00:58:32 -07:00
Ben Younes 4538e686ad fix: resolve Setup hook broken reference and warn on macOS-only binary (#1547) (#1696)
* fix: resolve Setup hook broken reference and warn on macOS-only binary (#1547)

On Linux ARM64, the plugin silently failed because:
1. The Setup hook called setup.sh which was removed; the hook exited 127
   (file not found), causing the plugin to appear uninstalled.
2. The committed plugin/scripts/claude-mem binary is macOS arm64 only;
   no warning was shown when it could not execute on other platforms.

Fix the Setup hook to call smart-install.js (the current setup mechanism)
and add checkBinaryPlatformCompatibility() to smart-install.js, which reads
the Mach-O magic bytes from the bundled binary and warns users on non-macOS
platforms that the JS fallback (bun-runner.js + worker-service.cjs) is active.

Generated by Claude Code
Vibe coded by ousamabenyounes

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: close fd in finally block, strengthen smart-install tests to use production function

- Wrap openSync/readSync in checkBinaryPlatformCompatibility with a finally block so the file descriptor is always closed even if readSync throws
- Export checkBinaryPlatformCompatibility with an optional binaryPath param for testability
- Refactor Mach-O detection tests to call the production function directly, mocking process.platform and passing controlled binary paths, eliminating duplicated inline logic
- Strengthen plugin-distribution test to assert at least one command hook exists before checking for smart-install.js, preventing vacuous pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 00:58:29 -07:00
Ben Younes f97c50bfb9 fix: session lifecycle guards to prevent runaway API spend (#1590) (#1693)
* fix: add session lifecycle guards to prevent runaway API spend (#1590)

Three root causes allowed 30+ subprocess accumulation over 36 hours:
1. SIGTERM-killed processes (code 143) triggered crash recovery and
   immediately respawned — now detected and treated as intentional
   termination (aborts controller so wasAborted=true in .finally).
2. No wall-clock limit: sessions ran for 13+ hours continuously
   spending tokens — now refuses new generators after 4 hours and
   drains the pending queue to prevent further spawning.
3. Duplicate --resume processes for the same session UUID — now
   killed and unregistered before a new spawn is registered.

Generated by Claude Code
Vibe coded by ousamabenyounes

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use normalized errorMsg in logger.error payload and annotate SIGTERM override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use persisted createdAt for wall-clock guard and bind abortController locally to prevent stale abort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: re-trigger CodeRabbit review after rate limit reset

* fix: defer process unregistration until exit and align boundary test with strict > (#1693)

- ProcessRegistry: don't unregister PID immediately after SIGTERM — let the
  existing 'exit' handler clean up when the process actually exits, preventing
  tracking loss for still-live processes.
- Test: align wall-clock boundary test with production's strict `>` operator
  (exactly 4h is NOT terminated, only >4h is).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 00:58:23 -07:00
Ben Younes 983be42998 fix: resolve Gemini CLI 0.37.0 session capture failures (#1664) (#1692)
Three root causes prevented Gemini sessions from persisting prompts,
observations, and summaries:

1. BeforeAgent was mapped to user-message (display-only) instead of
   session-init (which initialises the session and starts the SDK agent).

2. The transcript parser expected Claude Code JSONL (type: "assistant")
   but Gemini CLI 0.37.0 writes a JSON document with a messages array
   where assistant entries carry type: "gemini". extractLastMessage now
   detects the format and routes to the correct parser, preserving
   full backward compatibility with Claude Code JSONL transcripts.

3. The summarize handler omitted platformSource from the
   /api/sessions/summarize request body, causing sessions to be recorded
   without the gemini-cli source tag.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 00:58:20 -07:00
UCHIDA Masayuki 544e9d39f5 fix: replace hardcoded nvm/homebrew PATH with universal login shell resolution (#1833)
* fix: replace hardcoded nvm/homebrew PATH with universal login shell resolution

Hook commands previously hardcoded PATH entries for nvm and homebrew,
causing `node: command not found` for users with other Node version
managers (mise, asdf, volta, fnm, Nix, etc.).

Replace with `$($SHELL -lc 'echo $PATH')` which inherits the user's
login shell PATH regardless of how Node was installed. Also adds the
missing PATH export to the PreToolUse hook (#1702).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add cache-path fallback to PreToolUse hook

Aligns PreToolUse _R resolution with all other hooks by adding the
cache directory lookup before falling back to the marketplace path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:58:17 -07:00
Ethan 16a0737dfc fix: use parent project name for worktree observation writes (#1820)
* fix: use parent project name for worktree observation writes (#1819)

Observations and sessions from git worktrees were stored under
basename(cwd) instead of the parent repo name because write paths
called getProjectName() (not worktree-aware) instead of
getProjectContext() (worktree-aware). This is the same bug as
#1081, #1317, and #1500 — it regressed because the two functions
coexist and new code reached for the simpler one.

Fix: getProjectContext() now returns parentProjectName as primary
when in a worktree, and all four write-path call sites now use
getProjectContext().primary instead of getProjectName().

Includes regression test that creates a real worktree directory
structure and asserts primary === parentProjectName.

* fix: address review nitpicks — allProjects fallback, JSDoc, write-path test

- ContextBuilder: default projects to context.allProjects for legacy
  worktree-labeled record compatibility
- ProjectContext: clarify JSDoc that primary is canonical (parent repo
  in worktrees)
- Tests: add write-path regression test mirroring session-init/SessionRoutes
  pattern; refactor worktree fixture into beforeAll/afterAll

* refactor(project-name): rename local to cwdProjectName and dedupe allProjects

Addresses final CodeRabbit nitpick: disambiguates the local variable
from the returned `primary` field, and dedupes allProjects via Set
in case parent and cwd resolve to the same name.

---------

Co-authored-by: Ethan Hurst <ethan.hurst@outlook.com.au>
2026-04-15 00:58:14 -07:00
biswanath-cmd 3d92684e04 fix: filter empty string args before Bun spawn() to prevent CLI parsing errors (#1781)
Bun's child_process.spawn() silently drops empty string arguments from
argv, unlike Node which preserves them. When the Agent SDK defaults
settingSources to [] (empty array), [].join(",") produces "" which gets
pushed as ["--setting-sources", ""]. Bun drops the "", causing
--permission-mode to be consumed as the value for --setting-sources:

  Error processing --setting-sources: Invalid setting source: --permission-mode

This caused 100% observation failure (exit code 1 on every SDK subprocess
spawn), resulting in 0 observations stored across all sessions.

The fix filters empty string args before passing to spawn(), making the
behavior consistent between Node and Bun runtimes.

Fixes #1779
Related: #1660

Co-authored-by: bswnth48 <69203760+bswnth48@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:58:11 -07:00
enma998 471e1f62f9 Fix npx search and default Codex context to workspace-local AGENTS (#1780)
* Fix npx search query parameter mismatch

* Use workspace-local Codex AGENTS context by default

---------

Co-authored-by: bnb <bnb>
2026-04-15 00:58:08 -07:00
Aviral Arora f44605658d docs: add CLAUDE_MEM_MODE documentation for language and modes (fix #… (#1777)
* docs: add CLAUDE_MEM_MODE documentation for language and modes (fix #1767)

* docs: fix markdown formatting for CLAUDE_MEM_MODE section

* docs: fix markdown code block formatting properly

* docs: fix markdown issues in modes section

* docs: fix markdown spacing and table note formatting

* Update README.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* docs: fix markdown

* docs: fix markdown issues in modes

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-15 00:58:05 -07:00
suyua9 eeb6841033 fix: coerce corpus route filters (#1776)
* fix: coerce corpus route filters

* test: cover unsupported corpus type filters
2026-04-15 00:58:01 -07:00
Tran Quang 2a2008bac2 fix(file-context): preserve targeted reads + invalidate on mtime (#1719) (#1729)
* fix(file-context): preserve targeted reads + invalidate on mtime (#1719)

The PreToolUse:Read hook unconditionally rewrote tool input to
{file_path, limit:1}, which interacted with two failure modes:

1. Subagent edits a file → parent's next Read still gets truncated
   because the observation snapshot predates the change.
2. Claude requests a different section with offset/limit → the hook
   strips them, so the Claude Code harness's read-dedup cache returns
   "File unchanged" against the prior 1-line read. The file becomes
   unreadable for the rest of the conversation, even though the hook's
   own recovery hint says "Read again with offset/limit for the
   section you need."

Two complementary fixes:

- **mtime invalidation**: stat the file (we already stat for the size
  gate) and compare mtimeMs to the newest observation's created_at_epoch.
  If the file is newer, pass the read through unchanged so fresh content
  reaches Claude.

- **Targeted-read pass-through**: when toolInput already specifies
  offset and/or limit, preserve them in updatedInput instead of
  collapsing to {limit:1}. The harness's dedup cache then sees a
  distinct input and lets the read proceed.

The unconstrained-read path (no offset, no limit) is unchanged: still
truncated to 1 line plus the observation timeline, so token economics
are preserved for the common case.

Tests cover all three branches: existing truncation, targeted-read
pass-through (offset+limit, limit-only), and mtime-driven bypass.

Fixes #1719

* refactor(file-context): address review findings on #1719 fix

- Add offset-only test case for full targeted-read branch coverage
- Use >= for mtime comparison to handle same-millisecond edge case
- Add Number.isFinite() + bounds guards on offset/limit pass-through
- Trim over-verbose comments to concise single-line summaries
- Remove redundant `as number` casts after typeof narrowing
- Add comment explaining fileMtimeMs=0 sentinel invariant
2026-04-15 00:57:57 -07:00
Suryansh Rohil d64c252f4d Update: Updated readme for opencode installation (#1765) 2026-04-15 00:57:54 -07:00
Jochen Meyer 59ce0fc553 fix: exclude primary key index from unique constraint check in migration 7 (#1771)
* fix: exclude primary key index from unique constraint check in migration 7

PRAGMA index_list returns all indexes including those backing PRIMARY KEY
columns (origin='pk'), which always have unique=1. The check was therefore
always true, causing migration 7 to run the full DROP/CREATE/RENAME table
sequence on every worker startup instead of short-circuiting once the
UNIQUE constraint had already been removed.

Fix: filter to non-PK indexes by requiring idx.origin !== 'pk'. The
origin field is already present on the existing IndexInfo interface.

Fixes #1749

* fix: apply pk-origin guard to all three migration code paths

CodeRabbit correctly identified that the origin !== 'pk' fix was only
applied to MigrationRunner.ts but not to the two other active code paths
that run the same removeSessionSummariesUniqueConstraint logic:

- SessionStore.ts:220 — used by DatabaseManager and worker-service
- plugin/scripts/context-generator.cjs — bundled artifact (minified)

All three paths now consistently exclude primary-key indexes when
detecting unique constraints on session_summaries.
2026-04-15 00:57:51 -07:00
Jochen Meyer 31ee1024c5 fix: restrict ~/.claude-mem/.env permissions to owner-only (0600) (#1770)
* fix: restrict .env file permissions to owner-only (0600)

API keys stored in ~/.claude-mem/.env were created without explicit
permissions, defaulting to umask-dependent mode. On systems with a
permissive umask (e.g. 0022), the file would be world-readable.

- Set directory permissions to 0700 on creation
- Set file permissions to 0600 via writeFileSync mode option
- Call chmodSync after write to fix permissions on pre-existing files

Signed-off-by: Jochen Meyer

* fix: also restrict pre-existing directory permissions to 0700

The initial fix only set directory mode on creation. Pre-existing
~/.claude-mem/ directories from earlier installs remained world-readable.
Add chmodSync for the directory alongside the existing file chmod,
and document the Windows limitation (ACLs, not POSIX permissions).

---------

Signed-off-by: Jochen Meyer
2026-04-15 00:57:48 -07:00
Alex Newman 7d5d4c5036 docs: update CHANGELOG.md for v12.1.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:16:36 -07:00
48 changed files with 2783 additions and 552 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "12.1.1",
"version": "12.1.6",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "12.1.1",
"version": "12.1.6",
"description": "Memory compression system for Claude Code - persist context across sessions",
"author": {
"name": "Alex Newman"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "12.1.1",
"version": "12.1.6",
"description": "Memory compression system for Claude Code - persist context across sessions",
"author": {
"name": "Alex Newman",
+201 -105
View File
@@ -4,6 +4,102 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [12.1.5] - 2026-04-15
## Forced update to ship --setting-sources fix
Users on v12.1.3 experience 100% observation failure due to empty-string arg filtering corrupting `--setting-sources` on Claude Code 2.1.109+. The fix landed in v12.1.4 (commit 3d92684 — `fix: filter empty string args before Bun spawn()`). This release forces the update to propagate across npm and the marketplace.
Also shipped earlier today: the April 2026 backlog consolidation merged 93 PRs and 147 issues into 138 clean tracking issues (95 bugs, 43 feature requests).
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v12.1.4...v12.1.5
## [12.1.4] - 2026-04-15
A Claude instance inserted `$CMEM` token branding into the context injection header during a compression refactor. Reverted back to the original descriptive format: `# [project] recent context, datetime`
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v12.1.3...v12.1.4
## [12.1.3] - 2026-04-15
## What's Changed
### Reverted
- **Remove overengineered summary salvage logic** (#1850) — Reverts PR #1718 which fabricated synthetic summaries from observation data when the AI returned `<observation>` instead of `<summary>` tags. Missing a summary is preferable to creating a fake one with poorly-mapped fields.
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v12.1.2...v12.1.3
## [12.1.2] - 2026-04-15
## Community PRs merged (15)
**Runtime & reliability**
- #1698 Reap stuck generators in reapStaleSessions (@ousamabenyounes)
- #1697 Circuit breaker on OpenClaw worker client (@ousamabenyounes)
- #1696 Resolve Setup hook reference, warn on macOS-only binary (@ousamabenyounes)
- #1693 Session lifecycle guards to prevent runaway API spend (@ousamabenyounes)
- #1692 Resolve Gemini CLI 0.37.0 session capture failures (@ousamabenyounes)
**Cross-platform & hooks**
- #1833 Replace hardcoded nvm/homebrew PATH with login-shell resolution (@masak1yu)
- #1781 Filter empty-string args before Bun spawn() (@biswanath-cmd)
- #1780 Fix npx search, default Codex context to workspace-local AGENTS (@enma998)
**Data integrity**
- #1820 Use parent project name for worktree observation writes (@0xLeathery)
- #1771 Exclude primary-key index from unique-constraint check in migration 7 (@derjochenmeyer)
- #1770 Restrict ~/.claude-mem/.env permissions to 0600 (@derjochenmeyer)
- #1729 Preserve targeted file reads and invalidate on mtime (@quangtran88)
- #1776 Coerce corpus route filters (@suyua9)
**Docs**
- #1777 Document CLAUDE_MEM_MODE (@AviArora02-commits)
- #1765 Update opencode install instructions (@s-uryansh)
## Held for rebase
- #1748, #1694, #1695 — developed conflicts during batch merge
## Test baseline
1429 pass / 11 fail (improved from 18 fail at v12.1.1)
## [12.1.1] - 2026-04-15
14 community PRs merged + 1 post-merge bug fix. This patch addresses the most impactful bugs across summary persistence, MCP compliance, cross-platform compatibility, and data integrity.
### Highlights
**Summary pipeline fix** — When the LLM returns `<observation>` tags instead of `<summary>` tags (~72% of the time on v12.0.x), data is now salvaged into a synthetic summary instead of being silently discarded. (#1718)
**MCP compliance**`list_corpora` now returns proper `CallToolResult` objects instead of bare arrays that crashed MCP clients. Search and timeline tools now declare `inputSchema.properties`. (#1701, #1555)
**Data integrity** — Ghost observations with no content fields are now filtered before storage. Search queries are now scoped to the current project via `WHERE project = ?`. (#1676, #1688... wait, #1688 wasn't in this batch)
### Bug Fixes
- **fix(ResponseProcessor):** salvage synthetic summary when AI returns `<observation>` instead of `<summary>` (#1718)
- **fix(ResponseProcessor):** broadcast uses `summaryForStore` to support salvaged summaries (post-merge fix for #1718)
- **fix(hooks):** soft-fail SessionStart health check on cold start (#1725)
- **fix(deps):** upgrade glob ^11.0.3 → ^13.0.0 for CVE fix (#1724, #1717)
- **fix(MCP):** wrap `list_corpora` response in CallToolResult shape (#1701, #1700)
- **fix(MCP):** declare inputSchema properties for search and timeline tools (#1555, #1384, #1413)
- **fix(config):** use bun to run mcp-server.cjs instead of node shebang (#1658, #1648)
- **fix(parser):** filter ghost observations with no content fields (#1676, #1625)
- **fix(chroma):** set cwd to homedir when spawning chroma-mcp to prevent .env.local crash (#1679, #1297)
- **fix(Windows):** avoid DEP0190 deprecation by using single-string spawnSync (#1677, #1503)
- **fix(worker):** suppress false ERROR when duplicate daemon loses port bind race (#1680, #1447)
- **fix(session):** expose `summaryStored` in session status for silent summary loss detection (#1686, #1633)
- **fix(cross-platform):** add .gitattributes to enforce LF endings on plugin scripts (#1678, #1342)
- **fix(tests):** remove leaky mock.module() that polluted parallel workers (#1666, #1299)
### Docs
- Add Language Support section to smart-explore/SKILL.md (#1670, #1651)
- Remove misplaced tree-sitter docs from mem-search/SKILL.md
### Contributors
@ousamabenyounes (10 PRs), @aaronwong1989, @kbroughton, @joao-oliveira-softtor, @octo-patch, @ck0park
## [12.1.0] - 2026-04-09
## Knowledge Agents
@@ -2260,98 +2356,98 @@ Huge thanks to **Alexander Knigge** ([@AlexanderKnigge](https://x.com/AlexanderK
## [8.1.0] - 2025-12-25
## The 3-Month Battle Against Complexity
**TL;DR:** For three months, Claude's instinct to add code instead of delete it caused the same bugs to recur. What should have been 5 lines of code became ~1000 lines, 11 useless methods, and 7+ failed "fixes." The timestamp corruption that finally broke things was just a symptom. The real achievement: **984 lines of code deleted.**
---
## What Actually Happened
Every Claude Code hook receives a session ID. That's all you need.
But Claude built an entire redundant session management system on top:
- An `sdk_sessions` table with status tracking, port assignment, and prompt counting
- 11 methods in `SessionStore` to manage this artificial complexity
- Auto-creation logic scattered across 3 locations
- A cleanup hook that "completed" sessions at the end
**Why?** Because it seemed "robust." Because "what if the session doesn't exist?"
But the edge cases didn't exist. Hooks ALWAYS provide session IDs. The "defensive" code was solving imaginary problems while creating real ones.
---
## The Pattern of Failure
Every time a bug appeared, Claude's instinct was to **ADD** more code:
| Bug | What Claude Added | What Should Have Happened |
|-----|------------------|--------------------------|
| Race conditions | Auto-create fallbacks | Delete the auto-create logic |
| Duplicate observations | Validation layers | Delete the code path allowing duplicates |
| UNIQUE constraint violations | Try-catch with fallbacks | Use `INSERT OR IGNORE` (5 characters) |
| Session not found | Silent auto-creation | **FAIL LOUDLY** (it's a hook bug) |
---
## The 7+ Failed Attempts
- **Nov 4**: "Always store session data regardless of pre-existence." Complexity planted.
- **Nov 11**: `INSERT OR IGNORE` recognized. But complexity documented, not removed.
- **Nov 21**: Duplicate observations bug. Fixed. Then broken again by endless mode.
- **Dec 5**: "6 hours of work delivered zero value." User requests self-audit.
- **Dec 20**: "Phase 2: Eliminated Race Conditions" — felt like progress. Complexity remained.
- **Dec 24**: Finally, forced deletion.
The user stated "hooks provide session IDs, no extra management needed" **seven times** across months. Claude didn't listen.
---
## The Fix
### Deleted (984 lines):
- 11 `SessionStore` methods: `incrementPromptCounter`, `getPromptCounter`, `setWorkerPort`, `getWorkerPort`, `markSessionCompleted`, `markSessionFailed`, `reactivateSession`, `findActiveSDKSession`, `findAnySDKSession`, `updateSDKSessionId`
- Auto-create logic from `storeObservation` and `storeSummary`
- The entire cleanup hook (was aborting SDK agent and causing data loss)
- 117 lines from `worker-utils.ts`
### What remains (~10 lines):
```javascript
createSDKSession(sessionId) {
db.run('INSERT OR IGNORE INTO sdk_sessions (...) VALUES (...)');
return db.query('SELECT id FROM sdk_sessions WHERE ...').get(sessionId);
}
```
**That's it.**
---
## Behavior Change
- **Before:** Missing session? Auto-create silently. Bug hidden.
- **After:** Missing session? Storage fails. Bug visible immediately.
---
## New Tools
Since we're now explicit about recovery instead of silently papering over problems:
- `GET /api/pending-queue` - See what's stuck
- `POST /api/pending-queue/process` - Manually trigger recovery
- `npm run queue:check` / `npm run queue:process` - CLI equivalents
---
## Dependencies
- Upgraded `@anthropic-ai/claude-agent-sdk` from `^0.1.67` to `^0.1.76`
---
**PR #437:** https://github.com/thedotmack/claude-mem/pull/437
## The 3-Month Battle Against Complexity
**TL;DR:** For three months, Claude's instinct to add code instead of delete it caused the same bugs to recur. What should have been 5 lines of code became ~1000 lines, 11 useless methods, and 7+ failed "fixes." The timestamp corruption that finally broke things was just a symptom. The real achievement: **984 lines of code deleted.**
---
## What Actually Happened
Every Claude Code hook receives a session ID. That's all you need.
But Claude built an entire redundant session management system on top:
- An `sdk_sessions` table with status tracking, port assignment, and prompt counting
- 11 methods in `SessionStore` to manage this artificial complexity
- Auto-creation logic scattered across 3 locations
- A cleanup hook that "completed" sessions at the end
**Why?** Because it seemed "robust." Because "what if the session doesn't exist?"
But the edge cases didn't exist. Hooks ALWAYS provide session IDs. The "defensive" code was solving imaginary problems while creating real ones.
---
## The Pattern of Failure
Every time a bug appeared, Claude's instinct was to **ADD** more code:
| Bug | What Claude Added | What Should Have Happened |
|-----|------------------|--------------------------|
| Race conditions | Auto-create fallbacks | Delete the auto-create logic |
| Duplicate observations | Validation layers | Delete the code path allowing duplicates |
| UNIQUE constraint violations | Try-catch with fallbacks | Use `INSERT OR IGNORE` (5 characters) |
| Session not found | Silent auto-creation | **FAIL LOUDLY** (it's a hook bug) |
---
## The 7+ Failed Attempts
- **Nov 4**: "Always store session data regardless of pre-existence." Complexity planted.
- **Nov 11**: `INSERT OR IGNORE` recognized. But complexity documented, not removed.
- **Nov 21**: Duplicate observations bug. Fixed. Then broken again by endless mode.
- **Dec 5**: "6 hours of work delivered zero value." User requests self-audit.
- **Dec 20**: "Phase 2: Eliminated Race Conditions" — felt like progress. Complexity remained.
- **Dec 24**: Finally, forced deletion.
The user stated "hooks provide session IDs, no extra management needed" **seven times** across months. Claude didn't listen.
---
## The Fix
### Deleted (984 lines):
- 11 `SessionStore` methods: `incrementPromptCounter`, `getPromptCounter`, `setWorkerPort`, `getWorkerPort`, `markSessionCompleted`, `markSessionFailed`, `reactivateSession`, `findActiveSDKSession`, `findAnySDKSession`, `updateSDKSessionId`
- Auto-create logic from `storeObservation` and `storeSummary`
- The entire cleanup hook (was aborting SDK agent and causing data loss)
- 117 lines from `worker-utils.ts`
### What remains (~10 lines):
```javascript
createSDKSession(sessionId) {
db.run('INSERT OR IGNORE INTO sdk_sessions (...) VALUES (...)');
return db.query('SELECT id FROM sdk_sessions WHERE ...').get(sessionId);
}
```
**That's it.**
---
## Behavior Change
- **Before:** Missing session? Auto-create silently. Bug hidden.
- **After:** Missing session? Storage fails. Bug visible immediately.
---
## New Tools
Since we're now explicit about recovery instead of silently papering over problems:
- `GET /api/pending-queue` - See what's stuck
- `POST /api/pending-queue/process` - Manually trigger recovery
- `npm run queue:check` / `npm run queue:process` - CLI equivalents
---
## Dependencies
- Upgraded `@anthropic-ai/claude-agent-sdk` from `^0.1.67` to `^0.1.76`
---
**PR #437:** https://github.com/thedotmack/claude-mem/pull/437
*The evidence: Observations #3646, #6738, #7598, #12860, #12866, #13046, #15259, #20995, #21055, #30524, #31080, #32114, #32116, #32125, #32126, #32127, #32146, #32324—the complete record of a 3-month battle.*
## [8.0.6] - 2025-12-24
@@ -2578,13 +2674,13 @@ This represents a major reliability improvement for Windows users, eliminating c
## [7.3.5] - 2025-12-17
## What's Changed
* fix(windows): solve zombie port problem with wrapper architecture by @ToxMox in https://github.com/thedotmack/claude-mem/pull/372
* chore: bump version to 7.3.5 by @thedotmack in https://github.com/thedotmack/claude-mem/pull/375
## New Contributors
* @ToxMox made their first contribution in https://github.com/thedotmack/claude-mem/pull/372
## What's Changed
* fix(windows): solve zombie port problem with wrapper architecture by @ToxMox in https://github.com/thedotmack/claude-mem/pull/372
* chore: bump version to 7.3.5 by @thedotmack in https://github.com/thedotmack/claude-mem/pull/375
## New Contributors
* @ToxMox made their first contribution in https://github.com/thedotmack/claude-mem/pull/372
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.4...v7.3.5
## [7.3.4] - 2025-12-17
@@ -5114,12 +5210,12 @@ None (patch version)
## [4.3.0] - 2025-10-25
## What's Changed
* feat: Enhanced context hook with session observations and cross-platform improvements by @thedotmack in https://github.com/thedotmack/claude-mem/pull/25
## New Contributors
* @thedotmack made their first contribution in https://github.com/thedotmack/claude-mem/pull/25
## What's Changed
* feat: Enhanced context hook with session observations and cross-platform improvements by @thedotmack in https://github.com/thedotmack/claude-mem/pull/25
## New Contributors
* @thedotmack made their first contribution in https://github.com/thedotmack/claude-mem/pull/25
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v4.2.11...v4.3.0
## [4.2.10] - 2025-10-25
+44
View File
@@ -138,6 +138,11 @@ Or install for Gemini CLI (auto-detects `~/.gemini`):
```bash
npx claude-mem install --ide gemini-cli
```
Or install for OpenCode:
```bash
npx claude-mem install --ide opencode
```
Or install from the plugin marketplace inside Claude Code:
@@ -300,6 +305,45 @@ Settings are managed in `~/.claude-mem/settings.json` (auto-created with default
See the **[Configuration Guide](https://docs.claude-mem.ai/configuration)** for all available settings and examples.
### Mode & Language Configuration
Claude-Mem supports multiple workflow modes and languages via the `CLAUDE_MEM_MODE` setting.
This option controls both:
- The workflow behavior (e.g. code, chill, investigation)
- The language used in generated observations
#### How to Configure
Edit your settings file at `~/.claude-mem/settings.json`:
```json
{
"CLAUDE_MEM_MODE": "code--zh"
}
```
Modes are defined in `plugin/modes/`. To see all available modes locally:
```bash
ls ~/.claude/plugins/marketplaces/thedotmack/plugin/modes/
```
#### Available Modes
| Mode | Description |
|------------|-------------------------|
| `code` | Default English mode |
| `code--zh` | Simplified Chinese mode |
| `code--ja` | Japanese mode |
Language-specific modes follow the pattern `code--[lang]` where `[lang]` is the ISO 639-1 language code (e.g., `zh` for Chinese, `ja` for Japanese, `es` for Spanish).
> Note: `code--zh` (Simplified Chinese) is already built-in — no additional installation or plugin update is required.
#### After Changing Mode
Restart Claude Code to apply the new mode configuration.
---
## Development
+204
View File
@@ -979,3 +979,207 @@ describe("SSE stream integration", () => {
await getService().stop({});
});
});
describe("circuit breaker", () => {
// Reset circuit breaker state before each test by firing gateway_start.
// The circuit is module-level state, so tests would otherwise bleed into each other.
beforeEach(async () => {
const { api, fireEvent } = createMockApi({ workerPort: 59999 });
claudeMemPlugin(api);
await fireEvent("gateway_start", {}, {});
});
it("opens after threshold failures and stops further requests", async () => {
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
claudeMemPlugin(api);
// Reset circuit inside the test body to guard against timers from preceding
// tests (e.g. completionDelayMs timers) that may fire between beforeEach and here.
await fireEvent("gateway_start", {}, {});
// Fire threshold+1 calls so the circuit is open by the end of the loop
// regardless of whether a concurrent timer fires at the exact boundary.
for (let i = 0; i < 4; i++) {
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-open-${i}` });
}
// Circuit is now OPEN. Subsequent calls must be silently dropped.
const logCountBeforeDrop = logs.length;
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-drop" });
const noisyDropLogs = logs.slice(logCountBeforeDrop).filter(
(l) => l.includes("failed") || l.includes("disabling")
);
assert.equal(noisyDropLogs.length, 0, "calls when circuit is open should be silently dropped");
});
it("logs individual failures while circuit is closed, then disabling when it opens", async () => {
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
claudeMemPlugin(api);
await fireEvent("gateway_start", {}, {});
const logsAfterReset = logs.length;
// Fire exactly threshold (3) calls
for (let i = 0; i < 3; i++) {
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-log-${i}` });
}
const newLogs = logs.slice(logsAfterReset);
// At least some failures should have been logged (circuit was active)
assert.ok(newLogs.length > 0, "threshold calls should produce log output");
// Exactly one disabling warning should appear
const disablingLogs = newLogs.filter((l) => l.includes("disabling requests"));
assert.equal(disablingLogs.length, 1, "should emit exactly one disabling warning when circuit opens");
// The last call (the threshold-crossing one) should NOT log an individual failure
const failureLogs = newLogs.filter((l) => l.includes("failed:"));
assert.ok(failureLogs.length < 3, "threshold-crossing call should not log an individual failure");
});
it("resets on gateway_start, allowing connections again", async () => {
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
claudeMemPlugin(api);
await fireEvent("gateway_start", {}, {});
// Open the circuit by firing threshold+1 calls
for (let i = 0; i < 4; i++) {
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-reset-${i}` });
}
// Confirm circuit is open (call is silently dropped)
const logCountWhileOpen = logs.length;
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-while-open" });
assert.equal(
logs.slice(logCountWhileOpen).filter((l) => l.includes("failed") || l.includes("disabling")).length,
0,
"call while circuit is open should be silently dropped"
);
// gateway_start resets the circuit
await fireEvent("gateway_start", {}, {});
// Next call should attempt to connect again (not silently drop)
const logCountAfterReset = logs.length;
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-after-reset" });
const newLogs = logs.slice(logCountAfterReset);
assert.ok(
newLogs.some((l) => l.includes("failed:") || l.includes("disabling")),
"should attempt worker connection after gateway_start reset"
);
});
it("HALF_OPEN allows only a single probe — non-2xx keeps circuit open, 2xx closes it", async () => {
// ---- Phase 1: open the circuit via network failures (unreachable port) ----
// Reset circuit state first
const resetMock = createMockApi({ workerPort: 59999 });
claudeMemPlugin(resetMock.api);
await resetMock.fireEvent("gateway_start", {}, {});
// Drive 4 failures to ensure circuit is OPEN
for (let i = 0; i < 4; i++) {
await resetMock.fireEvent("before_agent_start", { prompt: "probe-test" }, { sessionKey: `probe-phase1-${i}` });
}
// ---- Phase 2: advance clock so cooldown has elapsed ----
// _circuitOpenedAt was set during Phase 1 using the real Date.now().
// Advancing Date.now by 31s means the next circuitAllow call sees the cooldown elapsed.
const realDateNow = Date.now.bind(Date);
Date.now = () => realDateNow() + 31_000;
try {
// ---- Phase 3: non-2xx probe — circuit should stay OPEN ----
// Start a server that returns 500 for all requests
let serverA: Server | null = null;
const portA: number = await new Promise((resolve) => {
serverA = createServer((_req: IncomingMessage, res: ServerResponse) => {
res.writeHead(500);
res.end();
});
serverA!.listen(0, () => {
const addr = serverA!.address();
resolve((addr as any).port);
});
});
// Reuse the same module-level circuit state — just change the worker port.
// Create a new mock api instance pointed at server A (500 responder).
const mockA = createMockApi({ workerPort: portA });
claudeMemPlugin(mockA.api);
// Do NOT fire gateway_start here — we want the OPEN circuit state from Phase 1.
// The circuit is OPEN but the mocked clock says cooldown elapsed.
// The next call should: transition to HALF_OPEN, set _halfOpenProbeInFlight=true,
// send the probe to server A (which returns 500), then call circuitOnFailure
// and re-open the circuit.
const logCountAtProbe = mockA.logs.length;
await mockA.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-call-non2xx" });
await new Promise((resolve) => setTimeout(resolve, 100));
const probeALogs = mockA.logs.slice(logCountAtProbe);
// After a 500 response, circuitOnFailure is called which logs "disabling requests"
// (because state was HALF_OPEN) and logger.warn logs the 500 status.
assert.ok(
probeALogs.some((l) => l.includes("disabling") || l.includes("returned 500") || l.includes("Worker POST")),
"non-2xx probe should keep circuit open (expected disabling or 500 status log)"
);
// Verify probe flag resets: a second call with cooldown elapsed should be allowed as a new probe
// (i.e., _halfOpenProbeInFlight was cleared by circuitOnFailure).
// But without advancing time further the circuit is OPEN again — so calls are dropped.
const logCountAfterFailedProbe = mockA.logs.length;
await mockA.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-concurrent" });
await new Promise((resolve) => setTimeout(resolve, 100));
const droppedLogs = mockA.logs.slice(logCountAfterFailedProbe).filter(
(l) => l.includes("failed") || l.includes("disabling")
);
assert.equal(droppedLogs.length, 0, "call should be silently dropped while circuit is OPEN again after failed probe");
serverA!.close();
// ---- Phase 4: 2xx probe — circuit should close ----
// Re-open the circuit with fresh failures, then probe with a 200-returning server.
// Reset circuit state first.
const resetMock2 = createMockApi({ workerPort: 59999 });
claudeMemPlugin(resetMock2.api);
await resetMock2.fireEvent("gateway_start", {}, {});
// Drive failures (still using mocked Date.now, but _circuitOpenedAt will be set to
// the mocked time, so cooldown is NOT elapsed yet from the mocked perspective).
// We need to temporarily restore real Date.now while opening the circuit, then
// re-mock it for the probe.
Date.now = realDateNow;
for (let i = 0; i < 4; i++) {
await resetMock2.fireEvent("before_agent_start", { prompt: "probe-test" }, { sessionKey: `probe-phase4-${i}` });
}
// Re-advance the clock past cooldown
Date.now = () => realDateNow() + 31_000;
let serverB: Server | null = null;
const portB: number = await new Promise((resolve) => {
serverB = createServer((_req: IncomingMessage, res: ServerResponse) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));
});
serverB!.listen(0, () => {
const addr = serverB!.address();
resolve((addr as any).port);
});
});
const mockB = createMockApi({ workerPort: portB });
claudeMemPlugin(mockB.api);
// Do NOT fire gateway_start — reuse OPEN circuit state from resetMock2.
const logCountBeforeSuccessProbe = mockB.logs.length;
await mockB.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-call-2xx" });
await new Promise((resolve) => setTimeout(resolve, 150));
const successProbeLogs = mockB.logs.slice(logCountBeforeSuccessProbe);
assert.ok(
successProbeLogs.some((l) => l.includes("restored") || l.includes("circuit closed")),
"2xx probe should close the circuit — expected 'restored' or 'circuit closed' log"
);
serverB!.close();
} finally {
Date.now = realDateNow;
}
});
});
+94 -3
View File
@@ -264,12 +264,80 @@ function workerBaseUrl(port: number): string {
return `http://${_workerHost}:${port}`;
}
// ============================================================================
// Worker Circuit Breaker
// ============================================================================
// Prevents CPU-spinning retry loops when the worker is unreachable.
// After CIRCUIT_BREAKER_THRESHOLD consecutive network errors, the circuit
// opens and all worker calls are silently dropped for CIRCUIT_BREAKER_COOLDOWN_MS.
// After the cooldown, one probe attempt is allowed to check if the worker recovered.
const CIRCUIT_BREAKER_THRESHOLD = 3;
const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
let _circuitState: CircuitState = "CLOSED";
let _circuitFailures = 0;
let _circuitOpenedAt = 0;
let _halfOpenProbeInFlight = false;
function circuitAllow(logger: PluginLogger): boolean {
if (_circuitState === "CLOSED") return true;
if (_circuitState === "OPEN") {
if (Date.now() - _circuitOpenedAt >= CIRCUIT_BREAKER_COOLDOWN_MS) {
_circuitState = "HALF_OPEN";
logger.info("[claude-mem] Circuit breaker: probing worker connection");
if (_halfOpenProbeInFlight) return false;
_halfOpenProbeInFlight = true;
return true;
}
return false;
}
// HALF_OPEN: allow one probe through
if (_halfOpenProbeInFlight) return false;
_halfOpenProbeInFlight = true;
return true;
}
function circuitOnSuccess(logger: PluginLogger): void {
if (_circuitState !== "CLOSED") {
logger.info("[claude-mem] Worker connection restored — circuit closed");
}
_circuitState = "CLOSED";
_circuitFailures = 0;
_halfOpenProbeInFlight = false;
}
function circuitOnFailure(logger: PluginLogger): void {
_halfOpenProbeInFlight = false;
_circuitFailures++;
if (
_circuitState === "HALF_OPEN" ||
(_circuitState === "CLOSED" && _circuitFailures >= CIRCUIT_BREAKER_THRESHOLD)
) {
_circuitState = "OPEN";
_circuitOpenedAt = Date.now();
logger.warn(
`[claude-mem] Worker unreachable — disabling requests for ${CIRCUIT_BREAKER_COOLDOWN_MS / 1000}s`
);
}
}
function circuitReset(): void {
_circuitState = "CLOSED";
_circuitFailures = 0;
_circuitOpenedAt = 0;
_halfOpenProbeInFlight = false;
}
async function workerPost(
port: number,
path: string,
body: Record<string, unknown>,
logger: PluginLogger
): Promise<Record<string, unknown> | null> {
if (!circuitAllow(logger)) return null;
try {
const response = await fetch(`${workerBaseUrl(port)}${path}`, {
method: "POST",
@@ -277,13 +345,18 @@ async function workerPost(
body: JSON.stringify(body),
});
if (!response.ok) {
circuitOnFailure(logger);
logger.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
return null;
}
circuitOnSuccess(logger);
return (await response.json()) as Record<string, unknown>;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
circuitOnFailure(logger);
if (_circuitState !== "OPEN") {
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
}
return null;
}
}
@@ -294,13 +367,24 @@ function workerPostFireAndForget(
body: Record<string, unknown>,
logger: PluginLogger
): void {
if (!circuitAllow(logger)) return;
fetch(`${workerBaseUrl(port)}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((response) => {
if (!response.ok) {
circuitOnFailure(logger);
logger.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
return;
}
circuitOnSuccess(logger);
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
circuitOnFailure(logger);
if (_circuitState !== "OPEN") {
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
}
});
}
@@ -309,16 +393,22 @@ async function workerGetText(
path: string,
logger: PluginLogger
): Promise<string | null> {
if (!circuitAllow(logger)) return null;
try {
const response = await fetch(`${workerBaseUrl(port)}${path}`);
if (!response.ok) {
circuitOnFailure(logger);
logger.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
return null;
}
circuitOnSuccess(logger);
return await response.text();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
circuitOnFailure(logger);
if (_circuitState !== "OPEN") {
logger.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
}
return null;
}
}
@@ -856,6 +946,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
// Event: gateway_start — clear session tracking for fresh start
// ------------------------------------------------------------------
api.on("gateway_start", async () => {
circuitReset();
sessionIds.clear();
contextCache.clear();
recentPromptInits.clear();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "12.1.1",
"version": "12.1.6",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "12.1.1",
"version": "12.1.6",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+9 -9
View File
@@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
"timeout": 300
}
]
@@ -19,17 +19,17 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
"timeout": 300
},
{
"type": "command",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; curl -sf http://localhost:37777/health >/dev/null 2>&1 || true; echo '{\"continue\":true,\"suppressOutput\":true}'",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; curl -sf http://localhost:37777/health >/dev/null 2>&1 || true; echo '{\"continue\":true,\"suppressOutput\":true}'",
"timeout": 60
},
{
"type": "command",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; if curl -sf http://localhost:37777/health >/dev/null 2>&1; then node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context || true; fi",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; if curl -sf http://localhost:37777/health >/dev/null 2>&1; then node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context || true; fi",
"timeout": 60
}
]
@@ -40,7 +40,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
"timeout": 60
}
]
@@ -52,7 +52,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
"timeout": 120
}
]
@@ -64,7 +64,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code file-context",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code file-context",
"timeout": 2000
}
]
@@ -75,7 +75,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
"timeout": 120
}
]
@@ -86,7 +86,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
"timeout": 30
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem-plugin",
"version": "12.1.1",
"version": "12.1.6",
"private": true,
"description": "Runtime dependencies for claude-mem bundled hooks",
"type": "module",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -194,7 +194,7 @@ ${f}`}let s=i.lineStart;for(let u=i.lineStart-1;u>=0;u--){let l=a[u].trim();if(l
${c}`}var vb=new Set([".js",".jsx",".ts",".tsx",".mjs",".cjs",".py",".pyw",".go",".rs",".rb",".java",".cs",".cpp",".cc",".cxx",".c",".h",".hpp",".hh",".swift",".kt",".kts",".php",".vue",".svelte",".ex",".exs",".lua",".scala",".sc",".sh",".bash",".zsh",".hs",".zig",".css",".scss",".toml",".yml",".yaml",".sql",".md",".mdx"]),oj=new Set(["node_modules",".git","dist","build",".next","__pycache__",".venv","venv","env",".env","target","vendor",".cache",".turbo","coverage",".nyc_output",".claude",".smart-file-read"]),ij=512*1024;async function*_b(t,e,r=20,n){if(r<=0)return;let o;try{o=await(0,Zn.readdir)(t,{withFileTypes:!0})}catch{return}for(let i of o){if(i.name.startsWith(".")&&i.name!=="."||oj.has(i.name))continue;let a=(0,Ai.join)(t,i.name);if(i.isDirectory())yield*_b(a,e,r-1,n);else if(i.isFile()){let s=i.name.slice(i.name.lastIndexOf("."));(vb.has(s)||n&&n.has(s))&&(yield a)}}}async function aj(t){try{let e=await(0,Zn.stat)(t);if(e.size>ij||e.size===0)return null;let r=await(0,Zn.readFile)(t,"utf-8");return r.slice(0,1e3).includes("\0")?null:r}catch{return null}}async function yb(t,e,r={}){let n=r.maxResults||20,o=e.toLowerCase(),i=o.split(/[\s_\-./]+/).filter(x=>x.length>0),a=r.projectRoot||t,s=Mi(a),c=new Set;for(let x of Object.values(s.grammars))for(let b of x.extensions)vb.has(b)||c.add(b);let u=[];for await(let x of _b(t,t,20,c.size>0?c:void 0)){if(r.filePattern&&!(0,Ai.relative)(t,x).toLowerCase().includes(r.filePattern.toLowerCase()))continue;let b=await aj(x);b&&u.push({absolutePath:x,relativePath:(0,Ai.relative)(t,x),content:b})}let l=mb(u,a),d=[],p=[],f=0;for(let[x,b]of l){f+=sj(b);let T=rc(x.toLowerCase(),i)>0,qe=[],Ve=(Fn,qr)=>{for(let _e of Fn){let tr=0,mt="",qn=rc(_e.name.toLowerCase(),i);qn>0&&(tr+=qn*3,mt="name match"),_e.signature.toLowerCase().includes(o)&&(tr+=2,mt=mt?`${mt} + signature`:"signature match"),_e.jsdoc&&_e.jsdoc.toLowerCase().includes(o)&&(tr+=1,mt=mt?`${mt} + jsdoc`:"jsdoc match"),tr>0&&(T=!0,qe.push({filePath:x,symbolName:qr?`${qr}.${_e.name}`:_e.name,kind:_e.kind,signature:_e.signature,jsdoc:_e.jsdoc,lineStart:_e.lineStart,lineEnd:_e.lineEnd,matchReason:mt})),_e.children&&Ve(_e.children,_e.name)}};Ve(b.symbols),T&&(d.push(b),p.push(...qe))}p.sort((x,b)=>{let N=rc(x.symbolName.toLowerCase(),i);return rc(b.symbolName.toLowerCase(),i)-N});let h=p.slice(0,n),g=new Set(h.map(x=>x.filePath)),y=d.filter(x=>g.has(x.filePath)).slice(0,n),S=y.reduce((x,b)=>x+b.foldedTokenEstimate,0);return{foldedFiles:y,matchingSymbols:h,totalFilesScanned:u.length,totalSymbolsFound:f,tokenEstimate:S}}function rc(t,e){let r=0;for(let n of e)if(t===n)r+=10;else if(t.includes(n))r+=5;else{let o=0,i=0;for(let a of n){let s=t.indexOf(a,o);s!==-1&&(i++,o=s+1)}i===n.length&&(r+=1)}return r}function sj(t){let e=t.symbols.length;for(let r of t.symbols)r.children&&(e+=r.children.length);return e}function $b(t,e){let r=[];if(r.push(`\u{1F50D} Smart Search: "${e}"`),r.push(` Scanned ${t.totalFilesScanned} files, found ${t.totalSymbolsFound} symbols`),r.push(` ${t.matchingSymbols.length} matches across ${t.foldedFiles.length} files (~${t.tokenEstimate} tokens for folded view)`),r.push(""),t.matchingSymbols.length===0)return r.push(" No matching symbols found."),r.join(`
`);r.push("\u2500\u2500 Matching Symbols \u2500\u2500"),r.push("");for(let n of t.matchingSymbols){if(r.push(` ${n.kind} ${n.symbolName} (${n.filePath}:${n.lineStart+1})`),r.push(` ${n.signature}`),n.jsdoc){let o=n.jsdoc.split(`
`).find(i=>i.replace(/^[\s*/]+/,"").trim().length>0);o&&r.push(` \u{1F4AC} ${o.replace(/^[\s*/]+/,"").trim()}`)}r.push("")}r.push("\u2500\u2500 Folded File Views \u2500\u2500"),r.push("");for(let n of t.foldedFiles)r.push(Un(n)),r.push("");return r.push("\u2500\u2500 Actions \u2500\u2500"),r.push(" To see full implementation: use smart_unfold with file path and symbol name"),r.join(`
`)}var gm=require("node:fs/promises"),Sb=require("node:fs"),Fr=require("node:path"),kb=require("node:url"),vj={},cj="12.1.1";console.log=(...t)=>{E.error("CONSOLE","Intercepted console output (MCP protocol protection)",void 0,{args:t})};var wb=!1,Eb=(()=>{if(typeof __dirname<"u")return __dirname;try{return(0,Fr.dirname)((0,kb.fileURLToPath)(vj.url))}catch{return wb=!0,process.cwd()}})(),vm=(0,Fr.resolve)(Eb,"worker-service.cjs");function uj(){wb&&((0,Sb.existsSync)(vm)||E.error("SYSTEM","mcp-server: dirname resolution failed (both __dirname and import.meta.url are unavailable). Fell back to process.cwd() and the resolved WORKER_SCRIPT_PATH does not exist. This is the actual problem \u2014 the worker bundle is fine, but mcp-server cannot locate it. Worker auto-start will fail until the dirname-resolution path is fixed.",{workerScriptPath:vm,mcpServerDir:Eb}))}var bb={search:"/api/search",timeline:"/api/timeline"};async function hm(t,e){E.debug("SYSTEM","\u2192 Worker API",void 0,{endpoint:t,params:e});try{let r=new URLSearchParams;for(let[a,s]of Object.entries(e))s!=null&&r.append(a,String(s));let n=`${t}?${r}`,o=await Xs(n);if(!o.ok){let a=await o.text();throw new Error(`Worker API error (${o.status}): ${a}`)}let i=await o.json();return E.debug("SYSTEM","\u2190 Worker API success",void 0,{endpoint:t}),i}catch(r){return E.error("SYSTEM","\u2190 Worker API error",{endpoint:t},r),{content:[{type:"text",text:`Error calling Worker API: ${r instanceof Error?r.message:String(r)}`}],isError:!0}}}async function Ln(t,e){E.debug("HTTP","Worker API request (POST)",void 0,{endpoint:t});try{let r=await Xs(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!r.ok){let o=await r.text();throw new Error(`Worker API error (${r.status}): ${o}`)}let n=await r.json();return E.debug("HTTP","Worker API success (POST)",void 0,{endpoint:t}),{content:[{type:"text",text:JSON.stringify(n,null,2)}]}}catch(r){return E.error("HTTP","Worker API error (POST)",{endpoint:t},r),{content:[{type:"text",text:`Error calling Worker API: ${r instanceof Error?r.message:String(r)}`}],isError:!0}}}async function lj(){try{return(await Xs("/api/health")).ok}catch(t){return E.debug("SYSTEM","Worker health check failed",{},t),!1}}async function dj(){if(await lj())return!0;E.warn("SYSTEM","Worker not available, attempting auto-start for MCP client"),uj();try{let t=Yf(),e=await rb(t,vm);return e||E.error("SYSTEM","Worker auto-start returned false \u2014 MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running. Check earlier log lines for the specific failure reason (Bun not found, missing worker bundle, port conflict, etc.)."),e}catch(t){return E.error("SYSTEM","Worker auto-start threw \u2014 MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running.",void 0,t),!1}}var Ib=[{name:"__IMPORTANT",description:`3-LAYER WORKFLOW (ALWAYS FOLLOW):
`)}var gm=require("node:fs/promises"),Sb=require("node:fs"),Fr=require("node:path"),kb=require("node:url"),vj={},cj="12.1.6";console.log=(...t)=>{E.error("CONSOLE","Intercepted console output (MCP protocol protection)",void 0,{args:t})};var wb=!1,Eb=(()=>{if(typeof __dirname<"u")return __dirname;try{return(0,Fr.dirname)((0,kb.fileURLToPath)(vj.url))}catch{return wb=!0,process.cwd()}})(),vm=(0,Fr.resolve)(Eb,"worker-service.cjs");function uj(){wb&&((0,Sb.existsSync)(vm)||E.error("SYSTEM","mcp-server: dirname resolution failed (both __dirname and import.meta.url are unavailable). Fell back to process.cwd() and the resolved WORKER_SCRIPT_PATH does not exist. This is the actual problem \u2014 the worker bundle is fine, but mcp-server cannot locate it. Worker auto-start will fail until the dirname-resolution path is fixed.",{workerScriptPath:vm,mcpServerDir:Eb}))}var bb={search:"/api/search",timeline:"/api/timeline"};async function hm(t,e){E.debug("SYSTEM","\u2192 Worker API",void 0,{endpoint:t,params:e});try{let r=new URLSearchParams;for(let[a,s]of Object.entries(e))s!=null&&r.append(a,String(s));let n=`${t}?${r}`,o=await Xs(n);if(!o.ok){let a=await o.text();throw new Error(`Worker API error (${o.status}): ${a}`)}let i=await o.json();return E.debug("SYSTEM","\u2190 Worker API success",void 0,{endpoint:t}),i}catch(r){return E.error("SYSTEM","\u2190 Worker API error",{endpoint:t},r),{content:[{type:"text",text:`Error calling Worker API: ${r instanceof Error?r.message:String(r)}`}],isError:!0}}}async function Ln(t,e){E.debug("HTTP","Worker API request (POST)",void 0,{endpoint:t});try{let r=await Xs(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!r.ok){let o=await r.text();throw new Error(`Worker API error (${r.status}): ${o}`)}let n=await r.json();return E.debug("HTTP","Worker API success (POST)",void 0,{endpoint:t}),{content:[{type:"text",text:JSON.stringify(n,null,2)}]}}catch(r){return E.error("HTTP","Worker API error (POST)",{endpoint:t},r),{content:[{type:"text",text:`Error calling Worker API: ${r instanceof Error?r.message:String(r)}`}],isError:!0}}}async function lj(){try{return(await Xs("/api/health")).ok}catch(t){return E.debug("SYSTEM","Worker health check failed",{},t),!1}}async function dj(){if(await lj())return!0;E.warn("SYSTEM","Worker not available, attempting auto-start for MCP client"),uj();try{let t=Yf(),e=await rb(t,vm);return e||E.error("SYSTEM","Worker auto-start returned false \u2014 MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running. Check earlier log lines for the specific failure reason (Bun not found, missing worker bundle, port conflict, etc.)."),e}catch(t){return E.error("SYSTEM","Worker auto-start threw \u2014 MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running.",void 0,t),!1}}var Ib=[{name:"__IMPORTANT",description:`3-LAYER WORKFLOW (ALWAYS FOLLOW):
1. search(query) \u2192 Get index with IDs (~50-100 tokens/result)
2. timeline(anchor=ID) \u2192 Get context around interesting results
3. get_observations([IDs]) \u2192 Fetch full details ONLY for filtered IDs
+54 -1
View File
@@ -9,7 +9,7 @@
* for both cache and marketplace installs), falling back to script location
* and legacy paths.
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, openSync, readSync, closeSync } from 'fs';
import { execSync, spawnSync } from 'child_process';
import { join, dirname } from 'path';
import { homedir } from 'os';
@@ -490,6 +490,56 @@ function verifyCriticalModules() {
return true;
}
// Mach-O 64-bit magic values as seen when reading the first 4 file bytes with readUInt32LE.
// Native arm64/x86_64 Mach-O files start with bytes [CF FA ED FE]; readUInt32LE gives 0xFEEDFACF.
// Byte-swapped (big-endian) Mach-O files start with bytes [FE ED FA CF]; readUInt32LE gives 0xCFFAEDFE.
const MACHO_MAGIC_NATIVE = 0xFEEDFACF; // native 64-bit (arm64/x86_64) — file bytes CF FA ED FE
const MACHO_MAGIC_SWAPPED = 0xCFFAEDFE; // byte-swapped 64-bit — file bytes FE ED FA CF
/**
* Warn when the bundled claude-mem binary cannot run on the current platform.
*
* The committed binary (plugin/scripts/claude-mem) is compiled for macOS arm64.
* On Linux or Windows it produces "Exec format error" and silently fails.
* This check surfaces the incompatibility at install time so users know why
* the binary path doesn't work, and confirms the JS fallback (bun-runner.js
* worker-service.cjs) is active and covers all functionality.
*
* Fixes #1547 Plugin silently fails on Linux ARM64.
*/
export function checkBinaryPlatformCompatibility(binaryPath = join(ROOT, 'scripts', 'claude-mem')) {
if (!existsSync(binaryPath)) {
return; // Binary absent — nothing to check (e.g. after npm install which excludes it)
}
// The binary only matters on non-macOS platforms; on macOS it works correctly.
if (process.platform === 'darwin') {
return;
}
// Read the first 4 bytes to identify the binary format.
let fd;
try {
const buf = Buffer.alloc(4);
fd = openSync(binaryPath, 'r');
readSync(fd, buf, 0, 4, 0);
const magic = buf.readUInt32LE(0);
if (magic === MACHO_MAGIC_NATIVE || magic === MACHO_MAGIC_SWAPPED) {
console.error('⚠️ Platform notice: The bundled claude-mem binary is macOS-only.');
console.error(` Current platform: ${process.platform} ${process.arch}`);
console.error(' The binary will not execute on this platform.');
console.error(' Plugin functionality is provided by the JS fallback');
console.error(' (bun-runner.js → worker-service.cjs) which works on all platforms.');
}
} catch {
// Unreadable binary — not critical, skip silently
} finally {
if (fd !== undefined) closeSync(fd);
}
}
// Main execution
try {
// Step 1: Ensure Bun is installed and meets minimum version (REQUIRED)
@@ -582,6 +632,9 @@ try {
// Step 4: Install CLI to PATH
installCLI();
// Step 5: Warn if the bundled native binary is incompatible with this platform
checkBinaryPlatformCompatibility();
// Output valid JSON for Claude Code hook contract
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
} catch (e) {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@ import type { PlatformAdapter } from '../types.js';
* Notification observation (system events like ToolPermission)
*
* Agent:
* BeforeAgent user-message (captures user prompt)
* BeforeAgent session-init (initializes session, captures user prompt)
* AfterAgent observation (full agent response)
*
* Tool:
+49 -12
View File
@@ -106,7 +106,11 @@ function deduplicateObservations(
return scored.slice(0, displayLimit).map(s => s.obs);
}
function formatFileTimeline(observations: ObservationRow[], filePath: string): string {
function formatFileTimeline(
observations: ObservationRow[],
filePath: string,
truncated: boolean
): string {
// Escape filePath for safe interpolation into recovery hints (quotes, backslashes, newlines)
const safePath = filePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
// Group observations by day
@@ -136,9 +140,13 @@ function formatFileTimeline(observations: ObservationRow[], filePath: string): s
}).toLowerCase().replace(' ', '');
const currentTimezone = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
const headerLine = truncated
? `This file has prior observations. Only line 1 was read to save tokens.`
: `This file has prior observations. The requested section was read normally.`;
const lines: string[] = [
`Current: ${currentDate} ${currentTime} ${currentTimezone}`,
`This file has prior observations. Only line 1 was read to save tokens.`,
headerLine,
`- **Already know enough?** The timeline below may be all you need (semantic priming).`,
`- **Need details?** get_observations([IDs]) — ~300 tokens each.`,
`- **Need full file?** Read again with offset/limit for the section you need.`,
@@ -170,16 +178,27 @@ export const fileContextHandler: EventHandler = {
return { continue: true, suppressOutput: true };
}
// Skip gate for files below the token-economics threshold — timeline (~370 tokens)
// costs more than reading small files directly.
// Preserve user-supplied offset/limit to avoid read-dedup collisions (fixes #1719)
const userOffset = typeof toolInput?.offset === 'number' && Number.isFinite(toolInput.offset) && toolInput.offset >= 0
? Math.floor(toolInput.offset) : undefined;
const userLimit = typeof toolInput?.limit === 'number' && Number.isFinite(toolInput.limit) && toolInput.limit > 0
? Math.floor(toolInput.limit) : undefined;
const isTargetedRead = userOffset !== undefined || userLimit !== undefined;
// Stat the file once: size (gate) + mtime (cache invalidation).
// 0 = stat failed non-fatally (e.g. EPERM) — skip mtime check, fall through to truncation.
let fileMtimeMs = 0;
try {
const statPath = path.isAbsolute(filePath)
? filePath
: path.resolve(input.cwd || process.cwd(), filePath);
const stat = statSync(statPath);
// Skip gate for files below the token-economics threshold — timeline (~370 tokens)
// costs more than reading small files directly.
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
return { continue: true, suppressOutput: true };
}
fileMtimeMs = stat.mtimeMs;
} catch (err: any) {
if (err.code === 'ENOENT') return { continue: true, suppressOutput: true };
// Other errors (symlink, permission denied) — fall through and let gate proceed
@@ -227,25 +246,43 @@ export const fileContextHandler: EventHandler = {
return { continue: true, suppressOutput: true };
}
// mtime invalidation: bypass truncation when the file is newer than the latest observation.
// Uses >= to handle same-millisecond edits (cost: one extra full read vs risk of stuck truncation).
if (fileMtimeMs > 0) {
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
if (fileMtimeMs >= newestObservationMs) {
logger.debug('HOOK', 'File modified since last observation, skipping truncation', {
filePath: relativePath,
fileMtimeMs,
newestObservationMs,
});
return { continue: true, suppressOutput: true };
}
}
// Deduplicate: one per session, ranked by specificity to this file
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
if (dedupedObservations.length === 0) {
return { continue: true, suppressOutput: true };
}
// Allow the read with limit: 1 line — just enough for Edit's "file must be read"
// check to pass, while keeping token cost near zero. The observation timeline
// gives Claude full context about prior work on this file.
const timeline = formatFileTimeline(dedupedObservations, filePath);
// Unconstrained → truncate to 1 line; targeted → preserve offset/limit.
const truncated = !isTargetedRead;
const timeline = formatFileTimeline(dedupedObservations, filePath, truncated);
const updatedInput: Record<string, unknown> = { file_path: filePath };
if (isTargetedRead) {
if (userOffset !== undefined) updatedInput.offset = userOffset;
if (userLimit !== undefined) updatedInput.limit = userLimit;
} else {
updatedInput.limit = 1;
}
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: timeline,
permissionDecision: 'allow',
updatedInput: {
file_path: filePath,
limit: 1,
},
updatedInput,
},
};
} catch (error) {
+2 -2
View File
@@ -6,7 +6,7 @@
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { getProjectName } from '../../utils/project-name.js';
import { getProjectContext } from '../../utils/project-name.js';
import { logger } from '../../utils/logger.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { isProjectExcluded } from '../../utils/project-filter.js';
@@ -42,7 +42,7 @@ export const sessionInitHandler: EventHandler = {
// Use placeholder so sessions still get created and tracked for memory
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
const project = getProjectName(cwd);
const project = getProjectContext(cwd).primary;
const platformSource = normalizePlatformSource(input.platform);
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
+5 -1
View File
@@ -18,6 +18,7 @@ import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-util
import { logger } from '../../utils/logger.js';
import { extractLastMessage } from '../../shared/transcript-parser.js';
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
const POLL_INTERVAL_MS = 500;
@@ -66,13 +67,16 @@ export const summarizeHandler: EventHandler = {
hasLastAssistantMessage: !!lastAssistantMessage
});
const platformSource = normalizePlatformSource(input.platform);
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
const response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
last_assistant_message: lastAssistantMessage
last_assistant_message: lastAssistantMessage,
platformSource
}),
timeoutMs: SUMMARIZE_TIMEOUT_MS
});
+2 -2
View File
@@ -102,7 +102,7 @@ export function runStatusCommand(): void {
}
/**
* Search the worker API at `GET /api/search?q=<query>`.
* Search the worker API at `GET /api/search?query=<query>`.
*/
export async function runSearchCommand(queryParts: string[]): Promise<void> {
ensureInstalledOrExit();
@@ -114,7 +114,7 @@ export async function runSearchCommand(queryParts: string[]): Promise<void> {
}
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?q=${encodeURIComponent(query)}`;
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?query=${encodeURIComponent(query)}`;
try {
const response = await fetch(searchUrl);
+5 -4
View File
@@ -10,7 +10,7 @@ import { homedir } from 'os';
import { unlinkSync } from 'fs';
import { SessionStore } from '../sqlite/SessionStore.js';
import { logger } from '../../utils/logger.js';
import { getProjectName } from '../../utils/project-name.js';
import { getProjectContext } from '../../utils/project-name.js';
import type { ContextInput, ContextConfig, Observation, SessionSummary } from './types.js';
import { loadContextConfig } from './ContextConfigLoader.js';
@@ -129,11 +129,12 @@ export async function generateContext(
): Promise<string> {
const config = loadContextConfig();
const cwd = input?.cwd ?? process.cwd();
const project = getProjectName(cwd);
const context = getProjectContext(cwd);
const project = context.primary;
const platformSource = input?.platform_source;
// Use provided projects array (for worktree support) or fall back to single project
const projects = input?.projects || [project];
// Use provided projects array (for worktree support) or fall back to all known projects
const projects = input?.projects ?? context.allProjects;
// Full mode: fetch all observations but keep normal rendering (level 1 summaries)
if (input?.full) {
@@ -35,7 +35,7 @@ function formatHeaderDateTime(): string {
*/
export function renderAgentHeader(project: string): string[] {
return [
`# $CMEM ${project} ${formatHeaderDateTime()}`,
`# [${project}] recent context, ${formatHeaderDateTime()}`,
''
];
}
@@ -223,5 +223,5 @@ export function renderAgentFooter(totalDiscoveryTokens: number, totalReadTokens:
* Render agent empty state
*/
export function renderAgentEmptyState(project: string): string {
return `# $CMEM ${project} ${formatHeaderDateTime()}\n\nNo previous sessions found.`;
return `# [${project}] recent context, ${formatHeaderDateTime()}\n\nNo previous sessions found.`;
}
+24 -53
View File
@@ -6,7 +6,7 @@
*
* 1. Writes/merges transcript-watch config to ~/.claude-mem/transcript-watch.json
* 2. Sets up watch for ~/.codex/sessions/**\/*.jsonl using existing watcher
* 3. Injects context via ~/.codex/AGENTS.md (Codex reads this natively)
* 3. Injects context via workspace-local AGENTS.md files (Codex reads these natively)
*
* Anti-patterns:
* - Does NOT add notify hooks -- transcript watching is sufficient
@@ -67,7 +67,7 @@ function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
return parsed;
} catch (parseError) {
logger.error('CODEX', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
logger.error('SYSTEM', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
// Back up corrupt file
const backupPath = `${configPath}.backup.${Date.now()}`;
@@ -130,42 +130,10 @@ function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
// ---------------------------------------------------------------------------
/**
* Inject claude-mem context section into ~/.codex/AGENTS.md.
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md and GEMINI.md.
* Remove legacy claude-mem context from ~/.codex/AGENTS.md.
* Codex now uses workspace-local AGENTS.md files to avoid cross-project bleed.
* Preserves any existing user content outside the tags.
*/
function injectCodexAgentsMdContext(): void {
try {
mkdirSync(CODEX_DIR, { recursive: true });
let existingContent = '';
if (existsSync(CODEX_AGENTS_MD_PATH)) {
existingContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
}
// Initial placeholder content -- will be populated after first session
const contextContent = [
'# Recent Activity',
'',
'<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->',
'',
'*No context yet. Complete your first session and context will appear here.*',
].join('\n');
const finalContent = replaceTaggedContent(existingContent, contextContent);
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent);
console.log(` Injected context placeholder into ${CODEX_AGENTS_MD_PATH}`);
} catch (error) {
// Non-fatal -- transcript watching still works without context injection
logger.warn('CODEX', 'Failed to inject AGENTS.md context', { error: (error as Error).message });
console.warn(` Warning: Could not inject context into AGENTS.md: ${(error as Error).message}`);
}
}
/**
* Remove claude-mem context section from AGENTS.md.
* Preserves user content outside the <claude-mem-context> tags.
*/
function removeCodexAgentsMdContext(): void {
try {
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
@@ -179,7 +147,6 @@ function removeCodexAgentsMdContext(): void {
if (startIdx === -1 || endIdx === -1) return;
// Remove the tagged section and any surrounding blank lines
const before = content.substring(0, startIdx).replace(/\n+$/, '');
const after = content.substring(endIdx + endTag.length).replace(/^\n+/, '');
const finalContent = (before + (after ? '\n\n' + after : '')).trim();
@@ -187,17 +154,21 @@ function removeCodexAgentsMdContext(): void {
if (finalContent) {
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent + '\n');
} else {
// File would be empty -- leave it empty rather than deleting
// (user may have other tooling that expects it to exist)
writeFileSync(CODEX_AGENTS_MD_PATH, '');
}
console.log(` Removed context section from ${CODEX_AGENTS_MD_PATH}`);
console.log(` Removed legacy global context from ${CODEX_AGENTS_MD_PATH}`);
} catch (error) {
logger.warn('CODEX', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
logger.warn('SYSTEM', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
}
}
/**
* @deprecated Codex now uses workspace-local AGENTS.md via transcript processor fallback.
* Preserves user content outside the <claude-mem-context> tags.
*/
const cleanupLegacyCodexAgentsMdContext = removeCodexAgentsMdContext;
// ---------------------------------------------------------------------------
// Public API: Install
// ---------------------------------------------------------------------------
@@ -206,7 +177,7 @@ function removeCodexAgentsMdContext(): void {
* Install Codex CLI integration for claude-mem.
*
* 1. Merges Codex transcript-watch config into ~/.claude-mem/transcript-watch.json
* 2. Injects context placeholder into ~/.codex/AGENTS.md
* 2. Cleans up any legacy global context block in ~/.codex/AGENTS.md
*
* @returns 0 on success, 1 on failure
*/
@@ -222,19 +193,19 @@ export async function installCodexCli(): Promise<number> {
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
// Step 2: Inject context into AGENTS.md
injectCodexAgentsMdContext();
// Step 2: Clean up legacy global AGENTS.md context
cleanupLegacyCodexAgentsMdContext();
console.log(`
Installation complete!
Transcript watch config: ${DEFAULT_CONFIG_PATH}
Context file: ${CODEX_AGENTS_MD_PATH}
Context files: <workspace>/AGENTS.md
How it works:
- claude-mem watches Codex session JSONL files for new activity
- No hooks needed -- transcript watching is fully automatic
- Context from past sessions is injected via ${CODEX_AGENTS_MD_PATH}
- Context from past sessions is injected via AGENTS.md in the active Codex workspace
Next steps:
1. Start claude-mem worker: npx claude-mem start
@@ -284,8 +255,8 @@ export function uninstallCodexCli(): number {
console.log(' No transcript-watch.json found -- nothing to remove.');
}
// Step 2: Remove context section from AGENTS.md
removeCodexAgentsMdContext();
// Step 2: Remove legacy global context section from AGENTS.md
cleanupLegacyCodexAgentsMdContext();
console.log('\nUninstallation complete!');
console.log('Restart claude-mem worker to apply changes.\n');
@@ -340,20 +311,20 @@ export function checkCodexCliStatus(): number {
// Check context config
if (codexWatch.context) {
console.log(` Context mode: ${codexWatch.context.mode}`);
console.log(` Context path: ${codexWatch.context.path ?? 'default'}`);
console.log(` Context path: ${codexWatch.context.path ?? '<workspace>/AGENTS.md (default)'}`);
console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`);
}
// Check AGENTS.md
// Check legacy global AGENTS.md usage
if (existsSync(CODEX_AGENTS_MD_PATH)) {
const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
if (mdContent.includes('<claude-mem-context>')) {
console.log(` Context: Active (${CODEX_AGENTS_MD_PATH})`);
console.log(` Legacy global context: Present (${CODEX_AGENTS_MD_PATH})`);
} else {
console.log(` Context: AGENTS.md exists but no context tags`);
console.log(` Legacy global context: Not active`);
}
} else {
console.log(` Context: No AGENTS.md file`);
console.log(` Legacy global context: None`);
}
// Check if ~/.codex/sessions exists (indicates Codex has been used)
@@ -80,7 +80,7 @@ const HOOK_TIMEOUT_MS = 10000;
*/
const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
'SessionStart': 'context',
'BeforeAgent': 'user-message',
'BeforeAgent': 'session-init',
'AfterAgent': 'observation',
'BeforeTool': 'observation',
'AfterTool': 'observation',
+1 -1
View File
@@ -217,7 +217,7 @@ export class SessionStore {
private removeSessionSummariesUniqueConstraint(): void {
// Check actual constraint state — don't rely on version tracking alone (issue #979)
const summariesIndexes = this.db.query('PRAGMA index_list(session_summaries)').all() as IndexInfo[];
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1);
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1 && idx.origin !== 'pk');
if (!hasUniqueConstraint) {
// Already migrated (no constraint exists)
+1 -1
View File
@@ -189,7 +189,7 @@ export class MigrationRunner {
private removeSessionSummariesUniqueConstraint(): void {
// Check actual constraint state — don't rely on version tracking alone (issue #979)
const summariesIndexes = this.db.query('PRAGMA index_list(session_summaries)').all() as IndexInfo[];
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1);
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1 && idx.origin !== 'pk');
if (!hasUniqueConstraint) {
// Already migrated (no constraint exists)
-1
View File
@@ -97,7 +97,6 @@ export const SAMPLE_CONFIG: TranscriptWatchConfig = {
startAtEnd: true,
context: {
mode: 'agents',
path: '~/.codex/AGENTS.md',
updateOn: ['session_start', 'session_end']
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { fileEditHandler } from '../../cli/handlers/file-edit.js';
import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { getProjectContext, getProjectName } from '../../utils/project-name.js';
import { getProjectContext } from '../../utils/project-name.js';
import { writeAgentsMd } from '../../utils/agents-md-utils.js';
import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js';
import { expandHomePath } from './config.js';
@@ -104,7 +104,7 @@ export class TranscriptEventProcessor {
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
if (typeof resolved === 'string' && resolved.trim()) return resolved;
if (watch.project) return watch.project;
if (session.cwd) return getProjectName(session.cwd);
if (session.cwd) return getProjectContext(session.cwd).primary;
return session.project;
}
+43 -2
View File
@@ -382,21 +382,62 @@ export function createPidCapturingSpawn(sessionDbId: number) {
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
}) => {
// Kill any existing process for this session before spawning a new one.
// Multiple processes sharing the same --resume UUID waste API credits and
// can conflict with each other (Issue #1590).
const existing = getProcessBySession(sessionDbId);
if (existing && existing.process.exitCode === null) {
logger.warn('PROCESS', `Killing duplicate process PID ${existing.pid} before spawning new one for session ${sessionDbId}`, {
existingPid: existing.pid,
sessionDbId
});
let exited = false;
try {
existing.process.kill('SIGTERM');
exited = existing.process.exitCode !== null;
} catch {
// Already dead — safe to unregister immediately
exited = true;
}
if (exited) {
unregisterProcess(existing.pid);
}
// If still alive, the 'exit' handler (line ~440) will unregister it.
}
getSupervisor().assertCanSpawn('claude sdk');
// On Windows, use cmd.exe wrapper for .cmd files to properly handle paths with spaces
const useCmdWrapper = process.platform === 'win32' && spawnOptions.command.endsWith('.cmd');
const env = sanitizeEnv(spawnOptions.env ?? process.env);
// Filter empty string args AND their preceding flag (Issue #2049).
// The Agent SDK emits ["--setting-sources", ""] when settingSources defaults to [].
// Simply dropping "" leaves an orphan --setting-sources that consumes the next
// flag (e.g. --permission-mode) as its value, crashing Claude Code 2.1.109+ with
// "Invalid setting source: --permission-mode". Drop the flag too so the SDK
// default (no setting sources) is preserved by omission.
const args: string[] = [];
for (const arg of spawnOptions.args) {
if (arg === '') {
if (args.length > 0 && args[args.length - 1].startsWith('--')) {
args.pop();
}
continue;
}
args.push(arg);
}
const child = useCmdWrapper
? spawn('cmd.exe', ['/d', '/c', spawnOptions.command, ...spawnOptions.args], {
? spawn('cmd.exe', ['/d', '/c', spawnOptions.command, ...args], {
cwd: spawnOptions.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
signal: spawnOptions.signal,
windowsHide: true
})
: spawn(spawnOptions.command, spawnOptions.args, {
: spawn(spawnOptions.command, args, {
cwd: spawnOptions.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
+89 -6
View File
@@ -17,6 +17,64 @@ import { SessionQueueProcessor } from '../queue/SessionQueueProcessor.js';
import { getProcessBySession, ensureProcessExit } from './ProcessRegistry.js';
import { getSupervisor } from '../../supervisor/index.js';
/** Idle threshold before a stuck generator (zombie subprocess) is force-killed. */
export const MAX_GENERATOR_IDLE_MS = 5 * 60 * 1000; // 5 minutes
/** Idle threshold before a no-generator session with no pending work is reaped. */
export const MAX_SESSION_IDLE_MS = 15 * 60 * 1000; // 15 minutes
/**
* Minimal process interface used by detectStaleGenerator compatible with
* both the real Bun.Subprocess / ChildProcess shapes and test mocks.
*/
export interface StaleGeneratorProcess {
exitCode: number | null;
kill(signal?: string): boolean | void;
}
/**
* Minimal session fields required to evaluate stale-generator status.
* This is a subset of ActiveSession, allowing unit tests to pass plain objects.
*/
export interface StaleGeneratorCandidate {
generatorPromise: Promise<void> | null;
lastGeneratorActivity: number;
abortController: AbortController;
}
/**
* Detect whether a session's generator is stuck (zombie subprocess) and, if so,
* SIGKILL the subprocess and abort the controller.
*
* Extracted from reapStaleSessions() so tests can import and exercise the exact
* same logic rather than duplicating it locally. (Issue #1652)
*
* @param session - session to inspect
* @param proc - tracked subprocess (may be undefined if not in ProcessRegistry)
* @param now - current timestamp (defaults to Date.now(); pass explicit value in tests)
* @returns true if the session was marked stale, false otherwise
*/
export function detectStaleGenerator(
session: StaleGeneratorCandidate,
proc: StaleGeneratorProcess | undefined,
now = Date.now()
): boolean {
if (!session.generatorPromise) return false;
const generatorIdleMs = now - session.lastGeneratorActivity;
if (generatorIdleMs <= MAX_GENERATOR_IDLE_MS) return false;
// Kill subprocess to unblock stuck for-await
if (proc && proc.exitCode === null) {
try {
proc.kill('SIGKILL');
} catch {}
}
// Signal the SDK agent loop to exit
session.abortController.abort();
return true;
}
export class SessionManager {
private dbManager: DatabaseManager;
private sessions: Map<number, ActiveSession> = new Map();
@@ -364,10 +422,12 @@ export class SessionManager {
}
}
private static readonly MAX_SESSION_IDLE_MS = 15 * 60 * 1000; // 15 minutes
/**
* Reap sessions with no active generator and no pending work that have been idle too long.
* Also reaps sessions whose generator has been stuck (no lastGeneratorActivity update) for
* longer than MAX_GENERATOR_IDLE_MS these are zombie subprocesses that will never exit
* on their own because the orphan reaper skips sessions in the active sessions map. (Issue #1652)
*
* This unblocks the orphan reaper which skips processes for "active" sessions. (Issue #1168)
*/
async reapStaleSessions(): Promise<number> {
@@ -375,8 +435,31 @@ export class SessionManager {
const staleSessionIds: number[] = [];
for (const [sessionDbId, session] of this.sessions) {
// Skip sessions with active generators
if (session.generatorPromise) continue;
// Sessions with active generators — check for stuck/zombie generators (Issue #1652)
if (session.generatorPromise) {
const generatorIdleMs = now - session.lastGeneratorActivity;
if (generatorIdleMs > MAX_GENERATOR_IDLE_MS) {
logger.warn('SESSION', `Stale generator detected for session ${sessionDbId} (no activity for ${Math.round(generatorIdleMs / 60000)}m) — force-killing subprocess`, {
sessionDbId,
generatorIdleMs
});
// Force-kill the subprocess to unblock the stuck for-await in SDKAgent.
// Without this the generator is blocked on `for await (const msg of queryResult)`
// and will never exit even after abort() is called.
const trackedProcess = getProcessBySession(sessionDbId);
if (trackedProcess && trackedProcess.process.exitCode === null) {
try {
trackedProcess.process.kill('SIGKILL');
} catch (err) {
logger.warn('SESSION', 'Failed to SIGKILL subprocess for stale generator', { sessionDbId }, err as Error);
}
}
// Signal the SDK agent loop to exit after the subprocess dies
session.abortController.abort();
staleSessionIds.push(sessionDbId);
}
continue;
}
// Skip sessions with pending work
const pendingCount = this.getPendingStore().getPendingCount(sessionDbId);
@@ -384,13 +467,13 @@ export class SessionManager {
// No generator + no pending work + old enough = stale
const sessionAge = now - session.startTime;
if (sessionAge > SessionManager.MAX_SESSION_IDLE_MS) {
if (sessionAge > MAX_SESSION_IDLE_MS) {
logger.warn('SESSION', `Reaping idle session ${sessionDbId} (no activity for >${Math.round(MAX_SESSION_IDLE_MS / 60000)}m)`, { sessionDbId });
staleSessionIds.push(sessionDbId);
}
}
for (const sessionDbId of staleSessionIds) {
logger.warn('SESSION', `Reaping stale session ${sessionDbId} (no activity for >${Math.round(SessionManager.MAX_SESSION_IDLE_MS / 60000)}m)`, { sessionDbId });
await this.deleteSession(sessionDbId);
}
@@ -85,27 +85,6 @@ export async function processAgentResponse(
// Convert nullable fields to empty strings for storeSummary (if summary exists)
const summaryForStore = normalizeSummaryForStorage(summary);
// Fallback: When summary parse fails but observations exist, salvage a synthetic summary.
// Fixes Issue #1312: AI sometimes returns <observation> instead of <summary> despite clear instructions.
// Observations are stored normally; this only affects the session summary.
let finalSummaryForStore = summaryForStore;
if (!summaryForStore && observations.length > 0) {
const primary = observations[0];
finalSummaryForStore = {
request: primary.title || `Session observations (${observations.length} items)`,
investigated: primary.narrative || primary.facts?.join('; ') || '',
learned: primary.facts?.join('; ') || '',
completed: primary.type === 'feature' || primary.type === 'bugfix' ? (primary.title || '') : '',
next_steps: '',
notes: `[Salvaged from ${observations.length} observation(s)] AI returned <observation> instead of <summary>`
};
logger.warn('PARSER', `SALVAGED summary from ${observations.length} observation(s) — AI did not output <summary> tags`, {
sessionId: session.sessionDbId,
agentName,
observationIds: observations.map(o => o.title).filter(Boolean).slice(0, 3)
});
}
// Get session store for atomic transaction
const sessionStore = dbManager.getSessionStore();
@@ -123,7 +102,7 @@ export async function processAgentResponse(
sessionStore.ensureMemorySessionIdRegistered(session.sessionDbId, session.memorySessionId);
// Log pre-storage with session ID chain for verification
logger.info('DB', `STORING | sessionDbId=${session.sessionDbId} | memorySessionId=${session.memorySessionId} | obsCount=${observations.length} | hasSummary=${!!finalSummaryForStore}`, {
logger.info('DB', `STORING | sessionDbId=${session.sessionDbId} | memorySessionId=${session.memorySessionId} | obsCount=${observations.length} | hasSummary=${!!summaryForStore}`, {
sessionId: session.sessionDbId,
memorySessionId: session.memorySessionId
});
@@ -134,7 +113,7 @@ export async function processAgentResponse(
session.memorySessionId,
session.project,
observations,
finalSummaryForStore,
summaryForStore,
session.lastPromptNumber,
discoveryTokens,
originalTimestamp ?? undefined,
@@ -178,7 +157,7 @@ export async function processAgentResponse(
// Sync and broadcast summary if present
await syncAndBroadcastSummary(
summary,
finalSummaryForStore,
summaryForStore,
result,
session,
dbManager,
@@ -12,6 +12,8 @@ import { CorpusBuilder } from '../../knowledge/CorpusBuilder.js';
import { KnowledgeAgent } from '../../knowledge/KnowledgeAgent.js';
import type { CorpusFilter } from '../../knowledge/types.js';
const ALLOWED_CORPUS_TYPES = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
export class CorpusRoutes extends BaseRouteHandler {
constructor(
private corpusStore: CorpusStore,
@@ -49,15 +51,31 @@ export class CorpusRoutes extends BaseRouteHandler {
const { name, description, project, types, concepts, files, query, date_start, date_end, limit } = req.body;
const coercedTypes = this.coerceStringArray(types, 'types', res);
if (coercedTypes === null) return;
if (coercedTypes && !coercedTypes.every(type => ALLOWED_CORPUS_TYPES.has(type))) {
this.badRequest(res, 'types must contain valid observation types');
return;
}
const coercedConcepts = this.coerceStringArray(concepts, 'concepts', res);
if (coercedConcepts === null) return;
const coercedFiles = this.coerceStringArray(files, 'files', res);
if (coercedFiles === null) return;
const coercedLimit = this.coercePositiveInteger(limit, 'limit', res);
if (coercedLimit === null) return;
const filter: CorpusFilter = {};
if (project) filter.project = project;
if (types) filter.types = types;
if (concepts) filter.concepts = concepts;
if (files) filter.files = files;
if (coercedTypes && coercedTypes.length > 0) filter.types = coercedTypes as CorpusFilter['types'];
if (coercedConcepts && coercedConcepts.length > 0) filter.concepts = coercedConcepts;
if (coercedFiles && coercedFiles.length > 0) filter.files = coercedFiles;
if (query) filter.query = query;
if (date_start) filter.date_start = date_start;
if (date_end) filter.date_end = date_end;
if (limit) filter.limit = limit;
if (coercedLimit !== undefined) filter.limit = coercedLimit;
const corpus = await this.corpusBuilder.build(name, description || '', filter);
@@ -66,6 +84,42 @@ export class CorpusRoutes extends BaseRouteHandler {
res.json(metadata);
});
private coerceStringArray(value: unknown, fieldName: string, res: Response): string[] | null | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
let parsed = value;
if (typeof value === 'string') {
try {
parsed = JSON.parse(value);
} catch {
parsed = value.split(',').map(part => part.trim()).filter(Boolean);
}
}
if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string')) {
this.badRequest(res, `${fieldName} must be an array of strings`);
return null;
}
return parsed.map(item => item.trim()).filter(Boolean);
}
private coercePositiveInteger(value: unknown, fieldName: string, res: Response): number | null | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
const parsed = typeof value === 'string' ? Number(value) : value;
if (typeof parsed !== 'number' || !Number.isInteger(parsed) || parsed <= 0) {
this.badRequest(res, `${fieldName} must be a positive integer`);
return null;
}
return parsed;
}
/**
* List all corpora with stats
* GET /api/corpus
@@ -22,7 +22,7 @@ import { PrivacyCheckValidator } from '../../validation/PrivacyCheckValidator.js
import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js';
import { getProjectName } from '../../../../utils/project-name.js';
import { getProjectContext } from '../../../../utils/project-name.js';
import { normalizePlatformSource } from '../../../../shared/platform-source.js';
export class SessionRoutes extends BaseRouteHandler {
@@ -94,11 +94,37 @@ export class SessionRoutes extends BaseRouteHandler {
* The next generator will use the new provider with shared conversationHistory.
*/
private static readonly STALE_GENERATOR_THRESHOLD_MS = 30_000; // 30 seconds (#1099)
private static readonly MAX_SESSION_WALL_CLOCK_MS = 4 * 60 * 60 * 1000; // 4 hours (#1590)
private ensureGeneratorRunning(sessionDbId: number, source: string): void {
const session = this.sessionManager.getSession(sessionDbId);
if (!session) return;
// Wall-clock age guard: refuse to start new generators for sessions that have
// been alive too long to prevent runaway API costs (Issue #1590).
// Use the persisted started_at_epoch from the DB so the guard survives worker
// restarts (session.startTime is reset to Date.now() on every re-activation).
const dbSessionRecord = this.dbManager.getSessionStore().db
.prepare('SELECT started_at_epoch FROM sdk_sessions WHERE id = ? LIMIT 1')
.get(sessionDbId) as { started_at_epoch: number } | undefined;
const sessionOriginMs = dbSessionRecord?.started_at_epoch ?? session.startTime;
const sessionAgeMs = Date.now() - sessionOriginMs;
if (sessionAgeMs > SessionRoutes.MAX_SESSION_WALL_CLOCK_MS) {
logger.warn('SESSION', 'Session exceeded wall-clock age limit — aborting to prevent runaway spend', {
sessionId: sessionDbId,
ageHours: Math.round(sessionAgeMs / 3_600_000 * 10) / 10,
limitHours: SessionRoutes.MAX_SESSION_WALL_CLOCK_MS / 3_600_000,
source
});
if (!session.abortController.signal.aborted) {
session.abortController.abort();
}
const pendingStore = this.sessionManager.getPendingMessageStore();
pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
this.sessionManager.removeSessionImmediate(sessionDbId);
return;
}
// GUARD: Prevent duplicate spawns
if (this.spawnInProgress.get(sessionDbId)) {
logger.debug('SESSION', 'Spawn already in progress, skipping', { sessionDbId, source });
@@ -187,15 +213,37 @@ export class SessionRoutes extends BaseRouteHandler {
session.currentProvider = provider;
session.lastGeneratorActivity = Date.now();
// Capture the AbortController that belongs to THIS generator run.
// session.abortController may be replaced (e.g. by stale-recovery) before the
// .catch / .finally handlers run, so binding it here prevents a stale rejection
// from cancelling a brand-new controller (race condition guard).
const myController = session.abortController;
session.generatorPromise = agent.startSession(session, this.workerService)
.catch(error => {
// Only log non-abort errors
if (session.abortController.signal.aborted) return;
if (myController.signal.aborted) return;
const errorMsg = error instanceof Error ? error.message : String(error);
// Treat SIGTERM (exit code 143) as intentional termination, not a crash.
// When a subprocess is killed externally, abort the controller to prevent
// crash recovery from immediately respawning the process (Issue #1590).
// APPROVED OVERRIDE
if (errorMsg.includes('code 143') || errorMsg.includes('signal SIGTERM')) {
logger.warn('SESSION', 'Generator killed by external signal — aborting session to prevent respawn', {
sessionId: session.sessionDbId,
provider,
error: errorMsg
});
myController.abort();
return;
}
logger.error('SESSION', `Generator failed`, {
sessionId: session.sessionDbId,
provider: provider,
error: error.message
error: errorMsg
}, error);
// Mark all processing messages as failed so they can be retried or abandoned
@@ -507,7 +555,7 @@ export class SessionRoutes extends BaseRouteHandler {
private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
const project = typeof cwd === 'string' && cwd.trim() ? getProjectName(cwd) : '';
const project = typeof cwd === 'string' && cwd.trim() ? getProjectContext(cwd).primary : '';
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId');
+11 -4
View File
@@ -9,7 +9,7 @@
* causing memory operations to bill personal API accounts instead of CLI subscription.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { logger } from '../utils/logger.js';
@@ -132,10 +132,13 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
*/
export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
try {
// Ensure directory exists
// Ensure directory exists with restricted permissions (owner only)
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
}
// Fix permissions on pre-existing directories (mode: is only applied on creation)
// Note: On Windows, chmod has no effect — permissions are controlled via ACLs.
chmodSync(DATA_DIR, 0o700);
// Load existing to preserve any extra keys
const existing = existsSync(ENV_FILE_PATH)
@@ -175,7 +178,11 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
}
}
writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), 'utf-8');
writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), { encoding: 'utf-8', mode: 0o600 });
// Explicitly set permissions in case the file already existed before this fix.
// writeFileSync's mode option only applies on file creation (O_CREAT), not on overwrites.
// Note: On Windows, chmod has no effect — permissions are controlled via ACLs.
chmodSync(ENV_FILE_PATH, 0o600);
} catch (error) {
logger.error('ENV', 'Failed to save .env file', { path: ENV_FILE_PATH }, error as Error);
throw error;
+77 -1
View File
@@ -3,7 +3,37 @@ import { logger } from '../utils/logger.js';
import { SYSTEM_REMINDER_REGEX } from '../utils/tag-stripping.js';
/**
* Extract last message of specified role from transcript JSONL file
* Detect whether a transcript file is in Gemini CLI JSON document format.
*
* Gemini CLI 0.37.0 writes a single JSON document with a top-level `messages`
* array instead of JSONL. Assistant entries use `type: "gemini"` rather than
* `type: "assistant"`.
*
* Example Gemini format:
* { "messages": [{ "type": "user", "content": "..." }, { "type": "gemini", "content": "..." }] }
*
* Claude Code format (JSONL):
* {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
*/
function isGeminiTranscriptFormat(content: string): { isGemini: true; messages: any[] } | { isGemini: false } {
try {
const parsed = JSON.parse(content);
if (parsed && Array.isArray(parsed.messages)) {
return { isGemini: true, messages: parsed.messages };
}
} catch {
// Not a valid single JSON object — assume JSONL
}
return { isGemini: false };
}
/**
* Extract last message of specified role from transcript file.
*
* Supports two transcript formats:
* - JSONL (Claude Code): one JSON object per line, `type: "assistant"` or `type: "user"`
* - JSON document (Gemini CLI 0.37.0+): `{ messages: [{ type: "gemini"|"user", content: string }] }`
*
* @param transcriptPath Path to transcript file
* @param role 'user' or 'assistant'
* @param stripSystemReminders Whether to remove <system-reminder> tags (for assistant)
@@ -24,6 +54,52 @@ export function extractLastMessage(
return '';
}
// Gemini CLI 0.37.0 writes a JSON document rather than JSONL.
// Detect and handle it before falling through to the JSONL parser.
const geminiCheck = isGeminiTranscriptFormat(content);
if (geminiCheck.isGemini) {
return extractLastMessageFromGeminiTranscript(geminiCheck.messages, role, stripSystemReminders);
}
return extractLastMessageFromJsonl(content, role, stripSystemReminders);
}
/**
* Extract last message from Gemini CLI JSON document transcript.
* Maps `type: "gemini"` assistant role; `type: "user"` user role.
*/
function extractLastMessageFromGeminiTranscript(
messages: any[],
role: 'user' | 'assistant',
stripSystemReminders: boolean
): string {
// "gemini" entries are assistant turns; "user" entries are user turns
const geminiRole = role === 'assistant' ? 'gemini' : 'user';
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.type === geminiRole && typeof msg.content === 'string') {
let text = msg.content;
if (stripSystemReminders) {
text = text.replace(SYSTEM_REMINDER_REGEX, '');
text = text.replace(/\n{3,}/g, '\n\n').trim();
}
return text;
}
}
return '';
}
/**
* Extract last message from Claude Code JSONL transcript.
* Each line is an independent JSON object with `type: "assistant"` or `type: "user"`.
*/
function extractLastMessageFromJsonl(
content: string,
role: 'user' | 'assistant',
stripSystemReminders: boolean
): string {
const lines = content.split('\n');
let foundMatchingRole = false;
+10 -8
View File
@@ -58,13 +58,13 @@ export function getProjectName(cwd: string | null | undefined): string {
* Project context with worktree awareness
*/
export interface ProjectContext {
/** The current project name (worktree or main repo) */
/** Canonical project name for writes/queries (parent repo in worktrees) */
primary: string;
/** Parent project name if in a worktree, null otherwise */
parent: string | null;
/** True if currently in a worktree */
isWorktree: boolean;
/** All projects to query: [primary] for main repo, [parent, primary] for worktree */
/** All projects to query: [primary] for main repo, [parentRepo, worktreeName] for worktree */
allProjects: string[];
}
@@ -78,24 +78,26 @@ export interface ProjectContext {
* @returns ProjectContext with worktree info
*/
export function getProjectContext(cwd: string | null | undefined): ProjectContext {
const primary = getProjectName(cwd);
const cwdProjectName = getProjectName(cwd);
if (!cwd) {
return { primary, parent: null, isWorktree: false, allProjects: [primary] };
return { primary: cwdProjectName, parent: null, isWorktree: false, allProjects: [cwdProjectName] };
}
const expandedCwd = expandTilde(cwd);
const worktreeInfo = detectWorktree(expandedCwd);
if (worktreeInfo.isWorktree && worktreeInfo.parentProjectName) {
// In a worktree: include parent first for chronological ordering
// In a worktree: use parent project name as primary so observations
// are stored under the same project as the main repo (#1081, #1500, #1819)
const allProjects = Array.from(new Set([worktreeInfo.parentProjectName, cwdProjectName]));
return {
primary,
primary: worktreeInfo.parentProjectName,
parent: worktreeInfo.parentProjectName,
isWorktree: true,
allProjects: [worktreeInfo.parentProjectName, primary]
allProjects
};
}
return { primary, parent: null, isWorktree: false, allProjects: [primary] };
return { primary: cwdProjectName, parent: null, isWorktree: false, allProjects: [cwdProjectName] };
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const configSource = readFileSync(
join(__dirname, '..', 'src', 'services', 'transcripts', 'config.ts'),
'utf-8',
);
const installerSource = readFileSync(
join(__dirname, '..', 'src', 'services', 'integrations', 'CodexCliInstaller.ts'),
'utf-8',
);
describe('Codex workspace-local context', () => {
it('does not hardcode ~/.codex/AGENTS.md in the sample transcript watch config', () => {
expect(configSource).not.toContain("path: '~/.codex/AGENTS.md'");
});
it('documents workspace-local AGENTS.md injection for Codex', () => {
expect(installerSource).toContain('workspace-local AGENTS.md');
expect(installerSource).toContain('Context files: <workspace>/AGENTS.md');
});
it('cleans legacy global Codex context during install', () => {
expect(installerSource).toContain('cleanupLegacyCodexAgentsMdContext();');
expect(installerSource).toContain('Removed legacy global context');
});
});
@@ -103,7 +103,7 @@ describe('AgentFormatter', () => {
const result = renderAgentHeader('my-project');
expect(result).toHaveLength(2);
expect(result[0]).toMatch(/^# \$CMEM my-project \d{4}-\d{2}-\d{2} \d{1,2}:\d{2}[ap]m [A-Z]{3,4}$/);
expect(result[0]).toMatch(/^# \[my-project\] recent context, \d{4}-\d{2}-\d{2} \d{1,2}:\d{2}[ap]m [A-Z]{3,4}$/);
expect(result[1]).toBe('');
});
@@ -116,7 +116,7 @@ describe('AgentFormatter', () => {
it('should handle empty project name', () => {
const result = renderAgentHeader('');
expect(result[0]).toMatch(/^# \$CMEM \d{4}-\d{2}-\d{2} \d{1,2}:\d{2}[ap]m [A-Z]{3,4}$/);
expect(result[0]).toMatch(/^# \[\] recent context, \d{4}-\d{2}-\d{2} \d{1,2}:\d{2}[ap]m [A-Z]{3,4}$/);
});
});
@@ -452,7 +452,7 @@ describe('AgentFormatter', () => {
it('should return helpful message with project name', () => {
const result = renderAgentEmptyState('my-project');
expect(result).toContain('# $CMEM my-project');
expect(result).toContain('# [my-project] recent context,');
expect(result).toContain('No previous sessions found.');
});
@@ -466,7 +466,7 @@ describe('AgentFormatter', () => {
it('should handle empty project name', () => {
const result = renderAgentEmptyState('');
expect(result).toContain('# $CMEM ');
expect(result).toContain('# [] recent context,');
});
});
});
+237
View File
@@ -0,0 +1,237 @@
/**
* Tests for Gemini CLI 0.37.0 compatibility fixes (Issue #1664)
*
* Validates:
* 1. BeforeAgent is mapped to session-init (not user-message)
* 2. Transcript parser handles Gemini JSON document format (type: "gemini")
* 3. Summarize handler includes platformSource in the request body
*/
import { describe, it, expect } from 'bun:test';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
// ---------------------------------------------------------------------------
// 1. BeforeAgent event mapping
// ---------------------------------------------------------------------------
describe('GeminiCliHooksInstaller - event mapping', () => {
it('should map BeforeAgent to session-init, not user-message', async () => {
// Import the module to access the constant indirectly by inspecting
// the generated command string through the installer's internal mapping.
// The constant GEMINI_EVENT_TO_INTERNAL_EVENT is module-private, but we
// can verify the effect by checking that the installer installs the
// correct internal event name.
//
// Strategy: read the source file and assert the mapping directly.
const { readFileSync } = await import('fs');
const src = readFileSync('src/services/integrations/GeminiCliHooksInstaller.ts', 'utf-8');
// BeforeAgent must map to 'session-init'
expect(src).toContain("'BeforeAgent': 'session-init'");
// BeforeAgent must NOT map to 'user-message'
expect(src).not.toContain("'BeforeAgent': 'user-message'");
});
it('should map SessionStart to context (unchanged)', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/services/integrations/GeminiCliHooksInstaller.ts', 'utf-8');
expect(src).toContain("'SessionStart': 'context'");
});
it('should map SessionEnd to session-complete (unchanged)', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/services/integrations/GeminiCliHooksInstaller.ts', 'utf-8');
expect(src).toContain("'SessionEnd': 'session-complete'");
});
});
// ---------------------------------------------------------------------------
// 2. Transcript parser — Gemini JSON document format
// ---------------------------------------------------------------------------
describe('extractLastMessage - Gemini CLI 0.37.0 transcript format', () => {
let tmpDir: string;
// Helper: write a temp transcript file and return its path
const writeTranscript = (name: string, content: string): string => {
const filePath = join(tmpDir, name);
writeFileSync(filePath, content, 'utf-8');
return filePath;
};
// Set up / tear down a fresh temp directory per suite
const setup = () => {
tmpDir = join(tmpdir(), `gemini-transcript-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
};
const teardown = () => {
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
};
describe('Gemini JSON document format', () => {
it('extracts last assistant message from Gemini transcript (type: "gemini")', async () => {
setup();
try {
const { extractLastMessage } = await import('../src/shared/transcript-parser.js');
const transcript = JSON.stringify({
messages: [
{ type: 'user', content: 'Hello Gemini' },
{ type: 'gemini', content: 'Hi there! How can I help you today?' },
{ type: 'user', content: 'What is 2+2?' },
{ type: 'gemini', content: 'The answer is 4.' },
]
});
const filePath = writeTranscript('gemini.json', transcript);
const result = extractLastMessage(filePath, 'assistant');
expect(result).toBe('The answer is 4.');
} finally {
teardown();
}
});
it('extracts last user message from Gemini transcript', async () => {
setup();
try {
const { extractLastMessage } = await import('../src/shared/transcript-parser.js');
const transcript = JSON.stringify({
messages: [
{ type: 'user', content: 'First message' },
{ type: 'gemini', content: 'First reply' },
{ type: 'user', content: 'Second message' },
]
});
const filePath = writeTranscript('gemini-user.json', transcript);
const result = extractLastMessage(filePath, 'user');
expect(result).toBe('Second message');
} finally {
teardown();
}
});
it('returns empty string when no assistant message exists in Gemini transcript', async () => {
setup();
try {
const { extractLastMessage } = await import('../src/shared/transcript-parser.js');
const transcript = JSON.stringify({
messages: [
{ type: 'user', content: 'Just a user message' },
]
});
const filePath = writeTranscript('gemini-no-assistant.json', transcript);
const result = extractLastMessage(filePath, 'assistant');
expect(result).toBe('');
} finally {
teardown();
}
});
it('strips system reminders from Gemini assistant messages when requested', async () => {
setup();
try {
const { extractLastMessage } = await import('../src/shared/transcript-parser.js');
const content = 'Real answer here.<system-reminder>ignore this</system-reminder>';
const transcript = JSON.stringify({
messages: [
{ type: 'user', content: 'Question' },
{ type: 'gemini', content },
]
});
const filePath = writeTranscript('gemini-strip.json', transcript);
const result = extractLastMessage(filePath, 'assistant', true);
expect(result).toContain('Real answer here.');
expect(result).not.toContain('system-reminder');
expect(result).not.toContain('ignore this');
} finally {
teardown();
}
});
it('handles single-turn Gemini transcript', async () => {
setup();
try {
const { extractLastMessage } = await import('../src/shared/transcript-parser.js');
const transcript = JSON.stringify({
messages: [
{ type: 'user', content: 'Hello' },
{ type: 'gemini', content: 'Hello! I am Gemini.' },
]
});
const filePath = writeTranscript('gemini-single.json', transcript);
const result = extractLastMessage(filePath, 'assistant');
expect(result).toBe('Hello! I am Gemini.');
} finally {
teardown();
}
});
});
describe('JSONL format (Claude Code) — no regression', () => {
it('still extracts assistant messages from JSONL transcripts', async () => {
setup();
try {
const { extractLastMessage } = await import('../src/shared/transcript-parser.js');
const lines = [
JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'user msg' }] } }),
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'assistant reply' }] } }),
].join('\n');
const filePath = writeTranscript('jsonl.jsonl', lines);
const result = extractLastMessage(filePath, 'assistant');
expect(result).toBe('assistant reply');
} finally {
teardown();
}
});
it('still extracts string content from JSONL transcripts', async () => {
setup();
try {
const { extractLastMessage } = await import('../src/shared/transcript-parser.js');
const lines = [
JSON.stringify({ type: 'assistant', message: { content: 'plain string response' } }),
].join('\n');
const filePath = writeTranscript('jsonl-string.jsonl', lines);
const result = extractLastMessage(filePath, 'assistant');
expect(result).toBe('plain string response');
} finally {
teardown();
}
});
});
});
// ---------------------------------------------------------------------------
// 3. Summarize handler includes platformSource
// ---------------------------------------------------------------------------
describe('Summarize handler - platformSource in request body', () => {
it('should include platformSource import in summarize.ts', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/cli/handlers/summarize.ts', 'utf-8');
expect(src).toContain('normalizePlatformSource');
expect(src).toContain('platform-source');
});
it('should pass platformSource in the summarize request body', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/cli/handlers/summarize.ts', 'utf-8');
// The body must include platformSource
expect(src).toContain('platformSource');
// It must appear in the JSON.stringify call for the summarize endpoint
expect(src).toContain('/api/sessions/summarize');
});
});
+239
View File
@@ -0,0 +1,239 @@
// Tests for file-context cache validation fix (#1719)
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
import { mkdtempSync, writeFileSync, utimesSync, rmSync } from 'fs';
import { tmpdir, homedir } from 'os';
import { join } from 'path';
// Mock modules that cause import chain issues — MUST be before handler imports
mock.module('../../src/shared/SettingsDefaultsManager.js', () => ({
SettingsDefaultsManager: {
get: (key: string) => {
if (key === 'CLAUDE_MEM_DATA_DIR') return join(homedir(), '.claude-mem');
return '';
},
getInt: () => 0,
loadFromFile: () => ({ CLAUDE_MEM_EXCLUDED_PROJECTS: [] }),
},
}));
mock.module('../../src/shared/worker-utils.js', () => ({
ensureWorkerRunning: () => Promise.resolve(true),
getWorkerPort: () => 37777,
workerHttpRequest: (apiPath: string, options?: any) => {
const url = `http://127.0.0.1:37777${apiPath}`;
return globalThis.fetch(url, {
method: options?.method ?? 'GET',
headers: options?.headers,
body: options?.body,
});
},
}));
mock.module('../../src/utils/project-name.js', () => ({
getProjectName: () => 'test-project',
getProjectContext: () => ({ allProjects: ['test-project'] }),
}));
mock.module('../../src/utils/project-filter.js', () => ({
isProjectExcluded: () => false,
}));
// Import after mocks
import { fileContextHandler } from '../../src/cli/handlers/file-context.js';
import { logger } from '../../src/utils/logger.js';
const PADDING = 'x'.repeat(2_000); // ensures file > FILE_READ_GATE_MIN_BYTES (1500)
let tmpDir: string;
let testFile: string;
let loggerSpies: ReturnType<typeof spyOn>[] = [];
let fetchSpy: ReturnType<typeof spyOn> | null = null;
function makeObservationsResponse(observations: Array<{ id: number; created_at_epoch: number; type?: string; title?: string }>) {
return new Response(
JSON.stringify({
observations: observations.map(o => ({
id: o.id,
memory_session_id: `session-${o.id}`,
title: o.title ?? `Observation ${o.id}`,
type: o.type ?? 'discovery',
created_at_epoch: o.created_at_epoch,
files_read: JSON.stringify([]),
files_modified: JSON.stringify(['test.md']),
})),
count: observations.length,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'file-context-test-'));
testFile = join(tmpDir, 'test.md');
writeFileSync(testFile, PADDING);
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
];
});
afterEach(() => {
loggerSpies.forEach(s => s.mockRestore());
if (fetchSpy) {
fetchSpy.mockRestore();
fetchSpy = null;
}
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
describe('fileContextHandler — cache validation fix (#1719)', () => {
it('truncates to limit:1 for an unconstrained Read (existing behavior)', async () => {
// File mtime is "now" (just written). Make observations newer to avoid mtime bypass.
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
expect(result.hookSpecificOutput).toBeDefined();
expect(result.hookSpecificOutput!.updatedInput).toEqual({
file_path: testFile,
limit: 1,
});
});
it('preserves user-supplied offset/limit on a targeted Read (#1719 fix)', async () => {
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile, offset: 289, limit: 140 },
});
expect(result.hookSpecificOutput).toBeDefined();
expect(result.hookSpecificOutput!.updatedInput).toEqual({
file_path: testFile,
offset: 289,
limit: 140,
});
});
it('preserves user-supplied offset only', async () => {
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile, offset: 100 },
});
expect(result.hookSpecificOutput!.updatedInput).toEqual({
file_path: testFile,
offset: 100,
});
expect((result.hookSpecificOutput!.updatedInput as any).limit).toBeUndefined();
});
it('preserves user-supplied limit only', async () => {
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile, limit: 50 },
});
expect(result.hookSpecificOutput!.updatedInput).toEqual({
file_path: testFile,
limit: 50,
});
// offset must NOT be present
expect((result.hookSpecificOutput!.updatedInput as any).offset).toBeUndefined();
});
it('bypasses truncation when file mtime is newer than newest observation (#1719 fix)', async () => {
// Backdate observations 1 hour into the past so the just-written file is newer.
const stale = Date.now() - 3_600_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([
{ id: 1, created_at_epoch: stale },
{ id: 2, created_at_epoch: stale - 1000 },
])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
// Pass-through: no hookSpecificOutput, no updatedInput rewrite
expect(result.continue).toBe(true);
expect(result.hookSpecificOutput).toBeUndefined();
});
it('still truncates when file mtime is older than newest observation', async () => {
// Backdate the file by 1 hour, observations stamped "now"
const past = (Date.now() - 3_600_000) / 1000;
utimesSync(testFile, past, past);
const now = Date.now();
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: now }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
expect(result.hookSpecificOutput).toBeDefined();
expect(result.hookSpecificOutput!.updatedInput).toEqual({
file_path: testFile,
limit: 1,
});
});
it('targeted-read header line reflects that the section was read normally', async () => {
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile, offset: 10, limit: 20 },
});
const ctx = result.hookSpecificOutput!.additionalContext;
expect(ctx).toContain('The requested section was read normally');
expect(ctx).not.toContain('Only line 1 was read');
});
});
@@ -138,3 +138,38 @@ describe('Plugin Distribution - Build Script Verification', () => {
expect(content).toContain('plugin/.claude-plugin/plugin.json');
});
});
describe('Plugin Distribution - Setup Hook (#1547)', () => {
it('should not reference removed setup.sh in Setup hook', () => {
// setup.sh was removed; the Setup hook must not reference it or the
// plugin silently fails to install on Linux (hooks disabled on setup failure).
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const content = readFileSync(hooksPath, 'utf-8');
expect(content).not.toContain('setup.sh');
});
it('should call smart-install.js in the Setup hook', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const setupHooks: any[] = parsed.hooks['Setup'] ?? [];
// Collect all command hooks from all matchers
const commandHooks = setupHooks.flatMap((matcher: any) =>
(matcher.hooks ?? []).filter((h: any) => h.type === 'command')
);
// There must be at least one command hook — otherwise the test vacuously passes
expect(commandHooks.length).toBeGreaterThan(0);
// At least one command hook must reference smart-install.js
const smartInstallHooks = commandHooks.filter((h: any) =>
h.command?.includes('smart-install.js')
);
expect(smartInstallHooks.length).toBeGreaterThan(0);
});
it('smart-install.js referenced by Setup hook should exist on disk', () => {
const smartInstallPath = path.join(projectRoot, 'plugin/scripts/smart-install.js');
expect(existsSync(smartInstallPath)).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const runtimeSourcePath = join(
__dirname,
'..',
'src',
'npx-cli',
'commands',
'runtime.ts',
);
const runtimeSource = readFileSync(runtimeSourcePath, 'utf-8');
describe('NPX search query param', () => {
it('documents the search endpoint with query param', () => {
expect(runtimeSource).toContain('GET /api/search?query=<query>');
});
it('uses query param instead of q param for worker search requests', () => {
expect(runtimeSource).toContain('/api/search?query=${encodeURIComponent(query)}');
expect(runtimeSource).not.toContain('/api/search?q=${encodeURIComponent(query)}');
});
});
@@ -0,0 +1,291 @@
/**
* Tests for Issue #1652: Stuck generator (zombie subprocess) detection in reapStaleSessions()
*
* Root cause: reapStaleSessions() unconditionally skipped sessions where
* `session.generatorPromise` was non-null, meaning generators stuck inside
* `for await (const msg of queryResult)` (blocked on a hung subprocess) were
* never cleaned up even after the session's Stop hook completed.
*
* Fix: Check `session.lastGeneratorActivity`. If it hasn't updated in
* MAX_GENERATOR_IDLE_MS (5 min), SIGKILL the subprocess to unblock the
* for-await, then abort the controller so the generator exits.
*
* Mock Justification (~30% mock code):
* - Session fixtures: Required to create valid ActiveSession objects with all
* required fields tests the actual detection logic, not fixture creation.
* - Process mock: Verify SIGKILL is sent and abort is called no real subprocess needed.
*/
import { describe, test, expect, beforeEach, afterEach, mock, setSystemTime } from 'bun:test';
import {
MAX_GENERATOR_IDLE_MS,
MAX_SESSION_IDLE_MS,
detectStaleGenerator,
type StaleGeneratorCandidate,
} from '../../../src/services/worker/SessionManager.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface MockProcess {
exitCode: number | null;
killed: boolean;
kill: (signal?: string) => boolean;
_lastSignal?: string;
}
function createMockProcess(exitCode: number | null = null): MockProcess {
const proc: MockProcess = {
exitCode,
killed: false,
kill(signal?: string) {
proc.killed = true;
proc._lastSignal = signal;
return true;
},
};
return proc;
}
interface TestSession extends StaleGeneratorCandidate {
sessionDbId: number;
startTime: number;
}
function createSession(overrides: Partial<TestSession> = {}): TestSession {
return {
sessionDbId: 1,
generatorPromise: null,
lastGeneratorActivity: Date.now(),
abortController: new AbortController(),
startTime: Date.now(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('reapStaleSessions — stale generator detection (Issue #1652)', () => {
describe('threshold constants', () => {
test('MAX_GENERATOR_IDLE_MS should be 5 minutes', () => {
expect(MAX_GENERATOR_IDLE_MS).toBe(5 * 60 * 1000);
});
test('MAX_SESSION_IDLE_MS should be 15 minutes', () => {
expect(MAX_SESSION_IDLE_MS).toBe(15 * 60 * 1000);
});
test('generator idle threshold should be less than session idle threshold', () => {
// Ensures stuck generators are cleaned up before idle no-generator sessions
expect(MAX_GENERATOR_IDLE_MS).toBeLessThan(MAX_SESSION_IDLE_MS);
});
});
describe('stale generator detection', () => {
test('should detect generator as stale when idle > 5 minutes', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - (MAX_GENERATOR_IDLE_MS + 1000), // 5m1s ago
});
const proc = createMockProcess();
const isStale = detectStaleGenerator(session, proc);
expect(isStale).toBe(true);
});
test('should NOT detect generator as stale when idle exactly at threshold', () => {
// At exactly the threshold we do NOT yet reap (strictly greater than).
// Freeze time so that both the session creation and detectStaleGenerator
// call share the same Date.now() value, preventing a race where the two
// calls return different timestamps and push the idle time over the boundary.
const now = Date.now();
setSystemTime(now);
try {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: now - MAX_GENERATOR_IDLE_MS,
});
const proc = createMockProcess();
const isStale = detectStaleGenerator(session, proc);
expect(isStale).toBe(false);
} finally {
setSystemTime(); // restore real time
}
});
test('should NOT detect generator as stale when idle < 5 minutes', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - 60_000, // 1 minute ago
});
const proc = createMockProcess();
const isStale = detectStaleGenerator(session, proc);
expect(isStale).toBe(false);
});
test('should NOT flag sessions without a generator (no generator = different code path)', () => {
const session = createSession({
generatorPromise: null,
// Even though lastGeneratorActivity is ancient, no generator means no stale-generator detection
lastGeneratorActivity: 0,
});
const proc = createMockProcess();
const isStale = detectStaleGenerator(session, proc);
expect(isStale).toBe(false);
});
});
describe('subprocess kill on stale generator', () => {
test('should SIGKILL the subprocess when stale generator detected', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - (MAX_GENERATOR_IDLE_MS + 5000),
});
const proc = createMockProcess(); // exitCode === null (still running)
detectStaleGenerator(session, proc);
expect(proc.killed).toBe(true);
expect(proc._lastSignal).toBe('SIGKILL');
});
test('should NOT attempt to kill an already-exited subprocess', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - (MAX_GENERATOR_IDLE_MS + 5000),
});
const proc = createMockProcess(0); // exitCode === 0 (already exited)
detectStaleGenerator(session, proc);
// Should not try to kill an already-exited process
expect(proc.killed).toBe(false);
});
test('should still abort controller even when no tracked subprocess found', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - (MAX_GENERATOR_IDLE_MS + 5000),
});
// proc is undefined — subprocess not tracked in ProcessRegistry
detectStaleGenerator(session, undefined);
// AbortController should still be aborted to signal the generator loop
expect(session.abortController.signal.aborted).toBe(true);
});
});
describe('abort controller on stale generator', () => {
test('should abort the session controller when stale generator detected', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - (MAX_GENERATOR_IDLE_MS + 1000),
});
const proc = createMockProcess();
expect(session.abortController.signal.aborted).toBe(false);
detectStaleGenerator(session, proc);
expect(session.abortController.signal.aborted).toBe(true);
});
test('should NOT abort controller for fresh generator', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - 30_000, // 30 seconds ago — fresh
});
const proc = createMockProcess();
detectStaleGenerator(session, proc);
expect(session.abortController.signal.aborted).toBe(false);
});
});
describe('idle session reaping (existing behaviour preserved)', () => {
test('idle session without generator should be reaped after 15 minutes', () => {
const session = createSession({
generatorPromise: null,
startTime: Date.now() - (MAX_SESSION_IDLE_MS + 1000), // 15m1s ago
});
// Simulate the existing idle-session path (no generator, no pending work)
const sessionAge = Date.now() - session.startTime;
const shouldReap = !session.generatorPromise && sessionAge > MAX_SESSION_IDLE_MS;
expect(shouldReap).toBe(true);
});
test('idle session without generator should NOT be reaped before 15 minutes', () => {
const session = createSession({
generatorPromise: null,
startTime: Date.now() - (10 * 60 * 1000), // 10 minutes ago
});
const sessionAge = Date.now() - session.startTime;
const shouldReap = !session.generatorPromise && sessionAge > MAX_SESSION_IDLE_MS;
expect(shouldReap).toBe(false);
});
test('session with active generator should never be reaped by idle-session path', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
startTime: Date.now() - (60 * 60 * 1000), // 1 hour ago — very old
// But generator was active recently (fresh activity)
lastGeneratorActivity: Date.now() - 10_000,
});
const proc = createMockProcess();
// Stale generator detection says NOT stale (activity is fresh)
const isStaleGenerator = detectStaleGenerator(session, proc);
expect(isStaleGenerator).toBe(false);
// Idle-session path is skipped because generatorPromise is non-null
expect(session.generatorPromise).not.toBeNull();
});
});
describe('lastGeneratorActivity update semantics', () => {
test('should be initialized to session startTime to avoid false positives on boot', () => {
// When a session is first created, lastGeneratorActivity must be set to a
// recent time so the generator isn't immediately flagged as stale before it
// has had a chance to produce output.
const now = Date.now();
const session = createSession({
startTime: now,
lastGeneratorActivity: now, // mirrors SessionManager initialization
});
const generatorIdleMs = now - session.lastGeneratorActivity;
expect(generatorIdleMs).toBeLessThan(MAX_GENERATOR_IDLE_MS);
});
test('should be updated when generator yields a message (prevents false positive reap)', () => {
const session = createSession({
generatorPromise: Promise.resolve(),
lastGeneratorActivity: Date.now() - (MAX_GENERATOR_IDLE_MS - 10_000), // 4m50s ago
});
// Simulate the getMessageIterator yielding a message:
session.lastGeneratorActivity = Date.now();
// Generator is now fresh — should not be reaped
const generatorIdleMs = Date.now() - session.lastGeneratorActivity;
expect(generatorIdleMs).toBeLessThan(MAX_GENERATOR_IDLE_MS);
});
});
});
+117
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { spawnSync } from 'child_process';
import { checkBinaryPlatformCompatibility } from '../plugin/scripts/smart-install.js';
/**
* Smart Install Script Tests
@@ -237,3 +238,119 @@ describe('smart-install stdout JSON output (#1253)', () => {
}
});
});
/**
* Tests for checkBinaryPlatformCompatibility() (#1547).
*
* The bundled plugin/scripts/claude-mem binary is macOS arm64 only.
* On Linux/Windows it cannot execute and hooks fail silently.
* These tests call the production function directly, mocking process.platform
* and passing controlled binary paths to verify Mach-O detection behaviour.
*/
describe('smart-install binary platform compatibility (#1547)', () => {
let testDir: string;
let originalPlatform: PropertyDescriptor | undefined;
beforeEach(() => {
testDir = join(tmpdir(), `claude-mem-binary-compat-test-${process.pid}`);
mkdirSync(testDir, { recursive: true });
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
// Restore process.platform
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
});
function setPlatform(value: string) {
Object.defineProperty(process, 'platform', { value, configurable: true });
}
it('should detect native arm64/x86_64 Mach-O binary and warn on Linux', () => {
// Real macOS arm64 binary header: bytes CF FA ED FE (MH_MAGIC_64)
const binaryPath = join(testDir, 'claude-mem');
writeFileSync(binaryPath, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x0C, 0x00, 0x00, 0x01]));
const stderrLines: string[] = [];
const originalError = console.error;
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
setPlatform('linux');
try {
checkBinaryPlatformCompatibility(binaryPath);
} finally {
console.error = originalError;
}
expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(true);
expect(stderrLines.some(l => l.includes('linux'))).toBe(true);
});
it('should detect byte-swapped Mach-O binary and warn on Linux', () => {
// Byte-swapped 64-bit Mach-O: bytes FE ED FA CF (MH_CIGAM_64)
const binaryPath = join(testDir, 'claude-mem-swapped');
writeFileSync(binaryPath, Buffer.from([0xFE, 0xED, 0xFA, 0xCF, 0x01, 0x00, 0x00, 0x0C]));
const stderrLines: string[] = [];
const originalError = console.error;
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
setPlatform('linux');
try {
checkBinaryPlatformCompatibility(binaryPath);
} finally {
console.error = originalError;
}
expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(true);
});
it('should NOT warn for an ELF binary (Linux native) on Linux', () => {
// ELF magic: 0x7F 'E' 'L' 'F'
const binaryPath = join(testDir, 'claude-mem-elf');
writeFileSync(binaryPath, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00]));
const stderrLines: string[] = [];
const originalError = console.error;
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
setPlatform('linux');
try {
checkBinaryPlatformCompatibility(binaryPath);
} finally {
console.error = originalError;
}
expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(false);
});
it('should not throw when binary path does not exist', () => {
const binaryPath = join(testDir, 'nonexistent-claude-mem');
expect(existsSync(binaryPath)).toBe(false);
setPlatform('linux');
expect(() => checkBinaryPlatformCompatibility(binaryPath)).not.toThrow();
});
it('should skip the check entirely when platform is darwin', () => {
// Write a Mach-O binary — on macOS the check returns early, so no warning
const binaryPath = join(testDir, 'claude-mem');
writeFileSync(binaryPath, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x0C, 0x00, 0x00, 0x01]));
const stderrLines: string[] = [];
const originalError = console.error;
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
setPlatform('darwin');
try {
checkBinaryPlatformCompatibility(binaryPath);
} finally {
console.error = originalError;
}
expect(stderrLines.length).toBe(0);
});
});
+48 -1
View File
@@ -5,7 +5,7 @@
* Source: src/utils/project-name.ts
*/
import { describe, it, expect } from 'bun:test';
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { homedir } from 'os';
import { getProjectName, getProjectContext } from '../../src/utils/project-name.js';
@@ -96,4 +96,51 @@ describe('getProjectContext', () => {
expect(ctx.primary).toBe('unknown-project');
expect(ctx.parent).toBeNull();
});
describe('worktree regression (#1081, #1500, #1819)', () => {
let tmp: string;
let mainRepo: string;
let worktreeCheckout: string;
beforeAll(async () => {
const { mkdtempSync, mkdirSync, writeFileSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
tmp = mkdtempSync(join(tmpdir(), 'cm-wt-'));
mainRepo = join(tmp, 'main-repo');
const worktreeGitDir = join(mainRepo, '.git', 'worktrees', 'my-worktree');
worktreeCheckout = join(tmp, 'my-worktree');
mkdirSync(worktreeGitDir, { recursive: true });
mkdirSync(worktreeCheckout, { recursive: true });
writeFileSync(
join(worktreeCheckout, '.git'),
`gitdir: ${worktreeGitDir}\n`
);
});
afterAll(async () => {
const { rmSync } = await import('fs');
rmSync(tmp, { recursive: true, force: true });
});
it('uses parent project name as primary when in a worktree', () => {
const ctx = getProjectContext(worktreeCheckout);
expect(ctx.isWorktree).toBe(true);
expect(ctx.primary).toBe('main-repo');
expect(ctx.parent).toBe('main-repo');
expect(ctx.allProjects).toEqual(['main-repo', 'my-worktree']);
});
it('write-path call sites resolve to parent project in worktrees', () => {
// Mirrors the pattern used by session-init.ts and SessionRoutes.ts:
// const project = getProjectContext(cwd).primary;
// This must resolve to the parent repo, not the worktree name,
// so observations are stored under the correct project.
const project = getProjectContext(worktreeCheckout).primary;
expect(project).toBe('main-repo');
expect(project).not.toBe('my-worktree');
});
});
});
@@ -319,9 +319,7 @@ describe('ResponseProcessor', () => {
);
const [, , , summary] = mockStoreObservations.mock.calls[0];
// #1718: When observations exist without <summary> tags, a synthetic summary is salvaged
expect(summary).not.toBeNull();
expect(summary.notes).toContain('Salvaged from');
expect(summary).toBeNull();
});
});
@@ -0,0 +1,174 @@
/**
* CorpusRoutes Type Coercion Tests
*
* Tests that MCP/HTTP clients sending string-encoded corpus filters are coerced
* before CorpusBuilder assumes array and number fields.
*/
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import type { Request, Response } from 'express';
import { CorpusRoutes } from '../../../../src/services/worker/http/routes/CorpusRoutes.js';
function createMockReqRes(body: any): {
req: Partial<Request>;
res: Partial<Response>;
jsonSpy: ReturnType<typeof mock>;
statusSpy: ReturnType<typeof mock>;
} {
const jsonSpy = mock(() => {});
const statusSpy = mock(() => ({ json: jsonSpy }));
return {
req: { body, path: '/api/corpus', params: {}, query: {} } as Partial<Request>,
res: { json: jsonSpy, status: statusSpy, headersSent: false } as unknown as Partial<Response>,
jsonSpy,
statusSpy,
};
}
function createCorpus(name: string, filter: any) {
return {
version: 1 as const,
name,
description: '',
created_at: '2026-04-14T00:00:00.000Z',
updated_at: '2026-04-14T00:00:00.000Z',
filter,
stats: {
observation_count: 0,
token_estimate: 0,
date_range: { earliest: '', latest: '' },
type_breakdown: {},
},
system_prompt: '',
session_id: null,
observations: [],
};
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('CorpusRoutes Type Coercion', () => {
let handler: (req: Request, res: Response) => void;
let mockBuild: ReturnType<typeof mock>;
beforeEach(() => {
mockBuild = mock((name: string, description: string, filter: any) => Promise.resolve(createCorpus(name, filter)));
const routes = new CorpusRoutes(
{ list: mock(() => []), read: mock(() => null), delete: mock(() => false) } as any,
{ build: mockBuild } as any,
{} as any
);
const mockApp = {
post: mock((path: string, fn: any) => {
if (path === '/api/corpus') handler = fn;
}),
get: mock(() => {}),
delete: mock(() => {}),
};
routes.setupRoutes(mockApp as any);
});
it('accepts native array filters and numeric limit', async () => {
const { req, res, jsonSpy } = createMockReqRes({
name: 'native',
types: ['decision', 'bugfix'],
concepts: ['hooks'],
files: ['src/a.ts'],
limit: 10,
});
handler(req as Request, res as Response);
await flushPromises();
expect(mockBuild).toHaveBeenCalledWith('native', '', {
types: ['decision', 'bugfix'],
concepts: ['hooks'],
files: ['src/a.ts'],
limit: 10,
});
expect(jsonSpy).toHaveBeenCalled();
});
it('coerces JSON-encoded string filters and string limit', async () => {
const { req, res } = createMockReqRes({
name: 'json-strings',
types: '["decision","bugfix"]',
concepts: '["hooks","agent"]',
files: '["src/a.ts","src/b.ts"]',
limit: '25',
});
handler(req as Request, res as Response);
await flushPromises();
expect(mockBuild).toHaveBeenCalledWith('json-strings', '', {
types: ['decision', 'bugfix'],
concepts: ['hooks', 'agent'],
files: ['src/a.ts', 'src/b.ts'],
limit: 25,
});
});
it('coerces comma-separated filters and trims whitespace', async () => {
const { req, res } = createMockReqRes({
name: 'comma-strings',
types: 'decision, bugfix',
concepts: 'hooks, agent',
files: 'src/a.ts, src/b.ts',
});
handler(req as Request, res as Response);
await flushPromises();
expect(mockBuild).toHaveBeenCalledWith('comma-strings', '', {
types: ['decision', 'bugfix'],
concepts: ['hooks', 'agent'],
files: ['src/a.ts', 'src/b.ts'],
});
});
it('rejects invalid array items before calling CorpusBuilder', async () => {
const { req, res, statusSpy } = createMockReqRes({
name: 'bad-array',
concepts: ['hooks', 42],
});
handler(req as Request, res as Response);
await flushPromises();
expect(statusSpy).toHaveBeenCalledWith(400);
expect(mockBuild).not.toHaveBeenCalled();
});
it('rejects unsupported corpus types before calling CorpusBuilder', async () => {
const { req, res, statusSpy } = createMockReqRes({
name: 'bad-type',
types: ['typo'],
});
handler(req as Request, res as Response);
await flushPromises();
expect(statusSpy).toHaveBeenCalledWith(400);
expect(mockBuild).not.toHaveBeenCalled();
});
it('rejects invalid limit before calling CorpusBuilder', async () => {
const { req, res, statusSpy } = createMockReqRes({
name: 'bad-limit',
limit: 'many',
});
handler(req as Request, res as Response);
await flushPromises();
expect(statusSpy).toHaveBeenCalledWith(400);
expect(mockBuild).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,251 @@
/**
* Tests for Issue #1590: Session lifecycle guards to prevent runaway API spend
*
* Validates three lifecycle safety mechanisms:
* 1. SIGTERM detection: externally-killed processes must NOT trigger crash recovery
* 2. Wall-clock age limit: sessions older than MAX_SESSION_WALL_CLOCK_MS must be terminated
* 3. Duplicate process prevention: a new spawn for a session kills any existing process first
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { EventEmitter } from 'events';
import {
registerProcess,
unregisterProcess,
getProcessBySession,
getActiveCount,
getActiveProcesses,
createPidCapturingSpawn,
} from '../../src/services/worker/ProcessRegistry.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMockProcess(overrides: { exitCode?: number | null; killed?: boolean } = {}) {
const emitter = new EventEmitter();
const mock = Object.assign(emitter, {
pid: Math.floor(Math.random() * 100_000) + 10_000,
exitCode: overrides.exitCode ?? null,
killed: overrides.killed ?? false,
stdin: null as null,
stdout: null as null,
stderr: null as null,
kill(signal?: string) {
mock.killed = true;
setTimeout(() => {
mock.exitCode = 0;
mock.emit('exit', mock.exitCode, signal || 'SIGTERM');
}, 10);
return true;
},
on: emitter.on.bind(emitter),
once: emitter.once.bind(emitter),
off: emitter.off.bind(emitter),
});
return mock;
}
function clearRegistry() {
for (const p of getActiveProcesses()) {
unregisterProcess(p.pid);
}
}
// ---------------------------------------------------------------------------
// 1. SIGTERM detection — does NOT trigger crash recovery
// ---------------------------------------------------------------------------
describe('SIGTERM detection (Issue #1590)', () => {
it('should classify "code 143" as a SIGTERM error', () => {
const errorMsg = 'Claude Code process exited with code 143';
const isSigterm = errorMsg.includes('code 143') || errorMsg.includes('signal SIGTERM');
expect(isSigterm).toBe(true);
});
it('should classify "signal SIGTERM" as a SIGTERM error', () => {
const errorMsg = 'Process terminated with signal SIGTERM';
const isSigterm = errorMsg.includes('code 143') || errorMsg.includes('signal SIGTERM');
expect(isSigterm).toBe(true);
});
it('should NOT classify ordinary errors as SIGTERM', () => {
const errorMsg = 'Invalid API key';
const isSigterm = errorMsg.includes('code 143') || errorMsg.includes('signal SIGTERM');
expect(isSigterm).toBe(false);
});
it('should NOT classify code 1 (normal error) as SIGTERM', () => {
const errorMsg = 'Claude Code process exited with code 1';
const isSigterm = errorMsg.includes('code 143') || errorMsg.includes('signal SIGTERM');
expect(isSigterm).toBe(false);
});
it('aborting the controller should mark wasAborted=true, preventing crash recovery', () => {
// Simulate what the catch handler does: abort when SIGTERM detected
const abortController = new AbortController();
expect(abortController.signal.aborted).toBe(false);
// SIGTERM arrives — we abort the controller
abortController.abort();
// By the time .finally() runs, wasAborted should be true
const wasAborted = abortController.signal.aborted;
expect(wasAborted).toBe(true);
});
it('should NOT abort the controller for non-SIGTERM crash errors', () => {
const abortController = new AbortController();
const errorMsg = 'FOREIGN KEY constraint failed';
// Non-SIGTERM: do NOT abort
const isSigterm = errorMsg.includes('code 143') || errorMsg.includes('signal SIGTERM');
if (isSigterm) {
abortController.abort();
}
expect(abortController.signal.aborted).toBe(false);
});
});
// ---------------------------------------------------------------------------
// 2. Wall-clock age limit
// ---------------------------------------------------------------------------
describe('Wall-clock age limit (Issue #1590)', () => {
const MAX_SESSION_WALL_CLOCK_MS = 4 * 60 * 60 * 1000; // 4 hours (matches SessionRoutes)
it('should NOT terminate a session started < 4 hours ago', () => {
const startTime = Date.now() - 30 * 60 * 1000; // 30 minutes ago
const sessionAgeMs = Date.now() - startTime;
expect(sessionAgeMs).toBeLessThan(MAX_SESSION_WALL_CLOCK_MS);
});
it('should NOT terminate a session started exactly 4 hours ago (strict >)', () => {
// Production uses strict `>` (not `>=`), so exactly 4h is still alive.
const startTime = Date.now() - MAX_SESSION_WALL_CLOCK_MS;
const sessionAgeMs = Date.now() - startTime;
// At exactly the boundary, sessionAgeMs === MAX, and `>` is false → no termination.
expect(sessionAgeMs).toBeLessThanOrEqual(MAX_SESSION_WALL_CLOCK_MS);
});
it('should terminate a session started more than 4 hours ago', () => {
const startTime = Date.now() - MAX_SESSION_WALL_CLOCK_MS - 1;
const sessionAgeMs = Date.now() - startTime;
expect(sessionAgeMs).toBeGreaterThan(MAX_SESSION_WALL_CLOCK_MS);
});
it('should terminate a session started 13+ hours ago (the issue scenario)', () => {
const startTime = Date.now() - 13 * 60 * 60 * 1000; // 13 hours ago
const sessionAgeMs = Date.now() - startTime;
expect(sessionAgeMs).toBeGreaterThan(MAX_SESSION_WALL_CLOCK_MS);
});
it('aborting + draining pending queue should prevent respawn', () => {
// Simulate the wall-clock termination sequence:
// 1. Abort controller (stops active generator)
// 2. Mark pending messages abandoned (no work to restart for)
// 3. Remove session from map
const abortController = new AbortController();
let pendingAbandoned = 0;
let sessionRemoved = false;
// Simulate abort
abortController.abort();
expect(abortController.signal.aborted).toBe(true);
// Simulate markAllSessionMessagesAbandoned
pendingAbandoned = 3; // Pretend 3 messages were abandoned
// Simulate removeSessionImmediate
sessionRemoved = true;
expect(pendingAbandoned).toBeGreaterThanOrEqual(0);
expect(sessionRemoved).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 3. Duplicate process prevention in createPidCapturingSpawn
// ---------------------------------------------------------------------------
describe('Duplicate process prevention (Issue #1590)', () => {
beforeEach(() => {
clearRegistry();
});
afterEach(() => {
clearRegistry();
});
it('should detect a duplicate when a live process already exists for the session', () => {
const proc = createMockProcess();
registerProcess(proc.pid, 42, proc as any);
const existing = getProcessBySession(42);
expect(existing).toBeDefined();
expect(existing!.process.exitCode).toBeNull(); // Still alive
});
it('should NOT detect a duplicate when the existing process has already exited', () => {
const proc = createMockProcess({ exitCode: 0 });
registerProcess(proc.pid, 42, proc as any);
const existing = getProcessBySession(42);
expect(existing).toBeDefined();
// exitCode is set — process is already done, NOT a live duplicate
expect(existing!.process.exitCode).not.toBeNull();
});
it('should kill existing process and unregister before spawning', () => {
const existingProc = createMockProcess();
registerProcess(existingProc.pid, 99, existingProc as any);
expect(getActiveCount()).toBe(1);
// Simulate the duplicate-kill logic:
const duplicate = getProcessBySession(99);
if (duplicate && duplicate.process.exitCode === null) {
try { duplicate.process.kill('SIGTERM'); } catch { /* already dead */ }
unregisterProcess(duplicate.pid);
}
expect(getActiveCount()).toBe(0);
expect(getProcessBySession(99)).toBeUndefined();
});
it('should leave registry empty after killing duplicate so new process can register', () => {
const oldProc = createMockProcess();
registerProcess(oldProc.pid, 77, oldProc as any);
expect(getActiveCount()).toBe(1);
// Kill duplicate
const dup = getProcessBySession(77);
if (dup && dup.process.exitCode === null) {
try { dup.process.kill('SIGTERM'); } catch { /* ignore */ }
unregisterProcess(dup.pid);
}
expect(getActiveCount()).toBe(0);
// New process can now register cleanly
const newProc = createMockProcess();
registerProcess(newProc.pid, 77, newProc as any);
expect(getActiveCount()).toBe(1);
const found = getProcessBySession(77);
expect(found!.pid).toBe(newProc.pid);
});
it('should not interfere when no existing process is registered', () => {
expect(getProcessBySession(55)).toBeUndefined();
// Duplicate-kill logic: should be a no-op
const dup = getProcessBySession(55);
if (dup && dup.process.exitCode === null) {
unregisterProcess(dup.pid);
}
// Registry should still be empty — no side effects
expect(getActiveCount()).toBe(0);
});
});