Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ddf57610a | |||
| d0fc68c630 | |||
| 1d7500604f | |||
| 05232ff091 | |||
| b411d91885 | |||
| 4538e686ad | |||
| f97c50bfb9 | |||
| 983be42998 | |||
| 544e9d39f5 | |||
| 16a0737dfc | |||
| 3d92684e04 | |||
| 471e1f62f9 | |||
| f44605658d | |||
| eeb6841033 | |||
| 2a2008bac2 | |||
| d64c252f4d | |||
| 59ce0fc553 | |||
| 31ee1024c5 | |||
| 7d5d4c5036 |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.1.1",
|
||||
"version": "12.1.3",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.1.1",
|
||||
"version": "12.1.3",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.1.1",
|
||||
"version": "12.1.3",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman",
|
||||
|
||||
@@ -4,6 +4,45 @@ 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.1] - 2026-
|
||||
✅ CHANGELOG.md generated successfully!
|
||||
226 releases processed
|
||||
dresses 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.1.1",
|
||||
"version": "12.1.3",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.1.1",
|
||||
"version": "12.1.3",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "12.1.1",
|
||||
"version": "12.1.3",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
|
||||
+255
-255
File diff suppressed because one or more lines are too long
+11
-11
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -97,7 +97,6 @@ export const SAMPLE_CONFIG: TranscriptWatchConfig = {
|
||||
startAtEnd: true,
|
||||
context: {
|
||||
mode: 'agents',
|
||||
path: '~/.codex/AGENTS.md',
|
||||
updateOn: ['session_start', 'session_end']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -382,21 +382,51 @@ 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: Bun's spawn() silently drops empty strings from argv,
|
||||
// causing subsequent flags to be consumed as values for the preceding flag.
|
||||
// The Agent SDK may produce empty-string args (e.g., settingSources defaults to []
|
||||
// which joins to ""). Node preserves these, but Bun drops them, breaking CLI parsing.
|
||||
const args = spawnOptions.args.filter(arg => 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'],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user