refactor: land PATHFINDER Plan 05 — hook surface

Worker-call plumbing collapsed to one helper. Polling replaced by
server-side blocking endpoint. Fail-loud counter surfaces persistent
worker outages via exit code 2.

- Phase 1: plugin/hooks/hooks.json — three 20-iteration `for i in
  1..20; do curl -sf .../health && break; sleep 0.1; done` shell
  retry wrappers deleted. Hook commands invoke their bun entry
  point directly.
- Phase 2: src/shared/worker-utils.ts — added
  executeWithWorkerFallback<T>(url, method, body) returning
  T | { continue: true; reason?: string }. All 8 hook handlers
  (observation, session-init, context, file-context, file-edit,
  summarize, session-complete, user-message) rewritten to use
  it instead of duplicating the ensureWorkerRunning →
  workerHttpRequest → fallback sequence.
- Phase 3: blocking POST /api/session/end in SessionRoutes.ts
  using validateBody + sessionEndSchema (z.object({sessionId})).
  One-shot ingestEventBus.on('summaryStoredEvent') listener,
  30 s timer, req.aborted handler — all share one cleanup so
  the listener cannot leak. summarize.ts polling loop, plus
  MAX_WAIT_FOR_SUMMARY_MS / POLL_INTERVAL_MS constants, deleted.
- Phase 4: src/shared/hook-settings.ts — loadFromFileOnce()
  memoizes SettingsDefaultsManager.loadFromFile per process.
  Per-handler settings reads collapsed.
- Phase 5: src/shared/should-track-project.ts — single exclusion
  check entry; isProjectExcluded no longer referenced from
  src/cli/handlers/.
- Phase 6: cwd validation pushed into adapter normalizeInput
  (all 6 adapters: claude-code, cursor, raw, gemini-cli,
  windsurf). New AdapterRejectedInput error in
  src/cli/adapters/errors.ts. Handler-level isValidCwd checks
  deleted from file-edit.ts and observation.ts. hook-command.ts
  catches AdapterRejectedInput → graceful fallback.
- Phase 7: session-init.ts conditional initAgent guard deleted;
  initAgent is idempotent. tests/hooks/context-reinjection-guard
  test (validated the deleted conditional) deleted in same PR
  per Principle 7.
- Phase 8: fail-loud counter at ~/.claude-mem/state/hook-failures
  .json. Atomic write via .tmp + rename. CLAUDE_MEM_HOOK_FAIL_LOUD
  _THRESHOLD setting (default 3). On consecutive worker-unreachable
  ≥ N: process.exit(2). On success: reset to 0. NOT a retry.
- Phase 9: ensureWorkerAliveOnce() module-scope memoization
  wrapping ensureWorkerRunning. executeWithWorkerFallback calls
  the memoized version.

Minimal validateBody middleware stub at
src/services/worker/http/middleware/validateBody.ts. Plan 06 will
expand with typed inference + error envelope conventions.

Verification: 4/4 grep targets pass. bun run build succeeds.
bun test → 1393 pass / 28 fail / 7 skip; -6 pass attributable
solely to deleted context-reinjection-guard test file. Zero new
failures vs baseline.

Plan: PATHFINDER-2026-04-22/05-hook-surface.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-23 02:32:39 -07:00
parent 520b0967a7
commit 593a71882b
26 changed files with 995 additions and 1024 deletions

View File

