agy-context-saver 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,7 @@ import os from "node:os";
32
32
  import path from "node:path";
33
33
  import process from "node:process";
34
34
  import { spawnSync } from "node:child_process";
35
+ import { fileURLToPath } from "node:url";
35
36
 
36
37
  function respond(obj) {
37
38
  process.stdout.write(JSON.stringify(obj) + "\n");
@@ -66,13 +67,11 @@ function getStateFilePath(convId = "global") {
66
67
  function getSessionState(convId = "global") {
67
68
  const file = getStateFilePath(convId);
68
69
  try {
69
- if (fs.existsSync(file)) {
70
- const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
71
- const entry = (parsed && typeof parsed.pollCount === "number") ? parsed : (parsed && parsed[convId]);
72
- const now = Date.now();
73
- if (entry && typeof entry.lastPollTime === "number" && (now - entry.lastPollTime) < STATE_TTL_MS) {
74
- return entry;
75
- }
70
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
71
+ const entry = (parsed && typeof parsed.pollCount === "number") ? parsed : (parsed && parsed[convId]);
72
+ const now = Date.now();
73
+ if (entry && typeof entry.lastPollTime === "number" && (now - entry.lastPollTime) < STATE_TTL_MS) {
74
+ return entry;
76
75
  }
77
76
  } catch {}
78
77
  return { pollCount: 0, denials: 0, taskIds: [], lastPollTime: 0 };
@@ -86,14 +85,12 @@ function saveSessionState(convId = "global", entry) {
86
85
  let toWrite;
87
86
  if (!convId || convId === "global") {
88
87
  let state = {};
89
- if (fs.existsSync(file)) {
90
- try {
91
- const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
92
- if (parsed && typeof parsed === "object" && typeof parsed.pollCount !== "number") {
93
- state = parsed;
94
- }
95
- } catch {}
96
- }
88
+ try {
89
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
90
+ if (parsed && typeof parsed === "object" && typeof parsed.pollCount !== "number") {
91
+ state = parsed;
92
+ }
93
+ } catch {}
97
94
  state[convId] = entry;
98
95
 
99
96
  // Prune stale entries
@@ -123,12 +120,7 @@ function saveSessionState(convId = "global", entry) {
123
120
  */
124
121
  function isProtectedInternalState(targetPath) {
125
122
  if (!targetPath || typeof targetPath !== "string") return false;
126
- const normalized = targetPath.replace(/\\/g, "/");
127
- return (
128
- normalized.includes("/.system_generated/") ||
129
- normalized.endsWith("/.system_generated") ||
130
- /\.system_generated(?:$|[\/\\])/i.test(normalized)
131
- );
123
+ return /\.system_generated(?:$|[\/\\])/i.test(targetPath);
132
124
  }
133
125
 
134
126
  /**
@@ -189,6 +181,7 @@ function isProtectedBenchmarkPath(targetPath) {
189
181
  */
190
182
  function isStepSpilloverPath(targetPath) {
191
183
  if (!targetPath || typeof targetPath !== "string") return false;
184
+ if (!isProtectedInternalState(targetPath)) return false; // cheap precheck
192
185
  const normalized = targetPath.replace(/\\/g, "/");
193
186
  return (
194
187
  /\.system_generated[/\\]steps[/\\]\d+[/\\]output\.txt/i.test(normalized) ||
@@ -197,180 +190,149 @@ function isStepSpilloverPath(targetPath) {
197
190
  }
198
191
 
199
192
  /**
200
- * Extracts task ID from target path or command if targeting a task output log.
193
+ * Extracts the task-log reference from a target path or command, or null.
194
+ * Returns { id, token }: `id` is the bare task id; `token` is the matched path text
195
+ * (relative "tasks/task-1.log" or absolute ".../.system_generated/tasks/task-1.log").
201
196
  */
202
- function getTaskLogMatch(targetPath) {
203
- if (!targetPath || typeof targetPath !== "string") return null;
204
- const normalized = targetPath.replace(/\\/g, "/");
205
- const match = normalized.match(/(?:^|[\s"'/])(?:\.system_generated\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log/i);
206
- return match ? match[1] : null;
197
+ function getTaskLogMatch(text) {
198
+ if (!text || typeof text !== "string" || !text.includes(".log")) return null;
199
+ const normalized = text.replace(/\\/g, "/");
200
+ if (!normalized.includes("tasks/")) return null;
201
+ const m = normalized.match(/(?:^|[\s"'])((?:[^\s"']*\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log)/i);
202
+ if (!m) return null;
203
+ let token = m[1].replace(/^file:\/\//i, "");
204
+ if (/^\/[A-Za-z]:/.test(token)) token = token.slice(1);
205
+ return { id: m[2], token };
207
206
  }
208
207
 
209
- /**
210
- * Resolves full path to a task log on disk.
211
- */
212
- function resolveTaskLogFile(taskId, convId) {
213
- if (taskId && typeof taskId === "string") {
214
- if (fs.existsSync(taskId)) {
215
- const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
216
- return { logPath: taskId, convId: convId || "global", cleanId };
217
- }
218
- if (taskId.includes("/") || taskId.includes("\\")) {
219
- const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
220
- return { logPath: null, convId: convId || "global", cleanId };
221
- }
222
- }
208
+ const TASK_TAIL_BYTES = 262144; // transcript tail scanned first (256 KB)
209
+ const TASK_QUIET_MS = 300000; // log-mtime fallback: quiet for 5 min => treated as finished
223
210
 
224
- const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
225
- const baseTaskName = cleanId.startsWith("task-") ? cleanId : `task-${cleanId}`;
211
+ function getProfileHomeDir() {
212
+ return process.env.AGY_TEST_PROFILE_DIR || os.homedir();
213
+ }
226
214
 
227
- const home = os.homedir();
228
- const brainDir = path.join(home, ".gemini", "antigravity", "brain");
215
+ function getBrainDir() {
216
+ return process.env.AGY_BRAIN_DIR || path.join(getProfileHomeDir(), ".gemini", "antigravity", "brain");
217
+ }
229
218
 
230
- if (convId && convId !== "global") {
231
- const candidate = path.join(brainDir, convId, ".system_generated", "tasks", `${baseTaskName}.log`);
232
- if (fs.existsSync(candidate)) {
233
- return { logPath: candidate, convId, cleanId: baseTaskName };
234
- }
219
+ /** Reads the last `bytes` of a file. Returns { text, whole } or null. */
220
+ function readTail(file, bytes) {
221
+ let fd;
222
+ try {
223
+ fd = fs.openSync(file, "r");
224
+ const size = fs.fstatSync(fd).size;
225
+ const len = Math.min(size, bytes);
226
+ const buf = Buffer.alloc(len);
227
+ fs.readSync(fd, buf, 0, len, size - len);
228
+ return { text: buf.toString("utf-8"), whole: len === size };
229
+ } catch {
230
+ return null;
231
+ } finally {
232
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
235
233
  }
234
+ }
236
235
 
237
- return { logPath: null, convId: convId || "global", cleanId: baseTaskName };
236
+ /**
237
+ * Looks for the finish / start notices of `taskName` in transcript text using literal needles
238
+ * (JSON-escaped and raw variants). Finish: `<conv>/task-N" finished with result`.
239
+ * Start: `<conv>/task-N` followed by a newline and "Task Description" or "Task logs".
240
+ * Returns "COMPLETED", "RUNNING" or null.
241
+ */
242
+ function scanTaskMarkers(text, taskName) {
243
+ if (text.includes(`/${taskName}\\" finished`) || text.includes(`/${taskName}" finished`)) return "COMPLETED";
244
+ if (text.includes(`/${taskName}\\nTask `) || text.includes(`/${taskName}\nTask `)) return "RUNNING";
245
+ return null;
238
246
  }
239
247
 
240
248
  /**
241
- * Determines whether a background task is verified completed / terminated.
249
+ * Resolves a background task's lifecycle state: "RUNNING", "COMPLETED" or "UNKNOWN".
250
+ * Authority order: transcript notices (tail first, full file only if needed), then log mtime.
242
251
  */
243
- function isTaskCompleted(taskId, convId, rawPath) {
244
- const candidatePath = (rawPath && fs.existsSync(rawPath)) ? rawPath : null;
245
- const resolved = resolveTaskLogFile(candidatePath || taskId, convId);
246
- const logPath = resolved.logPath || candidatePath;
247
- const cleanId = resolved.cleanId;
248
-
249
- // 1. Session state check
250
- const safeId = String(resolved.convId || convId || "global").replace(/[^a-zA-Z0-9_-]/g, "_");
251
- const sessionPath = path.join(os.tmpdir(), `agy-session-${safeId}.json`);
252
- if (fs.existsSync(sessionPath)) {
253
- try {
254
- const sess = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
255
- if (sess && sess.tasks && sess.tasks[cleanId]) {
256
- if (sess.tasks[cleanId].state === "TERMINATED" || sess.tasks[cleanId].state === "COMPLETED") return true;
257
- if (sess.tasks[cleanId].state === "RUNNING") return false;
258
- }
259
- } catch {}
252
+ function getTaskState(match, convId) {
253
+ const taskName = `task-${match.id}`;
254
+ const token = match.token;
255
+ let logPath = null;
256
+ let transcript = null;
257
+
258
+ if (/^(?:[A-Za-z]:)?\//.test(token)) {
259
+ logPath = token;
260
+ const sysDir = path.posix.dirname(path.posix.dirname(token));
261
+ if (path.posix.basename(sysDir) === ".system_generated") transcript = `${sysDir}/logs/transcript.jsonl`;
262
+ }
263
+ if (convId && convId !== "global") {
264
+ const sysDir = path.join(getBrainDir(), convId, ".system_generated");
265
+ logPath = logPath || path.join(sysDir, "tasks", `${taskName}.log`);
266
+ transcript = transcript || path.join(sysDir, "logs", "transcript.jsonl");
260
267
  }
261
268
 
262
- // 2. Transcript check
263
- if (resolved.convId && resolved.convId !== "global") {
264
- const home = os.homedir();
265
- const candidateTrans = path.join(home, ".gemini", "antigravity", "brain", resolved.convId, ".system_generated", "logs", "transcript.jsonl");
266
- if (fs.existsSync(candidateTrans)) {
267
- try {
268
- const content = fs.readFileSync(candidateTrans, "utf-8");
269
- const taskRegex = new RegExp(`Task id [^"]*${cleanId}[^"]* finished`, "i");
270
- if (taskRegex.test(content)) return true;
271
- } catch {}
269
+ if (transcript) {
270
+ const tail = readTail(transcript, TASK_TAIL_BYTES);
271
+ if (tail) {
272
+ let state = scanTaskMarkers(tail.text, taskName);
273
+ if (!state && !tail.whole) {
274
+ try { state = scanTaskMarkers(fs.readFileSync(transcript, "utf-8"), taskName); } catch {}
275
+ }
276
+ if (state) return state;
272
277
  }
273
278
  }
274
279
 
275
- // 3. File exists on disk and age check (>15s and not marked live)
276
- if (logPath && fs.existsSync(logPath)) {
277
- if (cleanId.includes("live") || cleanId.includes("running")) {
278
- return false;
279
- }
280
+ if (logPath) {
280
281
  try {
281
- const stats = fs.statSync(logPath);
282
- const mtimeAgeMs = Date.now() - stats.mtimeMs;
283
- if (mtimeAgeMs > 15000) {
284
- return true;
285
- }
282
+ return (Date.now() - fs.statSync(logPath).mtimeMs) > TASK_QUIET_MS ? "COMPLETED" : "RUNNING";
286
283
  } catch {}
287
284
  }
285
+ return "UNKNOWN";
286
+ }
288
287
 
289
- return false;
288
+ function isCompatibleRtkVersion(versionStr) {
289
+ if (!versionStr || typeof versionStr !== "string") return false;
290
+ const match = versionStr.match(/(\d+)\.(\d+)\.(\d+)/);
291
+ if (!match) return false;
292
+ const major = parseInt(match[1], 10);
293
+ const minor = parseInt(match[2], 10);
294
+ return major === 0 && (minor === 50 || minor === 51);
290
295
  }
291
296
 
292
- /**
293
- * Determines whether a background task is actively running.
294
- */
295
- function isTaskRunning(taskId, convId, rawPath) {
296
- const candidatePath = (rawPath && fs.existsSync(rawPath)) ? rawPath : null;
297
- const { logPath: resolvedPath, convId: resolvedConvId, cleanId } = resolveTaskLogFile(candidatePath || taskId, convId);
298
- const logPath = resolvedPath || candidatePath;
299
-
300
- // 1. Session state ledger check
301
- const safeId = String(resolvedConvId || convId || "global").replace(/[^a-zA-Z0-9_-]/g, "_");
302
- const sessionPath = path.join(os.tmpdir(), `agy-session-${safeId}.json`);
303
- if (fs.existsSync(sessionPath)) {
304
- try {
305
- const sess = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
306
- if (sess && sess.tasks && sess.tasks[cleanId]) {
307
- if (sess.tasks[cleanId].state === "RUNNING") return true;
308
- if (sess.tasks[cleanId].state === "TERMINATED" || sess.tasks[cleanId].state === "COMPLETED") return false;
309
- }
310
- } catch {}
297
+ function getLocalRtkCandidate() {
298
+ const names = os.platform() === "win32" ? ["rtk.exe", "rtk.cmd"] : ["rtk"];
299
+ const hookDir = path.dirname(fileURLToPath(import.meta.url));
300
+ for (const exeName of names) {
301
+ const pluginBin = path.join(getProfileHomeDir(), ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
302
+ if (fs.existsSync(pluginBin)) return pluginBin;
311
303
  }
304
+ for (const exeName of names) {
305
+ const repoBin = path.resolve(hookDir, "..", "bin", exeName);
306
+ if (fs.existsSync(repoBin)) return repoBin;
307
+ }
308
+ return "";
309
+ }
312
310
 
313
- // 2. Transcript check for completion notice
314
- if (resolvedConvId && resolvedConvId !== "global") {
315
- const home = os.homedir();
316
- const candidateTrans = path.join(home, ".gemini", "antigravity", "brain", resolvedConvId, ".system_generated", "logs", "transcript.jsonl");
317
- if (fs.existsSync(candidateTrans)) {
311
+ function findFirstRtkOnPath() {
312
+ const pathEnv = process.env.PATH || process.env.Path || "";
313
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
314
+ const exts = os.platform() === "win32" ? [".exe", ".cmd", ".bat"] : [""];
315
+ for (const dir of dirs) {
316
+ for (const ext of exts) {
317
+ const candidate = path.join(dir, `rtk${ext}`);
318
318
  try {
319
- const content = fs.readFileSync(candidateTrans, "utf-8");
320
- const taskRegex = new RegExp(`Task id [^"]*${cleanId}[^"]* finished`, "i");
321
- if (taskRegex.test(content)) {
322
- return false; // completed!
319
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
320
+ return candidate;
323
321
  }
324
322
  } catch {}
325
323
  }
326
324
  }
327
-
328
- // 3. File modification age check
329
- if (logPath && fs.existsSync(logPath)) {
330
- try {
331
- const stats = fs.statSync(logPath);
332
- const mtimeAgeMs = Date.now() - stats.mtimeMs;
333
- if (mtimeAgeMs > 15000) {
334
- return false; // Inactive for >15s -> terminated!
335
- }
336
- return true; // Modified within 15s and not marked finished -> RUNNING!
337
- } catch {}
338
- }
339
-
340
- // 4. Test environment / naming convention
341
- if (cleanId.includes("live") || cleanId.includes("running")) {
342
- return true;
343
- }
344
-
345
- return false;
325
+ return "";
346
326
  }
347
327
 
348
-
349
- let resolvedRtkPath = null;
350
- function getRtkBinary() {
351
- if (resolvedRtkPath) return resolvedRtkPath;
352
-
353
- // 1. Try global rtk on PATH
354
- try {
355
- const check = spawnSync("rtk", ["--version"], { timeout: 1000, windowsHide: true });
356
- if (check.status === 0) {
357
- resolvedRtkPath = "rtk";
358
- return resolvedRtkPath;
359
- }
360
- } catch {}
361
-
362
- // 2. Try plugin local bin
363
- const isWin = os.platform() === "win32";
364
- const exeName = isWin ? "rtk.exe" : "rtk";
365
- const homeDir = os.homedir();
366
- const pluginBin = path.join(homeDir, ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
367
- if (fs.existsSync(pluginBin)) {
368
- resolvedRtkPath = pluginBin;
369
- return resolvedRtkPath;
328
+ function spawnRtk(bin, args, opts) {
329
+ if (!bin) return { error: Object.assign(new Error("ENOENT"), { code: "ENOENT" }), status: null, stdout: "", stderr: "" };
330
+ if (os.platform() === "win32" && /\.(cmd|bat)$/i.test(bin)) {
331
+ const quotedBin = /\s/.test(bin) ? `"${bin}"` : bin;
332
+ const quotedArgs = args.map((a) => (/\s|["&|<>^]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" ");
333
+ return spawnSync("cmd.exe", ["/d", "/s", "/c", `"${quotedBin} ${quotedArgs}"`], { ...opts, windowsVerbatimArguments: true });
370
334
  }
371
-
372
- resolvedRtkPath = "rtk";
373
- return resolvedRtkPath;
335
+ return spawnSync(bin, args, opts);
374
336
  }
375
337
 
376
338
  const NON_REWRITABLE_BINARIES = new Set([
@@ -397,31 +359,98 @@ const REWRITE_CACHE_FILE = path.join(os.tmpdir(), "agy-rtk-rewrite-cache.json");
397
359
  const REWRITE_CACHE_MAX = 100;
398
360
  let inMemoryRewriteCache = null;
399
361
 
362
+ function readRewriteCacheFile() {
363
+ try {
364
+ const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
365
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
366
+ } catch {}
367
+ return {};
368
+ }
369
+
400
370
  function loadRewriteCache() {
401
- if (inMemoryRewriteCache) return inMemoryRewriteCache;
371
+ if (!inMemoryRewriteCache) inMemoryRewriteCache = readRewriteCacheFile();
372
+ return inMemoryRewriteCache;
373
+ }
374
+
375
+ function resolveCompatibleRtk() {
376
+ const localCandidate = getLocalRtkCandidate();
377
+ const pathCandidate = findFirstRtkOnPath();
378
+ const envSig = `${pathCandidate}::${localCandidate}`;
379
+ const cache = loadRewriteCache();
380
+ if (cache.__envSig === envSig && cache.__rtkResolved !== undefined) {
381
+ return cache.__rtkResolved;
382
+ }
383
+
384
+ for (const k of Object.keys(cache)) delete cache[k];
385
+
386
+ const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
387
+ let resolved = null;
388
+
389
+ const pathRes = pathCandidate ? spawnRtk(pathCandidate, ["--version"], opts) : { status: 1, stdout: "" };
390
+ const pathCompatible = !pathRes.error && pathRes.status === 0 && isCompatibleRtkVersion(pathRes.stdout);
391
+
392
+ if (pathCompatible) {
393
+ resolved = { exe: pathCandidate, prefix: "rtk" };
394
+ } else if (localCandidate) {
395
+ const localRes = spawnRtk(localCandidate, ["--version"], opts);
396
+ const localCompatible = !localRes.error && localRes.status === 0 && isCompatibleRtkVersion(localRes.stdout);
397
+ if (localCompatible) {
398
+ const normalizedLocal = localCandidate.replace(/\\/g, "/");
399
+ const prefix = /\s/.test(normalizedLocal) ? `"${normalizedLocal}"` : normalizedLocal;
400
+ resolved = { exe: localCandidate, prefix };
401
+ }
402
+ }
403
+
404
+ cache.__envSig = envSig;
405
+ cache.__rtkResolved = resolved;
402
406
  try {
403
- if (fs.existsSync(REWRITE_CACHE_FILE)) {
404
- const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
405
- if (data && typeof data === "object") {
406
- inMemoryRewriteCache = data;
407
- return inMemoryRewriteCache;
408
- }
407
+ const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
408
+ fs.writeFileSync(tmp, JSON.stringify(cache), "utf-8");
409
+ try {
410
+ fs.renameSync(tmp, REWRITE_CACHE_FILE);
411
+ } catch {
412
+ fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(cache), "utf-8");
413
+ try { fs.unlinkSync(tmp); } catch {}
409
414
  }
410
415
  } catch {}
411
- inMemoryRewriteCache = {};
412
- return inMemoryRewriteCache;
416
+
417
+ return resolved;
413
418
  }
414
419
 
415
- function saveRewriteCache(cache) {
420
+ function runRtkRewrite(cmd, resolvedRtk) {
421
+ const rtk = resolvedRtk || resolveCompatibleRtk();
422
+ if (!rtk) return { status: 1, stdout: "", stderr: "No compatible RTK binary available" };
423
+ const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
424
+ return spawnRtk(rtk.exe, ["rewrite", cmd], opts);
425
+ }
426
+
427
+ /**
428
+ * Persists one rewrite result. Re-reads the file first so concurrent hook processes merge instead
429
+ * of overwriting each other, trims to the newest entries, and writes atomically (temp + rename).
430
+ */
431
+ function rememberRewrite(cmd, rewritten) {
432
+ const cache = loadRewriteCache();
433
+ cache[cmd] = rewritten;
416
434
  try {
417
- const keys = Object.keys(cache);
418
- let toSave = cache;
419
- if (keys.length > REWRITE_CACHE_MAX) {
420
- const slice = keys.slice(keys.length - REWRITE_CACHE_MAX);
421
- toSave = {};
422
- for (const k of slice) toSave[k] = cache[k];
423
- }
424
- fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(toSave), "utf-8");
435
+ const disk = readRewriteCacheFile();
436
+ const envSig = cache.__envSig ?? disk.__envSig;
437
+ const rtkResolved = cache.__rtkResolved ?? disk.__rtkResolved;
438
+ delete disk.__envSig;
439
+ delete disk.__rtkResolved;
440
+ delete disk[cmd];
441
+ disk[cmd] = rewritten;
442
+ const keys = Object.keys(disk);
443
+ for (const k of keys.slice(0, Math.max(0, keys.length - REWRITE_CACHE_MAX))) delete disk[k];
444
+ if (envSig !== undefined) disk.__envSig = envSig;
445
+ if (rtkResolved !== undefined) disk.__rtkResolved = rtkResolved;
446
+ const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
447
+ fs.writeFileSync(tmp, JSON.stringify(disk), "utf-8");
448
+ try {
449
+ fs.renameSync(tmp, REWRITE_CACHE_FILE);
450
+ } catch {
451
+ fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(disk), "utf-8");
452
+ try { fs.unlinkSync(tmp); } catch {}
453
+ }
425
454
  } catch {}
426
455
  }
427
456
 
@@ -434,7 +463,11 @@ function rewriteCommandWithRtk(rawCmd) {
434
463
  if (!trimmed) return rawCmd;
435
464
 
436
465
  // Already prefixed with rtk
437
- if (/^rtk(?:\.exe)?\s+/i.test(trimmed)) {
466
+ if (/^rtk(?:\.exe)?(?=\s|$)/i.test(trimmed)) {
467
+ const rtk = resolveCompatibleRtk();
468
+ if (rtk && rtk.prefix !== "rtk") {
469
+ return trimmed.replace(/^rtk(?:\.exe)?(?=\s|$)/i, rtk.prefix);
470
+ }
438
471
  return trimmed;
439
472
  }
440
473
 
@@ -452,7 +485,7 @@ function rewriteCommandWithRtk(rawCmd) {
452
485
  return trimmed;
453
486
  }
454
487
 
455
- // Fast-path bypass for shell builtins, non-rewritable binaries, and powershell cmdlets
488
+ // Fast-path bypass for shell builtins, non-rewritable binaries, and powershell cmdlets
456
489
  const firstWord = (trimmed.match(/^[^\s"']+/)?.[0] || "").toLowerCase();
457
490
  if (
458
491
  NON_REWRITABLE_BINARIES.has(firstWord) ||
@@ -467,6 +500,9 @@ function rewriteCommandWithRtk(rawCmd) {
467
500
  return trimmed;
468
501
  }
469
502
 
503
+ const rtk = resolveCompatibleRtk();
504
+ if (!rtk) return trimmed;
505
+
470
506
  // Check persistent cache
471
507
  const cache = loadRewriteCache();
472
508
  if (cache[trimmed] !== undefined) {
@@ -474,22 +510,23 @@ function rewriteCommandWithRtk(rawCmd) {
474
510
  }
475
511
 
476
512
  let rewritten = trimmed;
513
+ let cacheable = false;
477
514
  try {
478
- const rtkBin = getRtkBinary();
479
- const res = spawnSync(rtkBin, ["rewrite", trimmed], {
480
- encoding: "utf-8",
481
- timeout: 2000,
482
- windowsHide: true
483
- });
515
+ const res = runRtkRewrite(trimmed, rtk);
516
+ // Never cache timeouts, spawn errors or signals: a transient failure must not pin a command
517
+ // to "no rewrite" for the lifetime of the cache.
518
+ cacheable = !res.error && !res.signal && typeof res.status === "number" && res.status >= 0 && res.status <= 3;
484
519
 
485
520
  // RTK exits 0 or 3 when rewritten, with new command on stdout
486
521
  if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
487
522
  rewritten = res.stdout.trim();
523
+ if (rtk.prefix !== "rtk") {
524
+ rewritten = rewritten.replace(/^rtk(?:\.exe)?(?=\s|$)/i, rtk.prefix);
525
+ }
488
526
  }
489
527
  } catch {}
490
528
 
491
- cache[trimmed] = rewritten;
492
- saveRewriteCache(cache);
529
+ if (cacheable) rememberRewrite(trimmed, rewritten);
493
530
  return rewritten;
494
531
  }
495
532
 
@@ -504,6 +541,34 @@ function getRequiredBackoffSeconds(pollCount) {
504
541
  return Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))));
505
542
  }
506
543
 
544
+ /**
545
+ * Denies reading the log of an actively RUNNING task: records the denial, trips the circuit
546
+ * breaker at 5 cumulative denials, otherwise answers with the Proportional Backoff message.
547
+ */
548
+ function denyRunningTask(taskId, convId) {
549
+ const session = getSessionState(convId);
550
+ session.denials++;
551
+ saveSessionState(convId, session);
552
+
553
+ if (session.denials >= 5) {
554
+ respond({
555
+ decision: "force_ask",
556
+ reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
557
+ });
558
+ }
559
+
560
+ const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
561
+ const requiredBackoffMs = requiredBackoffSec * 1000;
562
+ const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
563
+ const elapsedSec = Math.floor(elapsedMs / 1000);
564
+ const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
565
+
566
+ respond({
567
+ decision: "deny",
568
+ reason: `Antigravity Execution Governance: Task '${taskId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
569
+ });
570
+ }
571
+
507
572
  // --- Main Hook Entrypoint ---
508
573
  let isGovernedTool = false;
509
574
 
@@ -563,71 +628,49 @@ try {
563
628
  });
564
629
  }
565
630
 
566
- const rewritten = rewriteCommandWithRtk(rawCmd);
567
- const effectiveCmd = rewritten;
631
+ // Raw-command governance gates are pure string/fs checks: run them BEFORE the rtk rewrite
632
+ // spawn so denied commands never pay for a subprocess.
633
+ const isSpillover = isStepSpilloverPath(rawCmd);
634
+ const taskMatch = isSpillover ? null : getTaskLogMatch(rawCmd);
568
635
 
569
- // Check 1: Step Output Spillover Safe Harbor (0 New Tools)
570
- if (isStepSpilloverPath(rawCmd) || isStepSpilloverPath(effectiveCmd)) {
571
- if (/^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd)) {
572
- // Safe harbor: rtk read already provides line clamping and token windowing
573
- } else {
574
- respond({
575
- decision: "deny",
576
- reason:
577
- "Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
578
- });
579
- }
580
- } else {
581
- // Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools)
582
- const taskLogId = getTaskLogMatch(rawCmd) || getTaskLogMatch(effectiveCmd);
583
- if (taskLogId) {
584
- if (isTaskRunning(taskLogId, convId, rawCmd)) {
585
- // Task actively running: Deny with Proportional Backoff
586
- const session = getSessionState(convId);
587
- session.denials++;
588
- saveSessionState(convId, session);
589
-
590
- if (session.denials >= 5) {
591
- respond({
592
- decision: "force_ask",
593
- reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskLogId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
594
- });
595
- }
636
+ // Check 1: Other protected internal state (transcripts, scheduler, progress)
637
+ if (!isSpillover && !taskMatch && isProtectedInternalState(rawCmd)) {
638
+ respond({
639
+ decision: "deny",
640
+ reason:
641
+ "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
642
+ "- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
643
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
644
+ });
645
+ }
596
646
 
597
- const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
598
- const requiredBackoffMs = requiredBackoffSec * 1000;
599
- const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
600
- const elapsedSec = Math.floor(elapsedMs / 1000);
601
- const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
647
+ // Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools): RUNNING => Proportional Backoff
648
+ if (taskMatch && getTaskState(taskMatch, convId) === "RUNNING") {
649
+ denyRunningTask(taskMatch.id, convId);
650
+ }
602
651
 
603
- respond({
604
- decision: "deny",
605
- reason: `Antigravity Execution Governance: Task '${taskLogId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
606
- });
607
- }
652
+ const rewritten = rewriteCommandWithRtk(rawCmd);
653
+ const effectiveCmd = rewritten;
654
+ const isRtkRead = /^(?:["']?[^\s"']*[/\\])?rtk(?:\.exe|\.cmd|\.bat)?["']?\s+read\b/i.test(effectiveCmd);
608
655
 
609
- // Task is TERMINATED / COMPLETED: allow rtk read
610
- if (/^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd)) {
611
- // Allowed under lifecycle governance
612
- } else {
613
- respond({
614
- decision: "deny",
615
- reason:
616
- "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
617
- `- Once completed, inspect output using 'rtk read <path>' via run_command.\n` +
618
- "- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
619
- });
620
- }
621
- } else if (isProtectedInternalState(rawCmd) || isProtectedInternalState(effectiveCmd)) {
622
- // Check 3: Other protected internal state (transcripts, scheduler, progress)
623
- respond({
624
- decision: "deny",
625
- reason:
626
- "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
627
- "- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
628
- "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
629
- });
630
- }
656
+ // Check 3: Step Output Spillover Safe Harbor (rtk read only; it clamps + windows output)
657
+ if (isSpillover && !isRtkRead) {
658
+ respond({
659
+ decision: "deny",
660
+ reason:
661
+ "Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
662
+ });
663
+ }
664
+
665
+ // Check 4: Terminated / unknown-state task log: allow only through rtk read
666
+ if (taskMatch && !isRtkRead) {
667
+ respond({
668
+ decision: "deny",
669
+ reason:
670
+ "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
671
+ `- Once completed, inspect output using 'rtk read <path>' via run_command.\n` +
672
+ "- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
673
+ });
631
674
  }
632
675
 
633
676
  const isDaemon = args.IsDaemon === true || args.isDaemon === true;
@@ -682,33 +725,14 @@ try {
682
725
  }
683
726
 
684
727
  // 2b. Task Log Inspection (Lifecycle-Gated)
685
- const taskLogId = getTaskLogMatch(target);
686
- if (taskLogId) {
687
- if (isTaskRunning(taskLogId, convId, target)) {
688
- const session = getSessionState(convId);
689
- session.denials++;
690
- saveSessionState(convId, session);
691
-
692
- if (session.denials >= 5) {
693
- respond({
694
- decision: "force_ask",
695
- reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskLogId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
696
- });
697
- }
698
-
699
- const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
700
- const requiredBackoffMs = requiredBackoffSec * 1000;
701
- const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
702
- const elapsedSec = Math.floor(elapsedMs / 1000);
703
- const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
704
-
705
- respond({
706
- decision: "deny",
707
- reason: `Antigravity Execution Governance: Task '${taskLogId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
708
- });
728
+ const taskMatch = getTaskLogMatch(target);
729
+ if (taskMatch) {
730
+ const state = getTaskState(taskMatch, convId);
731
+ if (state === "RUNNING") {
732
+ denyRunningTask(taskMatch.id, convId);
709
733
  }
710
734
 
711
- if (isTaskCompleted(taskLogId, convId, target)) {
735
+ if (state === "COMPLETED") {
712
736
  // If terminated / completed, route to rtk read:
713
737
  respond({
714
738
  decision: "deny",
@@ -740,14 +764,57 @@ try {
740
764
  respond({ decision: "allow" });
741
765
  }
742
766
 
743
- // 2c. Hard-Route Workspace Code/Data Inspection to Canonical RTK Commands (LH-07)
744
- if (toolName === "view_file" || toolName === "read_file") {
767
+ // 2e. Bounded Slice Safe Harbor for view_file
768
+ // Allow view_file IF AND ONLY IF it is a bounded slice inspection (StartLine and EndLine <= 150 lines)
769
+ if (toolName === "view_file") {
770
+ const rawStart = args.StartLine !== undefined ? args.StartLine : args.start_line;
771
+ const rawEnd = args.EndLine !== undefined ? args.EndLine : args.end_line;
772
+ const hasSliceParams = typeof rawStart !== "undefined" || typeof rawEnd !== "undefined";
773
+
774
+ if (hasSliceParams) {
775
+ const startLine = Number(rawStart);
776
+ const endLine = Number(rawEnd);
777
+
778
+ if (
779
+ typeof rawStart !== "undefined" &&
780
+ typeof rawEnd !== "undefined" &&
781
+ Number.isInteger(startLine) &&
782
+ Number.isInteger(endLine) &&
783
+ startLine > 0 &&
784
+ endLine >= startLine
785
+ ) {
786
+ const span = endLine - startLine + 1;
787
+ const MAX_SAFE_SLICE_LINES = 150;
788
+ if (span <= MAX_SAFE_SLICE_LINES) {
789
+ respond({ decision: "allow" });
790
+ }
791
+ respond({
792
+ decision: "deny",
793
+ reason: `Antigravity Governance: Oversized file slice via 'view_file' (${span} lines > ${MAX_SAFE_SLICE_LINES} lines max safe window). Reduce line range or run 'rtk read ${target || "<file>"}' via run_command instead.`
794
+ });
795
+ }
796
+
797
+ respond({
798
+ decision: "deny",
799
+ reason: `Antigravity Governance: Invalid slice range via 'view_file'. Ensure StartLine and EndLine are positive integers with EndLine >= StartLine (max 150 lines), or run 'rtk read ${target || "<file>"}' via run_command instead.`
800
+ });
801
+ }
802
+
803
+ // Default when no slice attempted: keep hook identical to original behavior (zero slice advertisement)
745
804
  respond({
746
805
  decision: "deny",
747
806
  reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target || "<file>"}' via run_command instead.`
748
807
  });
749
808
  }
750
809
 
810
+ // 2f. Hard-Route Full-File / Other Workspace Code Inspection to Canonical RTK Commands (LH-07)
811
+ if (toolName === "read_file") {
812
+ respond({
813
+ decision: "deny",
814
+ reason: `Antigravity Governance: Native file inspection via 'read_file' bypasses token optimization. Run 'rtk read ${target || "<file>"}' via run_command instead.`
815
+ });
816
+ }
817
+
751
818
  if (toolName === "read_many_files") {
752
819
  respond({
753
820
  decision: "deny",
@@ -791,8 +858,8 @@ try {
791
858
  if (toolName === "manage_task") {
792
859
  const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
793
860
 
794
- // kill, send_input are always allowed immediately
795
- if (action !== "status" && action !== "list") {
861
+ // kill, send_input, and list are always allowed immediately
862
+ if (action !== "status") {
796
863
  respond({ decision: "allow" });
797
864
  }
798
865