From 65470a4495ed28fd0e4be90d98c931accd08a841 Mon Sep 17 00:00:00 2001 From: ericwalisko Date: Thu, 3 Sep 2026 20:43:58 -0400 Subject: [PATCH 1/2] Add SessionStart catch-up hook for force-killed sessions; release 0.2.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some environments force-kill Claude Code instead of ending it cleanly: a worktree/pane manager (e.g. Orca) SIGKILLs the process group when you delete a worktree, and a SIGKILL cannot run any hook. Verified empirically — a signal-trap process inside an Orca worktree got no trappable signal and its EXIT trap never ran when the worktree was deleted — and in Orca's own code (process.kill(-pgid, "SIGKILL")). So the SessionEnd sync + backup silently never fire in that flow; only sessions that hit PreCompact get indexed. Raw transcripts stay durable on disk, but the searchable index falls behind. Fix by moving the safety net to SessionStart, which always runs: - New: bare `memware sync` (no path, not --from-hook) catches up the configured backup.transcript_src (default ~/.claude/projects), so it indexes whatever the last force-killed session left unsynced. - New SessionStart hook runs that catch-up sync plus a throttled `memware backup --if-stale 20`, backgrounded (nohup … &) so session startup is never delayed and nothing is injected into context. - SessionEnd/PreCompact are kept — SessionEnd still fires on clean exits. Bumps to 0.2.6 (package + both manifests, per the guard test). Docs (README, integrations.md) and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NnaQwZUSDRbVUNh8fsdksD --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- docs/integrations.md | 3 +++ .../claude-code/.claude-plugin/plugin.json | 2 +- integrations/claude-code/hooks/hooks.json | 11 +++++++++++ src/memware/__init__.py | 2 +- src/memware/cli.py | 9 +++++++++ tests/test_cli.py | 19 +++++++++++++++++++ 9 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6fdae5f..b522862 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "memware", "source": "./integrations/claude-code", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth. Hooks: SessionEnd/PreCompact sync, prompt-time belief context.", - "version": "0.2.5", + "version": "0.2.6", "author": { "name": "ericwalisko" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6e0aa..8df3174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.2.6] - 2026-09-03 + +### Added +- A `SessionStart` plugin hook that catches up any session whose `SessionEnd` never ran. Some + environments force-kill Claude Code — a worktree/pane manager can `SIGKILL` the process + group on close, and a `SIGKILL` cannot run `SessionEnd` — so its sync + backup were skipped. + On the next start, a bare `memware sync` (new: no path = catch up the configured + `backup.transcript_src`, default `~/.claude/projects`) plus a throttled backup run, + **backgrounded** so startup is never delayed. Raw transcripts were durable regardless; this + makes the *index* current without relying on a clean exit. + ## [0.2.5] - 2026-09-03 ### Added diff --git a/README.md b/README.md index 0f06ca6..4e1803b 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ transcripts. It fills as you work (via the `remember` tool, or a derive job you Transcript recall is what backfill gives you immediately, and it is where most of the value is. Requires the `memware` CLI on your `PATH` (see [Install](#install)). Hooks: -`SessionEnd`/`PreCompact` sync the transcript into the index; an optional `UserPromptSubmit` +a `SessionStart` hook catches up any session whose `SessionEnd` was skipped (some environments force-kill Claude Code — a worktree manager may `SIGKILL` it — and a kill cannot run `SessionEnd`); `SessionEnd`/`PreCompact` sync the transcript into the index; an optional `UserPromptSubmit` hook injects the handful of currently valid beliefs whose subject the prompt names (beliefs only — transcript search is on demand through the MCP tools). Set `MEMWARE_DB` to move the store, and `MEMWARE_NO_CAPTURE=1` for any session you do not want indexed. See diff --git a/docs/integrations.md b/docs/integrations.md index 0eedb43..374376a 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -22,9 +22,12 @@ Hooks (`hooks/hooks.json`): | event | command | effect | |---|---|---| +| `SessionStart` | `memware sync` (catch-up) + `memware backup --if-stale 20`, backgrounded | indexes any session whose `SessionEnd` never ran, then a throttled backup — see note | | `SessionEnd`, `PreCompact` | `memware sync --harness claude-code --from-hook` | indexes the session's new turns from `transcript_path` | | `UserPromptSubmit` (optional) | `memware context --from-hook` | injects the few currently valid beliefs relevant to the prompt as `additionalContext` | +`SessionEnd` runs when Claude Code exits cleanly, but some environments **force-kill** it (a worktree/pane manager may `SIGKILL` the process group on close), and a `SIGKILL` cannot run any hook. The `SessionStart` hook covers that: it runs a bare `memware sync` — which catches up the configured `backup.transcript_src` (default `~/.claude/projects`) — plus a throttled backup, **backgrounded** so it never delays startup. So the previous session is indexed at the next start even if its `SessionEnd` was skipped; the raw transcript is durable on disk regardless. + The prompt-time hook injects **beliefs only**, capped by `-k`. Transcript search is on demand through the MCP server. Add it at **user** scope so every project sees it — the default (`local`) scopes the server to the one directory diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index f64aaee..24ce2a4 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memware", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth.", - "version": "0.2.5", + "version": "0.2.6", "author": { "name": "ericwalisko" } diff --git a/integrations/claude-code/hooks/hooks.json b/integrations/claude-code/hooks/hooks.json index d61fc6e..fc2f2fc 100644 --- a/integrations/claude-code/hooks/hooks.json +++ b/integrations/claude-code/hooks/hooks.json @@ -1,5 +1,16 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "nohup sh -c 'memware sync --harness claude-code; memware backup --if-stale 20 --quiet' >/dev/null 2>&1 & true", + "timeout": 10 + } + ] + } + ], "SessionEnd": [ { "hooks": [ diff --git a/src/memware/__init__.py b/src/memware/__init__.py index 4cdd26f..0a6292c 100644 --- a/src/memware/__init__.py +++ b/src/memware/__init__.py @@ -22,4 +22,4 @@ "history", "reject", ] -__version__ = "0.2.5" +__version__ = "0.2.6" diff --git a/src/memware/cli.py b/src/memware/cli.py index fc12f32..fbf8cf2 100644 --- a/src/memware/cli.py +++ b/src/memware/cli.py @@ -54,6 +54,15 @@ def cmd_sync(a: argparse.Namespace) -> int: tp = _hook_payload().get("transcript_path") if tp: paths.append(str(tp)) + if not paths and not a.from_hook: + # Bare `memware sync` = catch up the configured transcript source (default + # ~/.claude/projects). The SessionStart hook uses this to index sessions whose + # SessionEnd never ran — e.g. a worktree manager that SIGKILLs the process group. + from memware.config import get_dotted, load_config + + src = get_dotted(load_config(), "backup.transcript_src") + if src: + paths.append(str(src)) if not paths: print("nothing to sync", file=sys.stderr) return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 193eb8f..158e17c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -107,3 +107,22 @@ def test_setup_hint_shows_until_backups_configured(tmp_path, capsys, monkeypatch capsys.readouterr() main(["--db", db, "stats"]) # once a destination exists the tip is gone assert "memware setup" not in capsys.readouterr().err + + +def test_bare_sync_catches_up_configured_transcript_src(tmp_path, capsys, monkeypatch): + """`memware sync` with no path indexes the configured transcript source — what the + SessionStart hook runs to catch up sessions whose SessionEnd never fired (e.g. a worktree + force-killed by Orca). A path or --from-hook still targets exactly what's given.""" + monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "home")) + projects = tmp_path / "projects" + (projects / "p").mkdir(parents=True) + write_claude_jsonl( + projects / "p" / "s.jsonl", + "s", + [("assistant", "2026-08-25T00:00:00Z", "the indexer catches up on the next session start")], + ) + db = str(tmp_path / "m.db") + main(["--db", db, "config", "backup.transcript_src", str(projects)]) + capsys.readouterr() + assert main(["--db", db, "sync", "--json"]) == 0 # no path -> configured source + assert json.loads(capsys.readouterr().out)["added"] == 1 From f8298892b82ca1a608e18b3459f8519d9d6458c8 Mon Sep 17 00:00:00 2001 From: ericwalisko Date: Thu, 3 Sep 2026 20:45:50 -0400 Subject: [PATCH 2/2] store: PRAGMA busy_timeout=5000 for concurrent writers The SessionStart catch-up runs sync + backup in the background on every start, so several memware processes can now touch the store at once (SessionStart catch-up, a session-end sync, the backup cron). WAL permits one writer at a time; without a busy timeout a concurrent write fails immediately with "database is locked". Wait-and-retry instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NnaQwZUSDRbVUNh8fsdksD --- CHANGELOG.md | 3 +++ src/memware/store.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df3174..9db867b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to this project are documented here. The format follows **backgrounded** so startup is never delayed. Raw transcripts were durable regardless; this makes the *index* current without relying on a clean exit. +### Changed +- The store now sets `PRAGMA busy_timeout=5000`, so a concurrent writer waits and retries instead of failing with "database is locked" — the SessionStart catch-up, a session-end sync, and the backup cron can now overlap safely. + ## [0.2.5] - 2026-09-03 ### Added diff --git a/src/memware/store.py b/src/memware/store.py index 422e7b1..cfe6c1a 100644 --- a/src/memware/store.py +++ b/src/memware/store.py @@ -144,6 +144,10 @@ def __init__(self, path: str | os.PathLike[str] | None = None) -> None: self.conn.row_factory = sqlite3.Row self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA synchronous=NORMAL") + # WAL allows one writer at a time; wait-and-retry rather than failing a concurrent + # write with "database is locked". Several memware processes can touch the store at + # once — the SessionStart catch-up, a session-end sync, and the backup cron can overlap. + self.conn.execute("PRAGMA busy_timeout=5000") if not self._has_fts5(): raise RuntimeError("this SQLite build lacks FTS5; memware requires it") self.conn.executescript(SCHEMA)