@@ -24,12 +24,12 @@
},
{
"type": "command",
"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:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && break; sleep 1; done; curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/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; echo '{\"continue\":true,\"suppressOutput\":true}'",
"timeout": 60
},
{
"type": "command",
"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:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && break; sleep 1; done; if curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/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\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
"timeout": 60
}
]
@@ -40,7 +40,7 @@
"hooks": [
{
"type": "command",
"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\"; _HEALTH=0; curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && _HEALTH=1 || for i in 1 2 3 4 5 6 7 8 9 10; do sleep 1; curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && _HEALTH=1 && break; done; [ \"$_HEALTH\" = \"1\" ] && 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
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
import { AdapterRejectedInput, isValidCwd } from './errors.js';
// Maps Claude Code stdin format (session_id, cwd, tool_name, etc.)
// SessionStart hooks receive no stdin, so we must handle undefined input gracefully
@@ -12,9 +13,15 @@ const pickAgentField = (v: unknown): string | undefined =>
export const claudeCodeAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = (raw ?? {}) as any;
// Plan 05 Phase 6 — cwd validation at the adapter boundary (single check,
// not duplicated in handlers). Falls back to process.cwd() when unset.
const cwd = r.cwd ?? process.cwd();
if (!isValidCwd(cwd)) {
throw new AdapterRejectedInput('invalid_cwd');
}
return {
sessionId: r.session_id ?? r.id ?? r.sessionId,
cwd: r.cwd ?? process.cwd(),
cwd,
prompt: r.prompt,
toolName: r.tool_name,
toolInput: r.tool_input,

View File

@@ -1,4 +1,5 @@
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
import { AdapterRejectedInput, isValidCwd } from './errors.js';
// Maps Cursor stdin format - field names differ from Claude Code
// Cursor uses: conversation_id, workspace_roots[], result_json, command/output
@@ -13,9 +14,14 @@ export const cursorAdapter: PlatformAdapter = {
const r = (raw ?? {}) as any;
// Cursor-specific: shell commands come as command/output instead of tool_name/input/response
const isShellCommand = !!r.command && !r.tool_name;
// Plan 05 Phase 6 — cwd validation at the adapter boundary.
const cwd = r.workspace_roots?.[0] ?? r.cwd ?? process.cwd();
if (!isValidCwd(cwd)) {
throw new AdapterRejectedInput('invalid_cwd');
}
return {
sessionId: r.conversation_id || r.generation_id || r.id,
cwd: r.workspace_roots?.[0] ?? r.cwd ?? process.cwd(),
cwd,
prompt: r.prompt ?? r.query ?? r.input ?? r.message,
toolName: isShellCommand ? 'Bash' : r.tool_name,
toolInput: isShellCommand ? { command: r.command } : r.tool_input,

View File

@@ -0,0 +1,24 @@
/**
* Adapter-layer rejection. Plan 05 Phase 6 (PATHFINDER-2026-04-22): cwd
* validation moves from per-handler `if (!cwd) throw …` to the adapter
* boundary. When normalization detects an invalid input, the adapter throws
* `AdapterRejectedInput`; the hook runner translates it into a graceful
* `{ continue: true }` so the user's session is never blocked by a malformed
* hook payload.
*/
export class AdapterRejectedInput extends Error {
constructor(public readonly reason: string) {
super(`adapter rejected input: ${reason}`);
this.name = 'AdapterRejectedInput';
}
}
/**
* A cwd is valid when it is a non-empty string. The adapter normalizers fall
* back to `process.cwd()` when the inbound payload omits cwd, so the only way
* this returns false is when the payload supplies `null`/`''`/non-string.
*/
export function isValidCwd(cwd: unknown): cwd is string {
return typeof cwd === 'string' && cwd.length > 0;
}

View File

@@ -1,4 +1,5 @@
import type { PlatformAdapter } from '../types.js';
import { AdapterRejectedInput, isValidCwd } from './errors.js';
/**
* Gemini CLI Platform Adapter
@@ -39,6 +40,10 @@ export const geminiCliAdapter: PlatformAdapter = {
?? process.env.GEMINI_PROJECT_DIR
?? process.env.CLAUDE_PROJECT_DIR
?? process.cwd();
// Plan 05 Phase 6 — cwd validation at the adapter boundary.
if (!isValidCwd(cwd)) {
throw new AdapterRejectedInput('invalid_cwd');
}
const sessionId = r.session_id
?? process.env.GEMINI_SESSION_ID

View File

@@ -1,12 +1,18 @@
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
import { AdapterRejectedInput, isValidCwd } from './errors.js';
// Raw adapter passes through with minimal transformation - useful for testing
export const rawAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = raw as any;
const r = (raw ?? {}) as any;
// Plan 05 Phase 6 — cwd validation at the adapter boundary.
const cwd = r.cwd ?? process.cwd();
if (!isValidCwd(cwd)) {
throw new AdapterRejectedInput('invalid_cwd');
}
return {
sessionId: r.sessionId ?? r.session_id ?? 'unknown',
cwd: r.cwd ?? process.cwd(),
cwd,
prompt: r.prompt,
toolName: r.toolName ?? r.tool_name,
toolInput: r.toolInput ?? r.tool_input,

View File

@@ -1,4 +1,5 @@
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
import { AdapterRejectedInput, isValidCwd } from './errors.js';
// Maps Windsurf stdin format — JSON envelope with agent_action_name + tool_info payload
//
@@ -17,9 +18,15 @@ export const windsurfAdapter: PlatformAdapter = {
const toolInfo = r.tool_info ?? {};
const actionName: string = r.agent_action_name ?? '';
// Plan 05 Phase 6 — cwd validation at the adapter boundary.
const cwd = toolInfo.cwd ?? process.cwd();
if (!isValidCwd(cwd)) {
throw new AdapterRejectedInput('invalid_cwd');
}
const base: NormalizedHookInput = {
sessionId: r.trajectory_id ?? r.execution_id,
cwd: toolInfo.cwd ?? process.cwd(),
cwd,
platform: 'windsurf',
};

View File

@@ -6,34 +6,24 @@
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, getWorkerPort, workerHttpRequest } from '../../shared/worker-utils.js';
import {
executeWithWorkerFallback,
isWorkerFallback,
getWorkerPort,
} from '../../shared/worker-utils.js';
import { getProjectContext } from '../../utils/project-name.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { loadFromFileOnce } from '../../shared/hook-settings.js';
export const contextHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running before any other logic
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available - return empty context gracefully
return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: ''
},
exitCode: HOOK_EXIT_CODES.SUCCESS
};
}
const cwd = input.cwd ?? process.cwd();
const context = getProjectContext(cwd);
const port = getWorkerPort();
// Check if terminal output should be shown (load settings early)
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
// Plan 05 Phase 4: settings via process-scope cache.
const settings = loadFromFileOnce();
const showTerminalOutput = settings.CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT === 'true';
// Pass all projects (parent + worktree if applicable) for unified timeline
@@ -41,38 +31,36 @@ export const contextHandler: EventHandler = {
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath;
const emptyResult = {
const emptyResult: HookResult = {
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
exitCode: HOOK_EXIT_CODES.SUCCESS
exitCode: HOOK_EXIT_CODES.SUCCESS,
};
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
// Worker service has its own timeouts, so client-side timeout is redundant
let response: Response;
let colorResponse: Response | null;
try {
[response, colorResponse] = await Promise.all([
workerHttpRequest(apiPath),
showTerminalOutput ? workerHttpRequest(colorApiPath).catch(() => null) : Promise.resolve(null)
]);
} catch (error) {
// Worker unreachable — return empty context gracefully
logger.warn('HOOK', 'Context fetch error, returning empty', { error: error instanceof Error ? error.message : String(error) });
// Plan 05 Phase 2: single helper for ensure-worker-alive → request → fallback.
const contextResult = await executeWithWorkerFallback<string>(apiPath, 'GET');
if (isWorkerFallback(contextResult)) {
return emptyResult;
}
if (!response.ok) {
logger.warn('HOOK', 'Context generation failed, returning empty', { status: response.status });
let additionalContext: string;
if (typeof contextResult === 'string') {
additionalContext = contextResult.trim();
} else if (contextResult === undefined) {
additionalContext = '';
} else {
// Unexpected non-string body — log and fall back to empty.
logger.warn('HOOK', 'Context response was not a string', { type: typeof contextResult });
return emptyResult;
}
const [contextResult, colorResult] = await Promise.all([
response.text(),
colorResponse?.ok ? colorResponse.text() : Promise.resolve('')
]);
let coloredTimeline = '';
if (showTerminalOutput) {
const colorResult = await executeWithWorkerFallback<string>(colorApiPath, 'GET');
if (!isWorkerFallback(colorResult) && typeof colorResult === 'string') {
coloredTimeline = colorResult.trim();
}
}
const additionalContext = contextResult.trim();
const coloredTimeline = colorResult.trim();
const platform = input.platform;
// Use colored timeline for display if available, otherwise fall back to

View File

@@ -6,14 +6,12 @@
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { parseJsonArray } from '../../shared/timeline-formatting.js';
import { statSync } from 'fs';
import path from 'path';
import { isProjectExcluded } from '../../utils/project-filter.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { shouldTrackProject } from '../../shared/should-track-project.js';
import { getProjectContext } from '../../utils/project-name.js';
/** Skip the gate for files smaller than this — timeline overhead exceeds file read cost. */
@@ -207,19 +205,12 @@ export const fileContextHandler: EventHandler = {
logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) });
}
// Check if project is excluded from tracking
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (input.cwd && isProjectExcluded(input.cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
// Plan 05 Phase 5: project exclusion via single helper.
if (input.cwd && !shouldTrackProject(input.cwd)) {
logger.debug('HOOK', 'Project excluded from tracking, skipping file context', { cwd: input.cwd });
return { continue: true, suppressOutput: true };
}
// Ensure worker is running
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
return { continue: true, suppressOutput: true };
}
// Query worker for observations related to this file
const context = getProjectContext(input.cwd);
const cwd = input.cwd || process.cwd();
@@ -232,22 +223,19 @@ export const fileContextHandler: EventHandler = {
}
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
let data: { observations: ObservationRow[]; count: number };
try {
const response = await workerHttpRequest(`/api/observations/by-file?${queryParams.toString()}`, { method: 'GET' });
if (!response.ok) {
logger.warn('HOOK', 'File context query failed, skipping', { status: response.status, filePath });
return { continue: true, suppressOutput: true };
}
data = await response.json() as { observations: ObservationRow[]; count: number };
} catch (error) {
logger.warn('HOOK', 'File context fetch error, skipping', {
error: error instanceof Error ? error.message : String(error),
});
// Plan 05 Phase 2: single helper for ensure-worker-alive → request → fallback.
const result = await executeWithWorkerFallback<{ observations: ObservationRow[]; count: number }>(
`/api/observations/by-file?${queryParams.toString()}`,
'GET',
);
if (isWorkerFallback(result)) {
return { continue: true, suppressOutput: true };
}
if (!result || !Array.isArray((result as any).observations)) {
logger.warn('HOOK', 'File context query returned malformed body, skipping', { filePath });
return { continue: true, suppressOutput: true };
}
const data = result;
if (!data.observations || data.observations.length === 0) {
return { continue: true, suppressOutput: true };

View File

@@ -6,35 +6,13 @@
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
async function sendFileEditObservation(requestBody: string, filePath: string): Promise<void> {
const response = await workerHttpRequest('/api/sessions/observations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: requestBody
});
if (!response.ok) {
logger.warn('HOOK', 'File edit observation storage failed, skipping', { status: response.status, filePath });
return;
}
logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
}
export const fileEditHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running before any other logic
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available - skip file edit observation gracefully
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const { sessionId, cwd, filePath, edits } = input;
const platformSource = normalizePlatformSource(input.platform);
@@ -46,30 +24,31 @@ export const fileEditHandler: EventHandler = {
editCount: edits?.length ?? 0
});
// Validate required fields before sending to worker
// Plan 05 Phase 6: cwd is validated at the adapter boundary; this is a
// belt-and-suspenders type guard so TypeScript narrows.
if (!cwd) {
throw new Error(`Missing cwd in FileEdit hook input for session ${sessionId}, file ${filePath}`);
}
// Send to worker as an observation with file edit metadata
// The observation handler on the worker will process this appropriately
const requestBody = JSON.stringify({
contentSessionId: sessionId,
platformSource,
tool_name: 'write_file',
tool_input: { filePath, edits },
tool_response: { success: true },
cwd
});
// Plan 05 Phase 2: single helper for ensure-worker-alive → request → fallback.
const result = await executeWithWorkerFallback<{ status?: string }>(
'/api/sessions/observations',
'POST',
{
contentSessionId: sessionId,
platformSource,
tool_name: 'write_file',
tool_input: { filePath, edits },
tool_response: { success: true },
cwd,
},
);
try {
await sendFileEditObservation(requestBody, filePath);
} catch (error) {
// Worker unreachable — skip file edit observation gracefully
logger.warn('HOOK', 'File edit observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
if (isWorkerFallback(result)) {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
return { continue: true, suppressOutput: true };
}
},
};

View File

@@ -5,38 +5,14 @@
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { isProjectExcluded } from '../../utils/project-filter.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { shouldTrackProject } from '../../shared/should-track-project.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
async function sendObservationToWorker(requestBody: string, toolName: string): Promise<void> {
const response = await workerHttpRequest('/api/sessions/observations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: requestBody
});
if (!response.ok) {
logger.warn('HOOK', 'Observation storage failed, skipping', { status: response.status, toolName });
return;
}
logger.debug('HOOK', 'Observation sent successfully', { toolName });
}
export const observationHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running before any other logic
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available - skip observation gracefully
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const { sessionId, cwd, toolName, toolInput, toolResponse } = input;
const platformSource = normalizePlatformSource(input.platform);
@@ -49,38 +25,43 @@ export const observationHandler: EventHandler = {
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {});
// Validate required fields before sending to worker
// Plan 05 Phase 6: cwd is validated at the adapter boundary; the adapter
// rejects empty cwd before reaching the handler. We still type-narrow for
// TypeScript and as a belt-and-suspenders guard.
if (!cwd) {
throw new Error(`Missing cwd in PostToolUse hook input for session ${sessionId}, tool ${toolName}`);
}
// Check if project is excluded from tracking
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
// Plan 05 Phase 5: project exclusion via single helper.
if (!shouldTrackProject(cwd)) {
logger.debug('HOOK', 'Project excluded from tracking, skipping observation', { cwd, toolName });
return { continue: true, suppressOutput: true };
}
// Send to worker - worker handles privacy check and database operations
const requestBody = JSON.stringify({
contentSessionId: sessionId,
platformSource,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
cwd,
agentId: input.agentId,
agentType: input.agentType
});
// Plan 05 Phase 2: single helper for ensure-worker-alive → request → fallback.
const result = await executeWithWorkerFallback<{ status?: string }>(
'/api/sessions/observations',
'POST',
{
contentSessionId: sessionId,
platformSource,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
cwd,
agentId: input.agentId,
agentType: input.agentType,
},
);
try {
await sendObservationToWorker(requestBody, toolName);
} catch (error) {
// Worker unreachable — skip observation gracefully
logger.warn('HOOK', 'Observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
if (isWorkerFallback(result)) {
// Worker unreachable — fail-loud counter has already been incremented
// and may have escalated to exit 2. If we got here, threshold not yet
// reached, so degrade gracefully.
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
logger.debug('HOOK', 'Observation sent successfully', { toolName });
return { continue: true, suppressOutput: true };
}
},
};

View File

@@ -10,34 +10,12 @@
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
async function sendSessionCompleteRequest(sessionId: string, platformSource: string): Promise<void> {
const response = await workerHttpRequest('/api/sessions/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentSessionId: sessionId, platformSource })
});
if (!response.ok) {
const text = await response.text();
logger.warn('HOOK', 'session-complete: Failed to complete session', { status: response.status, body: text });
} else {
logger.info('HOOK', 'Session completed successfully', { contentSessionId: sessionId });
}
}
export const sessionCompleteHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available — skip session completion gracefully
return { continue: true, suppressOutput: true };
}
const { sessionId } = input;
const platformSource = normalizePlatformSource(input.platform);
@@ -47,19 +25,21 @@ export const sessionCompleteHandler: EventHandler = {
}
logger.info('HOOK', '→ session-complete: Removing session from active map', {
contentSessionId: sessionId
contentSessionId: sessionId,
});
try {
await sendSessionCompleteRequest(sessionId, platformSource);
} catch (error) {
// Log but don't fail - session may already be gone
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn('HOOK', 'session-complete: Error completing session', {
error: errorMessage
});
// Plan 05 Phase 2: single helper for ensure-worker-alive → request → fallback.
const result = await executeWithWorkerFallback<{ status?: string }>(
'/api/sessions/complete',
'POST',
{ contentSessionId: sessionId, platformSource },
);
if (isWorkerFallback(result)) {
return { continue: true, suppressOutput: true };
}
logger.info('HOOK', 'Session completed successfully', { contentSessionId: sessionId });
return { continue: true, suppressOutput: true };
}
},
};

