@quantiya/codevibe-antigravity-plugin 2.0.59 → 2.0.61

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/server.js +422 -164
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -51,7 +51,7 @@ install the unified `codevibe --agent` interface yet.
51
51
 
52
52
  ## How it works
53
53
 
54
- Antigravity CLI writes session transcripts to `~/.gemini/antigravity-cli/brain/<uuid>/.system_generated/logs/transcript.jsonl`. CodeVibe watches those files with chokidar, parses every line of the JSONL stream, and pushes events through E2E-encrypted AWS AppSync to your phone.
54
+ Antigravity CLI writes session transcripts to `~/.gemini/antigravity-cli/brain/<uuid>/.system_generated/logs/transcript.jsonl`. CodeVibe follows the active conversation's transcript file, parses every line of the JSONL stream, and pushes events through E2E-encrypted AWS AppSync to your phone.
55
55
 
56
56
  Approval prompts and multi-choice questions aren't in the transcript at the moment they appear — agy renders them directly into the terminal. CodeVibe observes the live tmux pane to detect both UI variants, parses the option labels, and surfaces them to mobile. Your mobile reply is delivered back via `tmux send-keys`.
57
57
 
package/dist/server.js CHANGED
@@ -95,14 +95,14 @@ var HttpApi = class {
95
95
  if (this.server) return this.assignedPort;
96
96
  this.server = http.createServer((req, res) => this.dispatch(req, res));
97
97
  try {
98
- await new Promise((resolve3, reject) => {
98
+ await new Promise((resolve2, reject) => {
99
99
  const onError = (err) => {
100
100
  this.server?.off("listening", onListening);
101
101
  reject(err);
102
102
  };
103
103
  const onListening = () => {
104
104
  this.server?.off("error", onError);
105
- resolve3();
105
+ resolve2();
106
106
  };
107
107
  this.server.once("error", onError);
108
108
  this.server.once("listening", onListening);
@@ -135,7 +135,7 @@ var HttpApi = class {
135
135
  const srv = this.server;
136
136
  if (!srv) return;
137
137
  this.server = null;
138
- await new Promise((resolve3) => srv.close(() => resolve3()));
138
+ await new Promise((resolve2) => srv.close(() => resolve2()));
139
139
  if (this.portFilePath) {
140
140
  try {
141
141
  await fs.promises.unlink(this.portFilePath);
@@ -268,7 +268,7 @@ function isValidEventSource(value) {
268
268
  return Object.values(import_codevibe_core2.EventSource).includes(value);
269
269
  }
270
270
  async function readBody(req, maxBytes) {
271
- return new Promise((resolve3, reject) => {
271
+ return new Promise((resolve2, reject) => {
272
272
  let received = 0;
273
273
  const chunks = [];
274
274
  req.on("data", (chunk) => {
@@ -280,7 +280,7 @@ async function readBody(req, maxBytes) {
280
280
  }
281
281
  chunks.push(chunk);
282
282
  });
283
- req.on("end", () => resolve3(Buffer.concat(chunks).toString("utf8")));
283
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
284
284
  req.on("error", reject);
285
285
  });
286
286
  }
@@ -306,8 +306,8 @@ async function boundedStep(work, ms, onTimeout) {
306
306
  return Promise.reject(err);
307
307
  }
308
308
  })();
309
- const timeout = new Promise((resolve3) => {
310
- timer = setTimeout(() => resolve3("timeout"), ms);
309
+ const timeout = new Promise((resolve2) => {
310
+ timer = setTimeout(() => resolve2("timeout"), ms);
311
311
  if (timer.unref) timer.unref();
312
312
  });
313
313
  const outcome = await Promise.race([
@@ -355,6 +355,30 @@ var CONVERSATION_DIR_REGEX = /^[0-9a-fA-F-]{36}$/;
355
355
  var SYSTEM_GENERATED_DIR = ".system_generated";
356
356
  var LOGS_DIR = "logs";
357
357
  var TRANSCRIPT_BASENAME = "transcript.jsonl";
358
+ var DISCOVERY_TICK_MS = 250;
359
+ var RESUME_SWEEP_EVERY_TICKS = 4;
360
+ var TRANSCRIPT_POLL_INTERVAL_MS = 100;
361
+ var MAX_FOLLOWED_TRANSCRIPTS = 4;
362
+ var FOLLOW_CATCH_UP_READ_DELAY_MS = 250;
363
+ var CHOKIDAR_ENV_OVERRIDES = ["CHOKIDAR_USEPOLLING", "CHOKIDAR_INTERVAL"];
364
+ function watchOneFilePolled(target, options) {
365
+ const saved = CHOKIDAR_ENV_OVERRIDES.map((name) => [name, process.env[name]]);
366
+ for (const [name] of saved) delete process.env[name];
367
+ let watcher;
368
+ try {
369
+ watcher = (0, import_chokidar.watch)(target, { ...options, usePolling: true });
370
+ } finally {
371
+ for (const [name, value] of saved) {
372
+ if (value === void 0) delete process.env[name];
373
+ else process.env[name] = value;
374
+ }
375
+ }
376
+ if (watcher.options?.usePolling !== true) {
377
+ void watcher.close().catch(() => void 0);
378
+ throw new Error("chokidar did not honour usePolling; refusing a native fs.watch");
379
+ }
380
+ return watcher;
381
+ }
358
382
  function defaultPaths() {
359
383
  const home = os2.homedir();
360
384
  return {
@@ -365,9 +389,39 @@ function defaultPaths() {
365
389
  var TranscriptTailer = class extends import_events.EventEmitter {
366
390
  constructor(options = {}) {
367
391
  super();
368
- this.watcher = null;
369
392
  this.isWatching = false;
370
393
  this.isShuttingDown = false;
394
+ /** Transcripts currently followed (stat-polled), keyed by conversation. */
395
+ this.followed = /* @__PURE__ */ new Map();
396
+ /** Conversations released by the follow cap during this lifetime. Their
397
+ * offsets stay protected from LRU eviction (as activeConversations are), so
398
+ * a later re-follow resumes exactly where it left off instead of replaying
399
+ * history. Cleared when its transcript goes away (unlink, confirmed
400
+ * absence, directory removal), when the sweep resets a never-read cursor
401
+ * for a replaced file, and on start(). A replacement found by the read
402
+ * chain keeps it: that read consumes the new file (Stage 2 R1-F6). */
403
+ this.releasedConversations = /* @__PURE__ */ new Set();
404
+ /** Last inode seen per transcript path by readNewLines: a different inode
405
+ * is a replacement (delete-then-recreate or atomic rename) and resets the
406
+ * cursor, as the former tree watch's `unlink` did. */
407
+ this.fileInodes = /* @__PURE__ */ new Map();
408
+ /** Per-path count of cursor resets (replacement, deletion, directory
409
+ * removal, unlink). A read in flight that sees the count change discards
410
+ * its stale cursor instead of writing it back, and re-queues itself so the
411
+ * new file is read from byte 0 (Stage 1 r7 S1-F14 / NB-K). */
412
+ this.cursorResets = /* @__PURE__ */ new Map();
413
+ /** Conversation directories seen in the brain root (snapshot at start, then
414
+ * maintained by the discovery tick). */
415
+ this.knownConversationDirs = /* @__PURE__ */ new Set();
416
+ /** Conversations created after start whose transcript does not exist yet;
417
+ * checked on every discovery tick. */
418
+ this.pendingTranscripts = /* @__PURE__ */ new Map();
419
+ /** Last swept stat of each unfollowed conversation's transcript (null when
420
+ * absent), for the resume sweep. */
421
+ this.transcriptSnapshots = /* @__PURE__ */ new Map();
422
+ this.rootMtimeMs = 0;
423
+ this.discoveryTimer = null;
424
+ this.discoveryTickCount = 0;
371
425
  /** Error-log rate limiting (incident 2026-09-22 — a wedged watcher wrote
372
426
  * 356,298 identical EMFILE errors / 96 MB before the daemon was killed). */
373
427
  this.lastErrorSignature = null;
@@ -394,11 +448,11 @@ var TranscriptTailer = class extends import_events.EventEmitter {
394
448
  const defaults = defaultPaths();
395
449
  this.brainRoot = options.brainRoot ?? defaults.brainRoot;
396
450
  this.offsetFilePath = options.offsetFilePath ?? defaults.offsetFilePath;
397
- this.usePolling = options.usePolling ?? false;
398
451
  }
399
452
  /**
400
- * Start watching the brain root. Loads persisted offsets, prunes orphans,
401
- * then chokidar watches for new / changed transcript files.
453
+ * Start following transcripts. Loads persisted offsets, prunes orphans,
454
+ * snapshots the brain, then runs the discovery tick. Opens no directory
455
+ * watch (see the file header).
402
456
  */
403
457
  start() {
404
458
  if (this.isWatching) {
@@ -420,97 +474,35 @@ var TranscriptTailer = class extends import_events.EventEmitter {
420
474
  });
421
475
  }
422
476
  }
477
+ this.knownConversationDirs.clear();
478
+ this.pendingTranscripts.clear();
479
+ this.transcriptSnapshots.clear();
480
+ this.releasedConversations.clear();
481
+ this.discoveryTickCount = 0;
482
+ this.rootMtimeMs = this.statMtimeMs(this.brainRoot) ?? 0;
483
+ for (const conversationId of this.listConversationDirs() ?? []) {
484
+ this.knownConversationDirs.add(conversationId);
485
+ this.transcriptSnapshots.set(
486
+ conversationId,
487
+ this.snapshotTranscript(this.transcriptPathFor(conversationId))
488
+ );
489
+ }
423
490
  logger.info("Starting transcript tailer", {
424
491
  brainRoot: this.brainRoot,
425
492
  offsetFile: this.offsetFilePath,
426
- loadedOffsets: this.offsets.size
427
- });
428
- this.watcher = (0, import_chokidar.watch)(this.brainRoot, {
429
- persistent: true,
430
- ignoreInitial: true,
431
- depth: 4,
432
- awaitWriteFinish: false,
433
- // partial-line carryover handles partial writes
434
- usePolling: this.usePolling,
435
- interval: this.usePolling ? 50 : void 0,
436
- // R24 — admit ONLY the exact path shape that can lead to a transcript.
437
- //
438
- // THE DEFECT THIS REPLACES: the previous predicate returned `false` for
439
- // EVERY directory ("allow directories so chokidar can descend into
440
- // them"), so chokidar descended into the whole brain. Measured on a real
441
- // machine 2026-09-22: **40,345 directories across 234 conversations**
442
- // (7.8 GB), on chokidar's kqueue backend which needs roughly one file
443
- // descriptor per watched directory. The watcher exhausted its descriptors
444
- // and threw EMFILE continuously — 77,359 errors in a single 28-minute
445
- // daemon start, 89% of the whole log file across the day.
446
- //
447
- // That was not merely wasteful, it was a CORRECTNESS failure, and the
448
- // exact shape matters because it is worse than a slow start:
449
- //
450
- // 1. The initial scan storms for tens of minutes.
451
- // 2. When it finally completes, `ready` fires and
452
- // backfillActiveTranscripts() does a ONE-SHOT catch-up read. That
453
- // uses its own readdirSync, NOT the watcher, so it succeeds — mobile
454
- // shows a sudden burst of backlog.
455
- // 3. But the per-file WATCH was never established, because the
456
- // descriptors were spent on old conversations during the scan. So no
457
- // `change` event ever arrives again and the tailer STALLS PERMANENTLY
458
- // at whatever offset the backfill reached.
459
- //
460
- // Measured live on the owner's machine while this fix was being written,
461
- // and RETAINED — every figure here comes from that capture, not from
462
- // recollection:
463
- // .hard-gate/r24-watch-scope/evidence/prefix-descriptor-accounting.log
464
- // The daemon held file descriptors for 231 OLD conversations and **zero**
465
- // for the active one, while its offset sat frozen at 987,484 bytes
466
- // against a transcript of 1,656,028 on disk — 668 KB behind and not
467
- // advancing, with the EMFILE rate back to 0/sec and the daemon otherwise
468
- // idle at 0.3% CPU. The gap WIDENED between two samplings twenty minutes
469
- // apart (231 KB, then 668 KB), which is what distinguishes a permanent
470
- // stall from slow catch-up.
471
- //
472
- // User-visible result: a burst of backlog, then silence. Interactive
473
- // prompts degrade to "Prompt candidate fired but no active conversation
474
- // known {observedConvCount:0}", which renders on mobile as "a prompt is
475
- // waiting in your desktop terminal" with NO selectable options — the user
476
- // can see that a prompt exists but cannot answer it, which is the entire
477
- // purpose of the mobile companion.
478
- //
479
- // NOTE this description was corrected twice. It was first written as
480
- // "permanent", then weakened to "a bounded dead window" after observing a
481
- // recovery, then corrected again once the descriptor accounting above was
482
- // actually measured. The recovery was the one-shot backfill of step 2, not
483
- // the watcher healing. Stage 1 NB-4 predicted this sub-case before it was
484
- // measured.
485
- //
486
- // THE FIX: the transcript lives at exactly one shape, four levels deep:
487
- // brain/<uuid>/.system_generated/logs/transcript.jsonl
488
- // so decide purely LEXICALLY on that shape and refuse everything else.
489
- // Same machine, after: **699** directories (1 root + 234 conversations +
490
- // 232 .system_generated + 232 logs) — a 57x reduction that also excludes
491
- // the actual bulk: scratch/ (230 dirs), .user_uploaded/ (232) and the
492
- // thousands of .system_generated/tasks/ dirs, none of which can ever hold
493
- // a transcript.
494
- //
495
- // Deciding lexically also removes the per-path `fs.statSync` the old
496
- // predicate ran during traversal, which was its own CPU cost on a tree
497
- // this size.
498
- ignored: (filePath) => this.isOutsideTranscriptPath(filePath)
499
- });
500
- this.watcher.on("add", (filePath) => this.onFileAdded(filePath));
501
- this.watcher.on("addDir", (dirPath) => this.onDirAdded(dirPath));
502
- this.watcher.on("change", (filePath) => this.onFileChanged(filePath));
503
- this.watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
504
- this.watcher.on("error", (error) => {
505
- this.emitError(error, "Chokidar error");
506
- });
507
- this.watcher.on("ready", () => {
508
- logger.info("Transcript tailer ready (chokidar initial scan complete)");
509
- this.backfillActiveTranscripts();
493
+ loadedOffsets: this.offsets.size,
494
+ knownConversations: this.knownConversationDirs.size
510
495
  });
496
+ this.discoveryTimer = setInterval(() => this.discoveryTick(), DISCOVERY_TICK_MS);
497
+ if (this.discoveryTimer.unref) this.discoveryTimer.unref();
511
498
  this.flushTimer = setInterval(() => this.flushOffsets(), OFFSET_FLUSH_INTERVAL_MS);
512
499
  if (this.flushTimer.unref) this.flushTimer.unref();
513
500
  this.isWatching = true;
501
+ const generation = this.readGeneration;
502
+ setImmediate(() => {
503
+ if (!this.isWatching || this.isShuttingDown || generation !== this.readGeneration) return;
504
+ this.backfillActiveTranscripts();
505
+ });
514
506
  }
515
507
  /**
516
508
  * (#638 G4) SAFE 'error' emission. `EventEmitter.emit('error', …)` with NO
@@ -559,10 +551,18 @@ var TranscriptTailer = class extends import_events.EventEmitter {
559
551
  } catch (error) {
560
552
  logger.warn("Final offset flush failed", { error });
561
553
  }
562
- if (this.watcher) {
563
- const watcher = this.watcher;
554
+ if (this.discoveryTimer) {
555
+ clearInterval(this.discoveryTimer);
556
+ this.discoveryTimer = null;
557
+ }
558
+ const watchers = Array.from(this.followed.values(), (f) => f.watcher);
559
+ this.followed.clear();
560
+ this.pendingTranscripts.clear();
561
+ this.knownConversationDirs.clear();
562
+ this.transcriptSnapshots.clear();
563
+ if (watchers.length > 0) {
564
564
  const outcome = await boundedStep(
565
- () => watcher.close(),
565
+ () => Promise.all(watchers.map((w) => w.close())),
566
566
  WATCHER_CLOSE_TIMEOUT_MS,
567
567
  () => logger.warn("chokidar watcher.close() timed out \u2014 abandoning it", {
568
568
  timeoutMs: WATCHER_CLOSE_TIMEOUT_MS
@@ -571,10 +571,10 @@ var TranscriptTailer = class extends import_events.EventEmitter {
571
571
  if (outcome === "timeout") {
572
572
  logger.warn("Transcript tailer watcher abandoned; relying on shutdown watchdog");
573
573
  }
574
- this.watcher = null;
575
574
  }
576
575
  this.activeReads.clear();
577
576
  this.pendingReads.clear();
577
+ this.cursorResets.clear();
578
578
  this.isWatching = false;
579
579
  this.isShuttingDown = false;
580
580
  logger.info(
@@ -603,7 +603,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
603
603
  getActiveConversations() {
604
604
  return Array.from(this.activeConversations);
605
605
  }
606
- // ─── chokidar event handlers ─────────────────────────────────────────────
606
+ // ─── follower event handlers ─────────────────────────────────────────────
607
607
  onFileAdded(filePath) {
608
608
  if (this.isShuttingDown) return;
609
609
  const conversationId = this.extractConversationId(filePath);
@@ -625,57 +625,292 @@ var TranscriptTailer = class extends import_events.EventEmitter {
625
625
  this.emit("transcript-add", filePath, conversationId);
626
626
  void this.scheduleReadNewLines(filePath, conversationId);
627
627
  }
628
+ // ─── Discovery (no directory watch) ─────────────────────────────────────
628
629
  /**
629
- * R24 traversal filter: true = chokidar must NOT descend into / watch this.
630
- *
631
- * Purely lexical — no filesystem call — so it is cheap enough to run on every
632
- * traversed path and cannot be fooled by a stat race. The only paths admitted
633
- * are the ones that can lead to a transcript:
634
- *
635
- * <brainRoot> depth 0
636
- * <brainRoot>/<uuid> depth 1
637
- * <brainRoot>/<uuid>/.system_generated depth 2
638
- * <brainRoot>/<uuid>/.system_generated/logs depth 3
639
- * <brainRoot>/<uuid>/.system_generated/logs/transcript.jsonl depth 4
640
- *
641
- * Anything else — scratch/, evidence/, .user_uploaded/, the task logs, and
642
- * every file that is not the transcript — is refused, which stops chokidar
643
- * descending into it at all.
644
- *
645
- * NOTE the depth-1 UUID check is what keeps this bounded as the brain grows:
646
- * a non-conversation entry at the top level can never be a conversation, so
647
- * it is never traversed. `depth: 4` on the watcher remains as a second,
648
- * independent bound.
630
+ * One discovery tick. Never throws: a throw from a timer callback is an
631
+ * uncaughtException, which the daemon's process guard may treat as fatal.
649
632
  */
650
- isOutsideTranscriptPath(filePath) {
651
- const rel = path2.relative(this.brainRoot, path2.resolve(filePath));
652
- if (rel === "") return false;
653
- if (rel === ".." || rel.startsWith(".." + path2.sep)) return true;
654
- const seg = rel.split(path2.sep);
655
- if (!CONVERSATION_DIR_REGEX.test(seg[0])) return true;
656
- switch (seg.length) {
657
- case 1:
658
- return false;
659
- // a well-formed conversation directory
660
- case 2:
661
- return seg[1] !== SYSTEM_GENERATED_DIR;
662
- case 3:
663
- return seg[2] !== LOGS_DIR;
664
- case 4:
665
- return seg[3] !== TRANSCRIPT_BASENAME;
666
- default:
667
- return true;
633
+ discoveryTick() {
634
+ if (!this.isWatching || this.isShuttingDown) return;
635
+ try {
636
+ this.discoveryTickCount++;
637
+ const rootMtimeMs = this.statMtimeMs(this.brainRoot);
638
+ if (rootMtimeMs !== null && rootMtimeMs !== this.rootMtimeMs) {
639
+ this.rootMtimeMs = rootMtimeMs;
640
+ this.reconcileConversationDirs();
641
+ }
642
+ for (const [conversationId, transcriptPath] of Array.from(this.pendingTranscripts)) {
643
+ if (fs2.existsSync(transcriptPath)) {
644
+ this.pendingTranscripts.delete(conversationId);
645
+ this.followTranscript(conversationId, transcriptPath, "added");
646
+ }
647
+ }
648
+ if (this.discoveryTickCount % RESUME_SWEEP_EVERY_TICKS === 0) {
649
+ this.reconcileConversationDirs();
650
+ this.sweepForResumedTranscripts();
651
+ }
652
+ } catch (error) {
653
+ this.emitError(error, "Transcript discovery tick failed (non-fatal)");
654
+ }
655
+ }
656
+ /** Bring knownConversationDirs in line with the brain root's listing. */
657
+ reconcileConversationDirs() {
658
+ const present = this.listConversationDirs();
659
+ if (present === null) return;
660
+ const presentSet = new Set(present);
661
+ for (const conversationId of present) {
662
+ if (this.knownConversationDirs.has(conversationId)) continue;
663
+ this.knownConversationDirs.add(conversationId);
664
+ this.onConversationDirAdded(conversationId);
665
+ }
666
+ const tracked = /* @__PURE__ */ new Set([
667
+ ...this.knownConversationDirs,
668
+ ...this.pendingTranscripts.keys(),
669
+ ...this.followed.keys()
670
+ ]);
671
+ for (const conversationId of tracked) {
672
+ if (presentSet.has(conversationId)) continue;
673
+ this.knownConversationDirs.delete(conversationId);
674
+ this.pendingTranscripts.delete(conversationId);
675
+ this.transcriptSnapshots.delete(conversationId);
676
+ this.unfollow(conversationId, "conversation directory removed");
677
+ this.resetCursorForReplacement(
678
+ this.transcriptPathFor(conversationId),
679
+ conversationId,
680
+ "conversation directory removed"
681
+ );
682
+ this.activeConversations.delete(conversationId);
668
683
  }
669
684
  }
670
- onDirAdded(dirPath) {
685
+ onConversationDirAdded(conversationId) {
671
686
  if (this.isShuttingDown) return;
672
- const base = path2.basename(dirPath);
673
- if (!/^[0-9a-fA-F-]{36}$/.test(base)) return;
674
- const conversationId = base;
675
- const expectedParent = path2.dirname(dirPath);
676
- if (path2.resolve(expectedParent) !== path2.resolve(this.brainRoot)) return;
677
- logger.info("New brain directory discovered (pre-prompt)", { dirPath, conversationId });
687
+ logger.info("New brain directory discovered (pre-prompt)", {
688
+ dirPath: path2.join(this.brainRoot, conversationId),
689
+ conversationId
690
+ });
678
691
  this.emit("conversation-discovered", conversationId);
692
+ const transcriptPath = this.transcriptPathFor(conversationId);
693
+ this.transcriptSnapshots.set(conversationId, null);
694
+ if (fs2.existsSync(transcriptPath)) {
695
+ this.followTranscript(conversationId, transcriptPath, "added");
696
+ } else {
697
+ this.pendingTranscripts.set(conversationId, transcriptPath);
698
+ }
699
+ }
700
+ sweepForResumedTranscripts() {
701
+ for (const conversationId of this.knownConversationDirs) {
702
+ if (this.followed.has(conversationId)) continue;
703
+ if (this.pendingTranscripts.has(conversationId)) continue;
704
+ const transcriptPath = this.transcriptPathFor(conversationId);
705
+ const current = this.snapshotTranscript(transcriptPath);
706
+ const previous = this.transcriptSnapshots.get(conversationId) ?? null;
707
+ if (current === null) {
708
+ if (previous !== null && this.isAbsent(transcriptPath)) {
709
+ this.transcriptSnapshots.set(conversationId, null);
710
+ this.resetCursorForReplacement(transcriptPath, conversationId, "deleted while unfollowed");
711
+ }
712
+ continue;
713
+ }
714
+ this.transcriptSnapshots.set(conversationId, current);
715
+ if (previous !== null && current.ino !== previous.ino && !this.fileInodes.has(transcriptPath)) {
716
+ this.resetCursorForReplacement(transcriptPath, conversationId, "inode changed while unfollowed");
717
+ this.emit("transcript-rotated", transcriptPath, conversationId);
718
+ }
719
+ if (previous === null || current.size !== previous.size || current.mtimeMs !== previous.mtimeMs || current.ino !== previous.ino || this.hasUnreadTail(conversationId, transcriptPath, current.size)) {
720
+ this.followTranscript(conversationId, transcriptPath, "resumed");
721
+ }
722
+ }
723
+ }
724
+ /**
725
+ * A conversation released by the follow cap in this lifetime whose file
726
+ * holds bytes past its cursor: its eviction drain did not finish (e.g. a
727
+ * transient stat/open error), so the final message would otherwise wait for
728
+ * the next write (Stage 2 R1-F8). Limited to released conversations — the
729
+ * offsets file also holds other daemons' cursors (F2), and following those
730
+ * on stale offsets would pull another session's history in.
731
+ */
732
+ hasUnreadTail(conversationId, transcriptPath, size) {
733
+ if (!this.releasedConversations.has(conversationId)) return false;
734
+ if (this.activeReads.has(transcriptPath)) return false;
735
+ const entry = this.offsets.get(transcriptPath);
736
+ if (!entry) return false;
737
+ return size > entry.byteOffset + Buffer.byteLength(entry.carryover, "utf-8");
738
+ }
739
+ /**
740
+ * Start following one transcript by stat-polling that file (no `fs.watch`
741
+ * handle), then route it through the same handler a filesystem event would
742
+ * have: `add` for a transcript created after start, `change` for an existing
743
+ * one (first sight of it is treated as a resume).
744
+ */
745
+ followTranscript(conversationId, transcriptPath, how) {
746
+ if (this.isShuttingDown || !this.isWatching) return;
747
+ if (this.followed.has(conversationId)) return;
748
+ let watcher;
749
+ try {
750
+ watcher = watchOneFilePolled(transcriptPath, {
751
+ persistent: true,
752
+ ignoreInitial: true,
753
+ usePolling: true,
754
+ interval: TRANSCRIPT_POLL_INTERVAL_MS,
755
+ awaitWriteFinish: false
756
+ // partial-line carryover handles partial writes
757
+ });
758
+ } catch (error) {
759
+ this.emitError(error, "Could not follow transcript by polling");
760
+ return;
761
+ }
762
+ while (this.followed.size >= MAX_FOLLOWED_TRANSCRIPTS) {
763
+ let oldestId = null;
764
+ let oldestMs = Number.POSITIVE_INFINITY;
765
+ for (const [id, f] of this.followed) {
766
+ if (f.lastActivityMs < oldestMs) {
767
+ oldestMs = f.lastActivityMs;
768
+ oldestId = id;
769
+ }
770
+ }
771
+ if (oldestId === null) break;
772
+ this.unfollow(oldestId, "follow cap reached", { drainTail: true });
773
+ }
774
+ const followed = {
775
+ watcher,
776
+ transcriptPath,
777
+ lastActivityMs: Date.now()
778
+ };
779
+ this.followed.set(conversationId, followed);
780
+ const touch = () => {
781
+ followed.lastActivityMs = Date.now();
782
+ };
783
+ watcher.on("add", (filePath) => {
784
+ touch();
785
+ this.onFileAdded(filePath);
786
+ });
787
+ watcher.on("change", (filePath) => {
788
+ touch();
789
+ this.onFileChanged(filePath);
790
+ });
791
+ watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
792
+ watcher.on("error", (error) => {
793
+ this.emitError(error, "Chokidar error");
794
+ });
795
+ watcher.on("ready", () => {
796
+ const timer = setTimeout(() => {
797
+ if (this.followed.get(conversationId)?.watcher !== watcher) return;
798
+ if (!this.activeConversations.has(conversationId)) return;
799
+ void this.scheduleReadNewLines(transcriptPath, conversationId);
800
+ }, FOLLOW_CATCH_UP_READ_DELAY_MS);
801
+ if (timer.unref) timer.unref();
802
+ });
803
+ logger.info("Following transcript", {
804
+ conversationId,
805
+ how,
806
+ followedCount: this.followed.size,
807
+ pollIntervalMs: TRANSCRIPT_POLL_INTERVAL_MS
808
+ });
809
+ if (how === "added") this.onFileAdded(transcriptPath);
810
+ else this.onFileChanged(transcriptPath);
811
+ }
812
+ /**
813
+ * Stop following one transcript. Its offset is kept, its swept stat is
814
+ * refreshed so the resume sweep only re-follows it on a NEW write, and it
815
+ * leaves activeConversations so a re-follow is a resume.
816
+ *
817
+ * drainTail (eviction): read whatever is past the offset now. Otherwise a
818
+ * write that landed within the last poll interval would sit under the
819
+ * refreshed snapshot, unread until that conversation writes again — for a
820
+ * final message, indefinitely. An evicted conversation also joins
821
+ * releasedConversations, which protects its offset from LRU eviction so a
822
+ * re-follow never replays history. Without drainTail (its directory was
823
+ * removed) the offset is LRU-evictable.
824
+ */
825
+ unfollow(conversationId, reason, opts = {}) {
826
+ const followed = this.followed.get(conversationId);
827
+ if (!followed) return;
828
+ if (this.knownConversationDirs.has(conversationId)) {
829
+ this.transcriptSnapshots.set(conversationId, this.snapshotTranscript(followed.transcriptPath));
830
+ }
831
+ if (opts.drainTail) {
832
+ this.releasedConversations.add(conversationId);
833
+ void this.scheduleReadNewLines(followed.transcriptPath, conversationId);
834
+ } else {
835
+ this.releasedConversations.delete(conversationId);
836
+ }
837
+ this.followed.delete(conversationId);
838
+ this.activeConversations.delete(conversationId);
839
+ followed.watcher.close().catch((error) => {
840
+ this.emitError(error, "Transcript follower close failed (non-fatal)");
841
+ });
842
+ logger.info("Stopped following transcript", { conversationId, reason });
843
+ }
844
+ /**
845
+ * The file at this path is no longer the one the cursor describes (deleted,
846
+ * its directory removed, or replaced by a different inode). Restart it from
847
+ * byte 0, as the former tree watch's `unlink` → onFileUnlinked did. The
848
+ * released-follower offset protection ends with the file it protected —
849
+ * except when the reset is made by the read that is about to consume the
850
+ * new file (`keepProtection`): that read advances the cursor over content it
851
+ * delivers, so the cursor must stay protected or a later re-follow would
852
+ * replay it (Stage 2 R1-F6).
853
+ */
854
+ resetCursorForReplacement(filePath, conversationId, reason, opts = {}) {
855
+ const entry = this.offsets.get(filePath);
856
+ if (entry) {
857
+ logger.warn("Transcript replaced or removed; resetting offset", {
858
+ filePath,
859
+ conversationId,
860
+ reason,
861
+ previousOffset: entry.byteOffset
862
+ });
863
+ entry.byteOffset = 0;
864
+ entry.carryover = "";
865
+ entry.lastKnownSize = 0;
866
+ entry.lastModifiedMs = Date.now();
867
+ }
868
+ this.fileInodes.delete(filePath);
869
+ if (!opts.keepProtection) this.releasedConversations.delete(conversationId);
870
+ this.bumpCursorResets(filePath);
871
+ }
872
+ /** Only a read chain in flight can hold a stale cursor, so only then is the
873
+ * count kept; the chain's end prunes it (Stage 2 R1-F7). Outside resets
874
+ * land at an `await`, by which time the chain is registered in activeReads. */
875
+ bumpCursorResets(filePath) {
876
+ if (!this.activeReads.has(filePath)) return;
877
+ this.cursorResets.set(filePath, (this.cursorResets.get(filePath) ?? 0) + 1);
878
+ }
879
+ transcriptPathFor(conversationId) {
880
+ return path2.join(this.brainRoot, conversationId, SYSTEM_GENERATED_DIR, LOGS_DIR, TRANSCRIPT_BASENAME);
881
+ }
882
+ /** UUID-named directories directly under the brain root; null on failure. */
883
+ listConversationDirs() {
884
+ try {
885
+ return fs2.readdirSync(this.brainRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && CONVERSATION_DIR_REGEX.test(entry.name)).map((entry) => entry.name);
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+ snapshotTranscript(transcriptPath) {
891
+ try {
892
+ const stat = fs2.statSync(transcriptPath);
893
+ return stat.isFile() ? { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino } : null;
894
+ } catch {
895
+ return null;
896
+ }
897
+ }
898
+ /** True only when the path is confirmed absent (ENOENT / ENOTDIR). */
899
+ isAbsent(filePath) {
900
+ try {
901
+ fs2.statSync(filePath);
902
+ return false;
903
+ } catch (error) {
904
+ const code = error.code;
905
+ return code === "ENOENT" || code === "ENOTDIR";
906
+ }
907
+ }
908
+ statMtimeMs(dirPath) {
909
+ try {
910
+ return fs2.statSync(dirPath).mtimeMs;
911
+ } catch {
912
+ return null;
913
+ }
679
914
  }
680
915
  /**
681
916
  * Fires when chokidar reports a transcript.jsonl was deleted or moved
@@ -709,6 +944,9 @@ var TranscriptTailer = class extends import_events.EventEmitter {
709
944
  entry.carryover = "";
710
945
  entry.lastKnownSize = 0;
711
946
  entry.lastModifiedMs = Date.now();
947
+ this.fileInodes.delete(filePath);
948
+ this.releasedConversations.delete(conversationId);
949
+ this.bumpCursorResets(filePath);
712
950
  this.activeConversations.delete(conversationId);
713
951
  }
714
952
  onFileChanged(filePath) {
@@ -728,7 +966,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
728
966
  }
729
967
  // ─── Backfill at startup ────────────────────────────────────────────────
730
968
  /**
731
- * Walk the brain root once at startup and emit resume events for any
969
+ * Walk the brain root once at startup and follow (as a resume) any
732
970
  * transcript.jsonl that already exists and shows recent activity. This
733
971
  * covers the case where agy is already running when codevibe-agy starts.
734
972
  */
@@ -740,6 +978,13 @@ var TranscriptTailer = class extends import_events.EventEmitter {
740
978
  if (!dir.isDirectory()) continue;
741
979
  const conversationId = dir.name;
742
980
  if (!/^[0-9a-fA-F-]{36}$/.test(conversationId)) continue;
981
+ if (!this.knownConversationDirs.has(conversationId)) {
982
+ this.knownConversationDirs.add(conversationId);
983
+ this.transcriptSnapshots.set(
984
+ conversationId,
985
+ this.snapshotTranscript(this.transcriptPathFor(conversationId))
986
+ );
987
+ }
743
988
  const dirPath = path2.join(this.brainRoot, conversationId);
744
989
  const transcriptPath = path2.join(
745
990
  dirPath,
@@ -777,16 +1022,16 @@ var TranscriptTailer = class extends import_events.EventEmitter {
777
1022
  transcriptPath,
778
1023
  conversationId
779
1024
  });
780
- this.activeConversations.add(conversationId);
781
- this.ensureOffset(transcriptPath);
782
- this.emit("transcript-resume", transcriptPath, conversationId);
783
- void this.scheduleReadNewLines(transcriptPath, conversationId);
1025
+ this.followTranscript(conversationId, transcriptPath, "resumed");
784
1026
  } else {
785
1027
  logger.info("Backfilling recent transcriptless directory at startup", {
786
1028
  dirPath,
787
1029
  conversationId
788
1030
  });
789
1031
  this.emit("conversation-discovered", conversationId);
1032
+ if (!this.followed.has(conversationId)) {
1033
+ this.pendingTranscripts.set(conversationId, transcriptPath);
1034
+ }
790
1035
  }
791
1036
  }
792
1037
  } catch (error) {
@@ -813,6 +1058,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
813
1058
  if (this.activeReads.get(filePath) === read) {
814
1059
  this.activeReads.delete(filePath);
815
1060
  this.pendingReads.delete(filePath);
1061
+ this.cursorResets.delete(filePath);
816
1062
  }
817
1063
  });
818
1064
  return read;
@@ -837,6 +1083,12 @@ var TranscriptTailer = class extends import_events.EventEmitter {
837
1083
  logger.warn("stat failed; file may have been deleted", { filePath, error });
838
1084
  return;
839
1085
  }
1086
+ const lastInode = this.fileInodes.get(filePath);
1087
+ if (lastInode !== void 0 && lastInode !== stat.ino) {
1088
+ this.resetCursorForReplacement(filePath, conversationId, "inode changed", { keepProtection: true });
1089
+ this.emit("transcript-rotated", filePath, conversationId);
1090
+ }
1091
+ this.fileInodes.set(filePath, stat.ino);
840
1092
  if (stat.size < entry.lastKnownSize) {
841
1093
  logger.warn("Transcript file rotated (size shrunk); resetting offset", {
842
1094
  filePath,
@@ -849,6 +1101,12 @@ var TranscriptTailer = class extends import_events.EventEmitter {
849
1101
  entry.lastKnownSize = stat.size;
850
1102
  this.emit("transcript-rotated", filePath, conversationId);
851
1103
  }
1104
+ const resetsAtStart = this.cursorResets.get(filePath) ?? 0;
1105
+ const staleRead = () => {
1106
+ if ((this.cursorResets.get(filePath) ?? 0) === resetsAtStart) return false;
1107
+ this.pendingReads.add(filePath);
1108
+ return true;
1109
+ };
852
1110
  const carryoverBytes = Buffer.byteLength(entry.carryover, "utf-8");
853
1111
  const readStart = entry.byteOffset + carryoverBytes;
854
1112
  if (stat.size <= readStart) {
@@ -871,14 +1129,14 @@ var TranscriptTailer = class extends import_events.EventEmitter {
871
1129
  let lineStartOffset = entry.byteOffset;
872
1130
  try {
873
1131
  for await (const chunk of stream) {
874
- if (generation !== this.readGeneration) {
1132
+ if (generation !== this.readGeneration || staleRead()) {
875
1133
  stream.destroy();
876
1134
  return;
877
1135
  }
878
1136
  buffered += chunk;
879
1137
  let newlineIndex = buffered.indexOf("\n");
880
1138
  while (newlineIndex !== -1) {
881
- if (generation !== this.readGeneration) {
1139
+ if (generation !== this.readGeneration || staleRead()) {
882
1140
  stream.destroy();
883
1141
  return;
884
1142
  }
@@ -900,7 +1158,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
900
1158
  this.emitError(error);
901
1159
  return;
902
1160
  }
903
- if (generation !== this.readGeneration) return;
1161
+ if (generation !== this.readGeneration || staleRead()) return;
904
1162
  entry.byteOffset = lineStartOffset;
905
1163
  entry.carryover = buffered;
906
1164
  entry.lastKnownSize = stat.size;
@@ -964,7 +1222,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
964
1222
  let oldestMtime = Number.POSITIVE_INFINITY;
965
1223
  for (const [p, entry] of this.offsets.entries()) {
966
1224
  const cid = this.extractConversationId(p);
967
- if (cid && this.activeConversations.has(cid)) {
1225
+ if (cid && (this.activeConversations.has(cid) || this.releasedConversations.has(cid))) {
968
1226
  continue;
969
1227
  }
970
1228
  if (entry.lastModifiedMs < oldestMtime) {
@@ -1962,8 +2220,8 @@ function escapeShellArg(value) {
1962
2220
  return value.replace(/'/g, `'\\''`);
1963
2221
  }
1964
2222
  function sleep(ms) {
1965
- return new Promise((resolve3) => {
1966
- const t = setTimeout(resolve3, ms);
2223
+ return new Promise((resolve2) => {
2224
+ const t = setTimeout(resolve2, ms);
1967
2225
  if (t.unref) t.unref();
1968
2226
  });
1969
2227
  }
@@ -3135,14 +3393,14 @@ function sanitizeErrorMessage(msg) {
3135
3393
  return out.trim().substring(0, 100);
3136
3394
  }
3137
3395
  function fireDaemonBeacon(name, params) {
3138
- return new Promise((resolve3) => {
3396
+ return new Promise((resolve2) => {
3139
3397
  let req;
3140
3398
  let settled = false;
3141
3399
  const settle = () => {
3142
3400
  if (settled) return;
3143
3401
  settled = true;
3144
3402
  clearTimeout(overall);
3145
- resolve3();
3403
+ resolve2();
3146
3404
  };
3147
3405
  const overall = setTimeout(() => {
3148
3406
  try {
@@ -4126,8 +4384,8 @@ var McpServer = class _McpServer {
4126
4384
  let hostedRetirementError = null;
4127
4385
  if (launchSettlement) {
4128
4386
  let timer;
4129
- const timeout = new Promise((resolve3) => {
4130
- timer = setTimeout(resolve3, this.launchSettleTimeoutMs);
4387
+ const timeout = new Promise((resolve2) => {
4388
+ timer = setTimeout(resolve2, this.launchSettleTimeoutMs);
4131
4389
  timer.unref?.();
4132
4390
  });
4133
4391
  try {
@@ -4280,7 +4538,7 @@ var McpServer = class _McpServer {
4280
4538
  error: String(err)
4281
4539
  });
4282
4540
  if (attempt < SESSION_RETIRE_MAX_ATTEMPTS) {
4283
- await new Promise((resolve3) => setTimeout(resolve3, SESSION_RETIRE_RETRY_MS * attempt));
4541
+ await new Promise((resolve2) => setTimeout(resolve2, SESSION_RETIRE_RETRY_MS * attempt));
4284
4542
  }
4285
4543
  }
4286
4544
  }
@@ -4940,7 +5198,7 @@ var McpServer = class _McpServer {
4940
5198
  if (this.reRaiseSuperseded(gen)) return;
4941
5199
  const slice = Math.min(50, deadline - Date.now());
4942
5200
  if (slice <= 0) return;
4943
- await new Promise((resolve3) => setTimeout(resolve3, slice));
5201
+ await new Promise((resolve2) => setTimeout(resolve2, slice));
4944
5202
  }
4945
5203
  }
4946
5204
  /**
@@ -5036,7 +5294,7 @@ var McpServer = class _McpServer {
5036
5294
  );
5037
5295
  await this.interruptibleBackoff(delay2, gen);
5038
5296
  } else {
5039
- await new Promise((resolve3) => setTimeout(resolve3, 250 * attempt));
5297
+ await new Promise((resolve2) => setTimeout(resolve2, 250 * attempt));
5040
5298
  }
5041
5299
  }
5042
5300
  }
@@ -5541,7 +5799,7 @@ var McpServer = class _McpServer {
5541
5799
  return;
5542
5800
  }
5543
5801
  promptContent = intent.followUpText;
5544
- await new Promise((resolve3) => setTimeout(resolve3, 250));
5802
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
5545
5803
  } else {
5546
5804
  const dismissal = await this.promptResponder.dismissActivePrompt();
5547
5805
  if (!dismissal.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.59",
3
+ "version": "2.0.61",
4
4
  "description": "Control Antigravity CLI from your iPhone and Android — real-time sync, approve file edits, send prompts by voice. Part of CodeVibe.",
5
5
  "main": "dist/server.js",
6
6
  "codevibe": {
@@ -46,7 +46,7 @@
46
46
  "node": ">=22.0.0"
47
47
  },
48
48
  "dependencies": {
49
- "@quantiya/codevibe-core": "^2.0.55",
49
+ "@quantiya/codevibe-core": "^2.0.56",
50
50
  "chokidar": "^5.0.0",
51
51
  "dotenv": "^16.6.1",
52
52
  "express": "^5.1.0",