fix: remove per-session gate, use permissionDecision deny for every read

The per-session FileReadGate was never requested and broke the cost
savings loop — subsequent reads in the same session silently bypassed
the timeline, hiding newly created observations.

Now the timeline fires on every read that has observations, using the
hook contract's permissionDecision: "deny" with the timeline as the
reason (exit 0 + JSON) instead of exit code 2 + stderr.

- Delete FileReadGate.ts entirely
- Remove /api/file-context/gate endpoint from DataRoutes
- Switch handler from exit code 2 to permissionDecision: "deny"
- Restore permissionDecision fields to HookResult
- Eliminate one HTTP round-trip per read (no gate check needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-06 22:05:40 -07:00
parent 31910fb265
commit 455aeaf654
5 changed files with 116 additions and 228 deletions
-83
View File
@@ -1,83 +0,0 @@
/**
* File Read Gate
*
* In-memory session-scoped gate tracking which files have had their timeline
* injected. Prevents duplicate timeline injections within the same session.
* Keys include cwd to prevent worktree collisions.
*/
interface SessionEntry {
files: Set<string>;
lastAccess: number;
}
const TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
const sessionGates = new Map<string, SessionEntry>();
let pruneCallCount = 0;
const PRUNE_EVERY_N_CALLS = 50;
/**
* Lazily prune session entries older than the TTL.
* Throttled to run every N calls to avoid iterating all sessions on every check.
*/
function pruneExpiredSessions(): void {
pruneCallCount++;
if (pruneCallCount < PRUNE_EVERY_N_CALLS) return;
pruneCallCount = 0;
const now = Date.now();
for (const [key, entry] of sessionGates) {
if (now - entry.lastAccess > TTL_MS) {
sessionGates.delete(key);
}
}
}
/**
* Build a composite key scoped to session + cwd to prevent worktree collisions.
*/
function makeKey(sessionId: string, cwd?: string): string {
return cwd ? `${sessionId}::${cwd}` : sessionId;
}
/**
* Check if this is the first read of a file in a session+cwd scope, and mark it if so.
* Returns true if this is the first attempt (file was not previously seen).
* Returns false if the file was already seen in this session.
*/
export function checkAndMark(sessionId: string, filePath: string, cwd?: string): boolean {
pruneExpiredSessions();
const key = makeKey(sessionId, cwd);
const normalizedPath = filePath.replace(/\\/g, '/');
let entry = sessionGates.get(key);
if (!entry) {
entry = { files: new Set(), lastAccess: Date.now() };
sessionGates.set(key, entry);
}
// Refresh TTL on every access so active sessions don't get re-blocked
entry.lastAccess = Date.now();
if (entry.files.has(normalizedPath)) {
return false;
}
entry.files.add(normalizedPath);
return true;
}
/**
* Clear all tracked files for a session (e.g., on session end).
* Clears all cwd scopes for the given session.
*/
export function clearSession(sessionId: string): void {
for (const key of sessionGates.keys()) {
if (key === sessionId || key.startsWith(`${sessionId}::`)) {
sessionGates.delete(key);
}
}
}
@@ -19,7 +19,6 @@ import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import type { WorkerService } from '../../../worker-service.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
import { checkAndMark } from '../../FileReadGate.js';
export class DataRoutes extends BaseRouteHandler {
constructor(
@@ -63,9 +62,6 @@ export class DataRoutes extends BaseRouteHandler {
// Import endpoint
app.post('/api/import', this.handleImport.bind(this));
// File-context gate
app.post('/api/file-context/gate', this.handleFileContextGate.bind(this));
}
/**
@@ -502,19 +498,4 @@ export class DataRoutes extends BaseRouteHandler {
});
});
/**
* Check if a file has already had its timeline injected in this session
* POST /api/file-context/gate
* Body: { sessionId: string, filePath: string }
* Returns: { firstAttempt: boolean }
*/
private handleFileContextGate = this.wrapHandler((req: Request, res: Response): void => {
const { sessionId, filePath, cwd } = req.body;
if (!sessionId || !filePath) {
this.badRequest(res, 'sessionId and filePath are required');
return;
}
const firstAttempt = checkAndMark(sessionId, filePath, cwd);
res.json({ firstAttempt });
});
}