View File

@@ -5,45 +5,29 @@
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.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';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { shouldTrackProject } from '../../shared/should-track-project.js';
import { loadFromFileOnce } from '../../shared/hook-settings.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
async function fetchSemanticContext(
prompt: string,
project: string,
limit: string,
sessionDbId: number
): Promise<string> {
const semanticRes = await workerHttpRequest('/api/context/semantic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: prompt, project, limit })
});
if (semanticRes.ok) {
const data = await semanticRes.json() as { context: string; count: number };
if (data.context) {
logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, { sessionId: sessionDbId, count: data.count });
return data.context;
}
}
return '';
interface SessionInitResponse {
sessionDbId: number;
promptNumber: number;
skipped?: boolean;
reason?: string;
contextInjected?: boolean;
}
interface SemanticContextResponse {
context: string;
count: number;
}
export const sessionInitHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running before any other logic
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available - skip session init gracefully
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const { sessionId, prompt: rawPrompt } = input;
const cwd = input.cwd ?? process.cwd(); // Match context.ts fallback (#1918)
@@ -53,9 +37,8 @@ export const sessionInitHandler: EventHandler = {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
// Check if project is excluded from tracking
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (cwd && isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
// Plan 05 Phase 5: project exclusion via single helper.
if (!shouldTrackProject(cwd)) {
logger.info('HOOK', 'Project excluded from tracking', { cwd });
return { continue: true, suppressOutput: true };
}
@@ -69,38 +52,28 @@ export const sessionInitHandler: EventHandler = {
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
// Initialize session via HTTP - handles DB operations and privacy checks
let initResponse: Response;
try {
initResponse = await workerHttpRequest('/api/sessions/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
project,
prompt,
platformSource
})
});
} catch (err) {
// Worker unreachable — on Linux/WSL, hook may fire before worker is healthy (#1907)
logger.warn('HOOK', `session-init: worker request failed: ${err instanceof Error ? err.message : err}`);
// Plan 05 Phase 2: single helper for ensure-worker-alive → request → fallback.
const initResult = await executeWithWorkerFallback<SessionInitResponse>(
'/api/sessions/init',
'POST',
{
contentSessionId: sessionId,
project,
prompt,
platformSource,
},
);
if (isWorkerFallback(initResult)) {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
if (!initResponse.ok) {
// Log but don't throw - a worker 500 should not block the user's prompt
logger.failure('HOOK', `Session initialization failed: ${initResponse.status}`, { contentSessionId: sessionId, project });
// Worker may have returned a non-2xx body (parsed but missing fields). Fail-soft.
if (typeof initResult?.sessionDbId !== 'number') {
logger.failure('HOOK', 'Session initialization returned malformed response', { contentSessionId: sessionId, project });
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const initResult = await initResponse.json() as {
sessionDbId: number;
promptNumber: number;
skipped?: boolean;
reason?: string;
contextInjected?: boolean;
};
const sessionDbId = initResult.sessionDbId;
const promptNumber = initResult.promptNumber;
@@ -117,57 +90,47 @@ export const sessionInitHandler: EventHandler = {
return { continue: true, suppressOutput: true };
}
// Skip SDK agent re-initialization if context was already injected for this session (#1079)
// The prompt was already saved to the database by /api/sessions/init above —
// no need to re-start the SDK agent on every turn.
// Note: we do NOT return here — semantic injection below must run on every prompt.
const skipAgentInit = Boolean(initResult.contextInjected);
if (skipAgentInit) {
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped_agent_init=true | reason=context_already_injected`, {
sessionId: sessionDbId
});
}
// Only initialize SDK agent for Claude Code (not Cursor)
// Cursor doesn't use the SDK agent - it only needs session/observation storage
if (!skipAgentInit && input.platform !== 'cursor' && sessionDbId) {
// Plan 05 Phase 7: agent init is idempotent — call unconditionally for
// every Claude Code session. Cursor still skipped (no SDK agent).
if (input.platform !== 'cursor' && sessionDbId) {
// Strip leading slash from commands for memory agent
// /review 101 -> review 101 (more semantic for observations)
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
logger.debug('HOOK', 'session-init: Calling /sessions/{sessionDbId}/init', { sessionDbId, promptNumber });
// Initialize SDK agent session via HTTP (starts the agent!)
const response = await workerHttpRequest(`/sessions/${sessionDbId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userPrompt: cleanedPrompt, promptNumber })
});
if (!response.ok) {
// Log but don't throw - SDK agent failure should not block the user's prompt
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
const agentInitResult = await executeWithWorkerFallback<{ status?: string }>(
`/sessions/${sessionDbId}/init`,
'POST',
{ userPrompt: cleanedPrompt, promptNumber },
);
if (isWorkerFallback(agentInitResult)) {
// Worker became unreachable mid-invocation; fail-loud counter handled it.
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
} else if (!skipAgentInit && input.platform === 'cursor') {
} else if (input.platform === 'cursor') {
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
}
// Semantic context injection: query Chroma for relevant past observations
// and inject as additionalContext so Claude receives relevant memory each prompt.
// Controlled by CLAUDE_MEM_SEMANTIC_INJECT setting (default: true).
// Plan 05 Phase 4: settings via process-scope cache.
const settings = loadFromFileOnce();
const semanticInject =
String(settings.CLAUDE_MEM_SEMANTIC_INJECT).toLowerCase() === 'true';
let additionalContext = '';
if (semanticInject && prompt && prompt.length >= 20 && prompt !== '[media prompt]') {
const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5';
try {
additionalContext = await fetchSemanticContext(prompt, project, limit, sessionDbId);
} catch (e) {
// Graceful degradation — semantic injection is optional
logger.debug('HOOK', 'Semantic injection unavailable', {
error: e instanceof Error ? e.message : String(e)
});
const semanticResult = await executeWithWorkerFallback<SemanticContextResponse>(
'/api/context/semantic',
'POST',
{ q: prompt, project, limit },
);
if (!isWorkerFallback(semanticResult) && semanticResult?.context) {
logger.debug('HOOK', `Semantic injection: ${semanticResult.count} observations for prompt`, { sessionId: sessionDbId, count: semanticResult.count });
additionalContext = semanticResult.context;
}
}

View File

@@ -1,35 +1,31 @@
/**
* Summarize Handler - Stop
*
* Runs in the Stop hook (120s timeout, not capped like SessionEnd).
* This is the ONLY place where we can reliably wait for async work.
*
* Flow:
* 1. Queue summarize request to worker
* 2. Poll worker until summary processing completes
* 3. Call /api/sessions/complete to clean up session
*
* SessionEnd (1.5s cap from Claude Code) is just a lightweight fallback —
* all real work must happen here in Stop.
* Plan 05 Phase 3 (PATHFINDER-2026-04-22): the 120-second client-side polling
* loop is replaced by a single POST to `/api/session/end`, which the worker
* holds open until the summary-stored event fires. One request, one response,
* no polling on either side.
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.js';
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 { HOOK_EXIT_CODES } 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;
const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120s timeout
interface SessionEndResponse {
ok: boolean;
messageId?: number;
reason?: string;
}
export const summarizeHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Skip summaries in subagent context — subagents do not own the session summary.
// Gate on agentId only: that field is present exclusively for Task-spawned subagents.
// agentType alone (no agentId) indicates `--agent`-started main sessions, which still
// own their summary. Do this BEFORE ensureWorkerRunning() so a subagent Stop hook
// own their summary. Do this BEFORE the worker call so a subagent Stop hook
// does not bootstrap the worker.
if (input.agentId) {
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
@@ -40,16 +36,13 @@ export const summarizeHandler: EventHandler = {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
// Ensure worker is running before any other logic
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available - skip summary gracefully
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const { sessionId, transcriptPath } = input;
// Validate required fields before processing
if (!sessionId) {
logger.warn('HOOK', 'summarize: No sessionId provided, skipping');
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
if (!transcriptPath) {
// No transcript available - skip summary gracefully (not an error)
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
@@ -84,86 +77,59 @@ export const summarizeHandler: EventHandler = {
const platformSource = normalizePlatformSource(input.platform);
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
let response: Response;
try {
response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
last_assistant_message: lastAssistantMessage,
platformSource
}),
timeoutMs: SUMMARIZE_TIMEOUT_MS
});
} catch (err) {
// Network error, worker crash, or timeout — exit gracefully instead of
// bubbling to hook runner which exits code 2 and blocks session exit (#1901)
logger.warn('HOOK', `Stop hook: summarize request failed: ${err instanceof Error ? err.message : err}`);
const queueResult = await executeWithWorkerFallback<{ status?: string }>(
'/api/sessions/summarize',
'POST',
{
contentSessionId: sessionId,
last_assistant_message: lastAssistantMessage,
platformSource,
},
);
if (isWorkerFallback(queueResult)) {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
if (!response.ok) {
return { continue: true, suppressOutput: true };
logger.debug('HOOK', 'Summary request queued, awaiting blocking session-end response');
// 2. Plan 05 Phase 3 — single blocking POST. Server holds the connection
// open until the summary-stored event fires (Plan 03 Phase 2 emitter)
// or its server-side timeout elapses. No polling on this side.
const endResult = await executeWithWorkerFallback<SessionEndResponse>(
'/api/session/end',
'POST',
{ sessionId },
);
if (isWorkerFallback(endResult)) {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
logger.debug('HOOK', 'Summary request queued, waiting for completion');
// 2. Poll worker until pending work for this session is done.
// This keeps the Stop hook alive (120s timeout) so the SDK agent
// can finish processing the summary before SessionEnd kills the session.
const waitStart = Date.now();
let summaryStored: boolean | null = null;
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
let statusResponse: Response;
let status: { queueLength?: number; summaryStored?: boolean | null };
try {
statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, { timeoutMs: 5000 });
status = await statusResponse.json() as { queueLength?: number; summaryStored?: boolean | null };
} catch (pollError) {
// Worker may be busy — keep polling
logger.debug('HOOK', 'Summary status poll failed, retrying', { error: pollError instanceof Error ? pollError.message : String(pollError) });
continue;
}
const queueLength = status.queueLength ?? 0;
// Only treat an empty queue as completion when the session exists (non-404).
// A 404 means the session was not found — not that processing finished.
if (queueLength === 0 && statusResponse.status !== 404) {
summaryStored = status.summaryStored ?? null;
logger.info('HOOK', 'Summary processing complete', {
waitedMs: Date.now() - waitStart,
summaryStored
});
// Warn when the agent processed a summarize request but produced no storable summary.
// This is the silent-failure path described in #1633: queue empties but no summary record exists.
if (summaryStored === false) {
logger.warn('HOOK', 'Summary was not stored: LLM response likely lacked valid <summary> tags (#1633)', {
sessionId,
waitedMs: Date.now() - waitStart
});
}
break;
}
if (endResult?.ok) {
logger.info('HOOK', 'Summary stored', {
sessionId,
messageId: endResult.messageId,
});
} else {
// 504 from server — agent didn't store a summary inside the server-side
// window. Logged so the silent-failure path (#1633) stays visible.
logger.warn('HOOK', 'Session-end did not observe a stored summary', {
sessionId,
reason: endResult?.reason,
});
}
// 3. Complete the session — clean up active sessions map.
// This runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
// Runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
// so it reliably fires after summary work is done.
try {
await workerHttpRequest('/api/sessions/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentSessionId: sessionId }),
timeoutMs: 10_000
});
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
} catch (err) {
logger.warn('HOOK', `Stop hook: session-complete failed: ${err instanceof Error ? err.message : err}`);
const completeResult = await executeWithWorkerFallback<{ status?: string }>(
'/api/sessions/complete',
'POST',
{ contentSessionId: sessionId },
);
if (isWorkerFallback(completeResult)) {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
return { continue: true, suppressOutput: true };
}
},
};

View File

@@ -7,47 +7,38 @@
import { basename } from 'path';
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, getWorkerPort, workerHttpRequest } from '../../shared/worker-utils.js';
import {
executeWithWorkerFallback,
isWorkerFallback,
getWorkerPort,
} from '../../shared/worker-utils.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
async function fetchAndDisplayContext(project: string, colorsParam: string, port: number): Promise<void> {
const response = await workerHttpRequest(
`/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}`
);
if (!response.ok) {
return;
}
const output = await response.text();
process.stderr.write(
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" +
output +
"\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with <private> ... </private> to prevent storing sensitive information.\n" +
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
);
}
export const userMessageHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available — skip user message gracefully
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const port = getWorkerPort();
const project = basename(input.cwd ?? process.cwd());
const colorsParam = input.platform === 'claude-code' ? '&colors=true' : '';
try {
await fetchAndDisplayContext(project, colorsParam, port);
} catch {
// Worker unreachable — skip user message gracefully
// Plan 05 Phase 2: single helper for ensure-worker-alive → request → fallback.
const result = await executeWithWorkerFallback<string>(
`/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}`,
'GET',
);
if (isWorkerFallback(result)) {
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const output = typeof result === 'string' ? result : '';
process.stderr.write(
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" +
output +
"\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with <private> ... </private> to prevent storing sensitive information.\n" +
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
);
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
}
},
};

View File

@@ -1,5 +1,6 @@
import { readJsonFromStdin } from './stdin-reader.js';
import { getPlatformAdapter } from './adapters/index.js';
import { AdapterRejectedInput } from './adapters/errors.js';
import { getEventHandler } from './handlers/index.js';
import { HOOK_EXIT_CODES } from '../shared/hook-constants.js';
import { logger } from '../utils/logger.js';
@@ -98,6 +99,18 @@ export async function hookCommand(platform: string, event: string, options: Hook
try {
return await executeHookPipeline(adapter, handler, platform, options);
} catch (error) {
// Plan 05 Phase 6 — adapter rejected the input (invalid cwd or other
// boundary-detected payload defect). Treat as graceful: emit a continue
// envelope and exit 0 so the user's session is not blocked by a malformed
// hook payload from the platform.
if (error instanceof AdapterRejectedInput) {
logger.warn('HOOK', `Adapter rejected input (${error.reason}), skipping hook`);
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
if (!options.skipExit) {
process.exit(HOOK_EXIT_CODES.SUCCESS);
}
return HOOK_EXIT_CODES.SUCCESS;
}
if (isWorkerUnavailableError(error)) {
// Worker unavailable — degrade gracefully, don't block the user
// Log to file instead of stderr (#1181)

View File

@@ -0,0 +1,39 @@
/**
* Zod body-validation middleware (minimal — Plan 06 will expand).
*
* Plan 05 Phase 3 (PATHFINDER-2026-04-22) adds the blocking
* `/api/session/end` endpoint and needs body validation now. Plan 06 Phase 2
* defines this middleware in full (with error-shape conventions, type
* inference for downstream handlers, etc.); we ship the minimum surface here
* so Plan 05 doesn't hand-roll its own validation.
*
* Contract (this stub): given a Zod schema, parse `req.body`. On parse
* failure, respond `400` with `{ error: 'invalid_body', issues }` and stop;
* on success, replace `req.body` with the parsed (typed/coerced) value and
* call `next()`.
*
* Plan 06 will expand this to:
* - typed `req.body` via `Request<…, …, z.infer<typeof S>>` mapping
* - per-route schema registry for OpenAPI/discovery
* - shared error envelope conventions (`{ code, message, details }`)
*
* Until Plan 06 lands, only Plan 05's `/api/session/end` consumes this.
*/
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import type { ZodTypeAny } from 'zod';
export function validateBody(schema: ZodTypeAny): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: 'invalid_body',
issues: result.error.issues,
});
return;
}
req.body = result.data;
next();
};
}

View File

@@ -6,6 +6,9 @@
*/
import express, { Request, Response } from 'express';
import { z } from 'zod';
import { ingestEventBus, type SummaryStoredEvent } from '../shared.js';
import { validateBody } from '../middleware/validateBody.js';
import { getWorkerPort } from '../../../../shared/worker-utils.js';
import { logger } from '../../../../utils/logger.js';
import { stripMemoryTagsFromJson, stripMemoryTagsFromPrompt } from '../../../../utils/tag-stripping.js';
@@ -388,8 +391,76 @@ export class SessionRoutes extends BaseRouteHandler {
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
app.get('/api/sessions/status', this.handleStatusByClaudeId.bind(this));
// Plan 05 Phase 3 — blocking session-end endpoint. Replaces the
// 120-second client-side polling loop in the Stop hook with a single
// POST that the server holds open until the summary lands (event-bus
// signal from Plan 03 Phase 2).
app.post(
'/api/session/end',
validateBody(SessionRoutes.sessionEndSchema),
this.handleSessionEnd,
);
}
// Plan 05 Phase 3 — Zod schema for the blocking session-end endpoint.
// Plan 06 will move schemas into a per-route registry; for now they live
// inline at the top of the route file that consumes them.
private static readonly sessionEndSchema = z.object({
sessionId: z.string().min(1),
});
// Plan 05 Phase 3 — server-side timeout for the blocking summary wait.
// 30s is a starting point per `05-hook-surface.md` Phase 3 — revisit once
// we have measured summary-latency distribution post-Plan 03.
private static readonly SERVER_SIDE_SUMMARY_TIMEOUT_MS = 30_000;
/**
* Plan 05 Phase 3 — POST /api/session/end
*
* One request, one response. Holds the connection open until the
* `summaryStoredEvent` for `sessionId` fires (Plan 03 Phase 2 emitter), or
* until SERVER_SIDE_SUMMARY_TIMEOUT_MS elapses, or until the client aborts.
*
* No polling on either side. No retry inside this handler. Listener +
* timer + abort handler all share one cleanup function so the listener can
* never leak past the response.
*/
private handleSessionEnd = (req: Request, res: Response): void => {
const { sessionId } = req.body as { sessionId: string };
let settled = false;
const onStored = (evt: SummaryStoredEvent): void => {
if (evt.sessionId !== sessionId) return;
if (settled) return;
settled = true;
cleanup();
res.status(200).json({ ok: true, messageId: evt.messageId });
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
cleanup();
res.status(504).json({ ok: false, reason: 'summary_not_stored_in_time' });
}, SessionRoutes.SERVER_SIDE_SUMMARY_TIMEOUT_MS);
const cleanup = (): void => {
clearTimeout(timer);
ingestEventBus.off('summaryStoredEvent', onStored);
};
ingestEventBus.on('summaryStoredEvent', onStored);
// Client aborted (hook process died) — drop the listener immediately so
// it doesn't accumulate. No response is sent for an aborted client.
req.on('close', () => {
if (settled) return;
settled = true;
cleanup();
});
};
/**
* Initialize a new session
*/

View File

@@ -56,6 +56,7 @@ export interface SettingsDefaults {
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string; // Path to transcript watcher config JSON
// Process Management
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; // Max concurrent Claude SDK agent subprocesses (default: 2)
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: string; // Plan 05 Phase 8 — consecutive hook→worker unreachable failures before exit code 2 (default: 3)
// Exclusion Settings
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
@@ -127,6 +128,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'),
// Process Management
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: '3', // Plan 05 Phase 8 — escalate to exit code 2 after N consecutive worker-unreachable hook invocations
// Exclusion Settings
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation

View File

@@ -0,0 +1,35 @@
/**
* Per-process settings cache for hook handlers.
*
* Plan 05 Phase 4 (PATHFINDER-2026-04-22): each hook process is short-lived,
* but multiple handlers within a single hook invocation independently call
* `SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)` and re-read the
* settings file from disk. Settings cannot mutate during a single hook
* invocation, so we memoize the first read for the lifetime of the process.
*
* One helper, N callers (Principle 6). Every hook handler that needs settings
* imports `loadFromFileOnce()` from here instead of calling
* `SettingsDefaultsManager.loadFromFile` directly.
*/
import {
SettingsDefaultsManager,
type SettingsDefaults,
} from './SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from './paths.js';
let cachedSettings: SettingsDefaults | null = null;
/**
* Load settings from disk on first call, return the memoized value thereafter.
*
* Cache lifetime is the process — hooks are short-lived (typically <1s), so a
* settings change made by the user is picked up the next time Claude Code
* spawns a hook process. There is no in-process invalidation API because there
* is no in-process mutation path.
*/
export function loadFromFileOnce(): SettingsDefaults {
if (cachedSettings !== null) return cachedSettings;
cachedSettings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
return cachedSettings;
}

View File

@@ -0,0 +1,26 @@
/**
* Single answer to "should this hook run for this cwd?"
*
* Plan 05 Phase 5 (PATHFINDER-2026-04-22): three handlers (observation,
* session-init, file-context) each duplicated the
* `loadFromFileOnce() → isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)`
* pair. This module is the only entry point for that question; handlers call
* `shouldTrackProject(cwd)` and route through here.
*
* One helper, N callers (Principle 6). After this module lands, no handler
* references `isProjectExcluded` directly — the import lives only here.
*/
import { isProjectExcluded } from '../utils/project-filter.js';
import { loadFromFileOnce } from './hook-settings.js';
/**
* @returns true when the project at `cwd` is NOT excluded from claude-mem
* tracking, i.e., the hook should proceed; false when the project
* matches one of the exclusion globs.
*/
export function shouldTrackProject(cwd: string): boolean {
if (!cwd) return true;
const settings = loadFromFileOnce();
return !isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS);
}

View File

@@ -1,10 +1,11 @@
import path from "path";
import { readFileSync, existsSync } from "fs";
import { readFileSync, existsSync, writeFileSync, renameSync, mkdirSync } from "fs";
import { spawn, execSync } from "child_process";
import { logger } from "../utils/logger.js";
import { HOOK_TIMEOUTS, getTimeout } from "./hook-constants.js";
import { HOOK_TIMEOUTS, HOOK_EXIT_CODES, getTimeout } from "./hook-constants.js";
import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js";
import { MARKETPLACE_ROOT } from "./paths.js";
import { MARKETPLACE_ROOT, DATA_DIR } from "./paths.js";
import { loadFromFileOnce } from "./hook-settings.js";
// `validateWorkerPidFile` consults `captureProcessStartToken` at
// `src/supervisor/process-registry.ts` for PID-reuse detection (commit
// 99060bac). The lazy-spawn fast path below uses it to confirm a live port
@@ -386,3 +387,210 @@ export async function ensureWorkerRunning(): Promise<boolean> {
}
return true;
}
// ============================================================================
// Plan 05 Phase 9 — single per-process alive cache.
//
// One hook invocation may issue multiple worker requests (session-init issues
// several). The alive-state cannot change mid-invocation without the hook
// process exiting, so memoize the first result. By Principle 6 (one helper,
// N callers), this is the ONLY alive-state cache; all hook→worker call sites
// route through `executeWithWorkerFallback` (Phase 2) which calls this.
// ============================================================================
let aliveCache: boolean | null = null;
export async function ensureWorkerAliveOnce(): Promise<boolean> {
if (aliveCache !== null) return aliveCache;
aliveCache = await ensureWorkerRunning();
return aliveCache;
}
// ============================================================================
// Plan 05 Phase 8 — fail-loud counter.
//
// The counter records how many consecutive hook invocations have seen the
// worker unreachable. After N (default 3) consecutive failures, the next
// hook exits code 2 so Claude Code's hook contract surfaces the outage to
// Claude. Below N, hooks exit 0 to avoid breaking the user's session.
//
// This is NOT a retry. We do not reinvoke `ensureWorkerAliveOnce` or
// reattempt the HTTP request. We record the result of the one primary-path
// attempt and either return (graceful) or escalate (fail-loud).
//
// File: ~/.claude-mem/state/hook-failures.json
// Atomic write: tmp + rename (POSIX atomic within a filesystem).
// ============================================================================
interface HookFailureState {
consecutiveFailures: number;
lastFailureAt: number;
}
const FAIL_LOUD_DEFAULT_THRESHOLD = 3;
function getStateDir(): string {
return path.join(DATA_DIR, 'state');
}
function getHookFailuresPath(): string {
return path.join(getStateDir(), 'hook-failures.json');
}
function readHookFailureState(): HookFailureState {
try {
const raw = readFileSync(getHookFailuresPath(), 'utf-8');
const parsed = JSON.parse(raw) as Partial<HookFailureState>;
return {
consecutiveFailures: typeof parsed.consecutiveFailures === 'number' && Number.isFinite(parsed.consecutiveFailures)
? Math.max(0, Math.floor(parsed.consecutiveFailures))
: 0,
lastFailureAt: typeof parsed.lastFailureAt === 'number' && Number.isFinite(parsed.lastFailureAt)
? parsed.lastFailureAt
: 0,
};
} catch {
// Missing file or corrupt JSON → fresh state.
return { consecutiveFailures: 0, lastFailureAt: 0 };
}
}
function writeHookFailureStateAtomic(state: HookFailureState): void {
const stateDir = getStateDir();
const dest = getHookFailuresPath();
const tmp = `${dest}.tmp`;
try {
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
writeFileSync(tmp, JSON.stringify(state), 'utf-8');
renameSync(tmp, dest);
} catch (error: unknown) {
logger.debug('SYSTEM', 'Failed to persist hook-failure counter', {
error: error instanceof Error ? error.message : String(error),
});
}
}
function getFailLoudThreshold(): number {
try {
const settings = loadFromFileOnce();
const raw = settings.CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD;
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 1) return parsed;
} catch {
// settings unreadable — fall through to default
}
return FAIL_LOUD_DEFAULT_THRESHOLD;
}
/**
* Record a worker-unreachable hook invocation. Returns the new counter value.
* If the counter reaches the threshold, this function writes to stderr and
* exits the process with code 2 (blocking error per Claude Code hook contract).
*
* Not a retry — does not reattempt the operation. The caller already ran the
* single primary-path attempt and got `false` from `ensureWorkerAliveOnce`.
*/
function recordWorkerUnreachable(): number {
const state = readHookFailureState();
const next: HookFailureState = {
consecutiveFailures: state.consecutiveFailures + 1,
lastFailureAt: Date.now(),
};
writeHookFailureStateAtomic(next);
const threshold = getFailLoudThreshold();
if (next.consecutiveFailures >= threshold) {
process.stderr.write(
`claude-mem worker unreachable for ${next.consecutiveFailures} consecutive hooks.\n`
);
process.exit(HOOK_EXIT_CODES.BLOCKING_ERROR);
}
return next.consecutiveFailures;
}
/**
* Reset the consecutive-failure counter. Called when the worker is alive,
* acknowledging that any prior outage has ended. Not a retry — it is a
* success-path acknowledgement.
*/
function resetWorkerFailureCounter(): void {
const state = readHookFailureState();
if (state.consecutiveFailures === 0) return; // skip a no-op write
writeHookFailureStateAtomic({ consecutiveFailures: 0, lastFailureAt: 0 });
}
// ============================================================================
// Plan 05 Phase 2 — `executeWithWorkerFallback(url, method, body)`.
//
// Eight handlers used to duplicate the
// `ensureWorkerRunning() → workerHttpRequest() → if (!ok) return { continue: true }`
// sequence. This helper is the ONE implementation; eight handlers import it.
//
// Behavior:
// 1. ensureWorkerAliveOnce() (Phase 9). If false → fail-loud counter
// (Phase 8). May process.exit(2). Otherwise return graceful fallback.
// 2. workerHttpRequest(url, method, body). Parse JSON.
// 3. On success, reset the fail-loud counter.
//
// No retry inside this helper. No timeout-and-exit-0 swallow. The fail-loud
// counter records consecutive invocation outcomes; it does not reinvoke work.
// ============================================================================
export type WorkerFallback =
| { continue: true }
| { continue: true; reason: string };
export type WorkerCallResult<T> = T | WorkerFallback;
export function isWorkerFallback<T>(result: WorkerCallResult<T>): result is WorkerFallback {
return typeof result === 'object'
&& result !== null
&& (result as WorkerFallback).continue === true
&& Object.prototype.hasOwnProperty.call(result, 'continue');
}
export async function executeWithWorkerFallback<T = unknown>(
url: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
body?: unknown,
): Promise<WorkerCallResult<T>> {
const alive = await ensureWorkerAliveOnce();
if (!alive) {
// Records and possibly process.exit(2). If we return below, the counter
// is below threshold, the user's session continues uninterrupted.
recordWorkerUnreachable();
return { continue: true, reason: 'worker_unreachable' };
}
const init: { method: string; headers?: Record<string, string>; body?: string } = { method };
if (body !== undefined) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
const response = await workerHttpRequest(url, init);
if (!response.ok) {
// Non-2xx is a real worker response (so the worker IS reachable). Reset
// the consecutive-failures counter; surface the response body to the
// caller as a typed value via T's caller-controlled shape. Callers that
// care about non-2xx must inspect the value (or wrap with their own
// status check); the helper does not silently coerce non-2xx into a
// graceful fallback.
resetWorkerFailureCounter();
const text = await response.text().catch(() => '');
let parsed: unknown = text;
try { parsed = JSON.parse(text); } catch { /* keep raw text */ }
return parsed as T;
}
resetWorkerFailureCounter();
const text = await response.text();
if (text.length === 0) return undefined as unknown as T;
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
}

View File

@@ -1,315 +0,0 @@
/**
* Tests for Context Re-Injection Guard (#1079)
*
* Validates:
* - session-init handler skips SDK agent init when contextInjected=true
* - session-init handler proceeds with SDK agent init when contextInjected=false
* - SessionManager.getSession returns undefined for uninitialized sessions
* - SessionManager.getSession returns session after initialization
*/
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
import { homedir } from 'os';
import { join } from 'path';
// Mock modules that cause import chain issues - MUST be before handler imports
// paths.ts calls SettingsDefaultsManager.get() at module load time
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) => {
// Delegate to global fetch so tests can mock fetch behavior
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-filter.js', () => ({
isProjectExcluded: () => false,
}));
// Now import after mocks
import { logger } from '../../src/utils/logger.js';
// Suppress logger output during tests
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(() => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'failure').mockImplementation(() => {}),
];
});
afterEach(() => {
loggerSpies.forEach(spy => spy.mockRestore());
});
describe('Context Re-Injection Guard (#1079)', () => {
describe('session-init handler - contextInjected flag behavior', () => {
it('should skip SDK agent init when contextInjected is true', async () => {
const fetchedUrls: string[] = [];
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = typeof url === 'string' ? url : url.toString();
fetchedUrls.push(urlStr);
if (urlStr.includes('/api/sessions/init')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
sessionDbId: 42,
promptNumber: 2,
skipped: false,
contextInjected: true // SDK agent already running
})
});
}
// The /sessions/42/init call — should NOT be reached
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'initialized' })
});
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch as any;
try {
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
const result = await sessionInitHandler.execute({
sessionId: 'test-session-123',
cwd: '/test/project',
prompt: 'second prompt in this session',
platform: 'claude-code',
});
// Should return success without making the second /sessions/42/init call
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
// Only the /api/sessions/init call should have been made
const apiInitCalls = fetchedUrls.filter(u => u.includes('/api/sessions/init'));
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
expect(apiInitCalls.length).toBe(1);
expect(sdkInitCalls.length).toBe(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should proceed with SDK agent init when contextInjected is false', async () => {
const fetchedUrls: string[] = [];
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = typeof url === 'string' ? url : url.toString();
fetchedUrls.push(urlStr);
if (urlStr.includes('/api/sessions/init')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
sessionDbId: 42,
promptNumber: 1,
skipped: false,
contextInjected: false // First prompt — SDK agent not yet started
})
});
}
// The /sessions/42/init call — SHOULD be reached
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'initialized' })
});
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch as any;
try {
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
const result = await sessionInitHandler.execute({
sessionId: 'test-session-456',
cwd: '/test/project',
prompt: 'first prompt in session',
platform: 'claude-code',
});
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
// Both calls should have been made
const apiInitCalls = fetchedUrls.filter(u => u.includes('/api/sessions/init'));
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
expect(apiInitCalls.length).toBe(1);
expect(sdkInitCalls.length).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should proceed with SDK agent init when contextInjected is undefined (backward compat)', async () => {
const fetchedUrls: string[] = [];
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = typeof url === 'string' ? url : url.toString();
fetchedUrls.push(urlStr);
if (urlStr.includes('/api/sessions/init')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
sessionDbId: 42,
promptNumber: 1,
skipped: false
// contextInjected not present (older worker version)
})
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'initialized' })
});
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch as any;
try {
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
const result = await sessionInitHandler.execute({
sessionId: 'test-session-789',
cwd: '/test/project',
prompt: 'test prompt',
platform: 'claude-code',
});
expect(result.continue).toBe(true);
// When contextInjected is undefined/missing, should still make the SDK init call
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
expect(sdkInitCalls.length).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('SessionManager contextInjected logic', () => {
it('should return undefined for getSession when no active session exists', async () => {
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
const mockDbManager = {
getSessionById: () => ({
id: 1,
content_session_id: 'test-session',
project: 'test',
user_prompt: 'test prompt',
memory_session_id: null,
status: 'active',
started_at: new Date().toISOString(),
completed_at: null,
}),
getSessionStore: () => ({ db: {} }),
} as any;
const sessionManager = new SessionManager(mockDbManager);
// Session 42 has not been initialized in memory
const session = sessionManager.getSession(42);
expect(session).toBeUndefined();
});
it('should return active session after initializeSession is called', async () => {
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
const mockDbManager = {
getSessionById: () => ({
id: 42,
content_session_id: 'test-session',
project: 'test',
user_prompt: 'test prompt',
memory_session_id: null,
status: 'active',
started_at: new Date().toISOString(),
completed_at: null,
}),
getSessionStore: () => ({
db: {},
clearMemorySessionId: () => {},
}),
} as any;
const sessionManager = new SessionManager(mockDbManager);
// Initialize session (simulates first SDK agent init)
sessionManager.initializeSession(42, 'first prompt', 1);
// Now getSession should return the active session
const session = sessionManager.getSession(42);
expect(session).toBeDefined();
expect(session!.contentSessionId).toBe('test-session');
});
it('should return contextInjected=true pattern for subsequent prompts', async () => {
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
const mockDbManager = {
getSessionById: () => ({
id: 42,
content_session_id: 'test-session',
project: 'test',
user_prompt: 'test prompt',
memory_session_id: 'sdk-session-abc',
status: 'active',
started_at: new Date().toISOString(),
completed_at: null,
}),
getSessionStore: () => ({
db: {},
clearMemorySessionId: () => {},
}),
} as any;
const sessionManager = new SessionManager(mockDbManager);
// Before initialization: contextInjected would be false
expect(sessionManager.getSession(42)).toBeUndefined();
// After initialization: contextInjected would be true
sessionManager.initializeSession(42, 'first prompt', 1);
expect(sessionManager.getSession(42)).toBeDefined();
// Second call to initializeSession returns existing session (idempotent)
const session2 = sessionManager.initializeSession(42, 'second prompt', 2);
expect(session2.contentSessionId).toBe('test-session');
expect(session2.userPrompt).toBe('second prompt');
expect(session2.lastPromptNumber).toBe(2);
});
});
});