@quantiya/codevibe-antigravity-plugin 2.0.60 → 2.0.62

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 +938 -333
  3. package/package.json +2 -2
package/dist/server.js CHANGED
@@ -49,7 +49,7 @@ var fs5 = __toESM(require("fs"));
49
49
  var os5 = __toESM(require("os"));
50
50
  var import_child_process3 = require("child_process");
51
51
  var import_util3 = require("util");
52
- var import_codevibe_core5 = require("@quantiya/codevibe-core");
52
+ var import_codevibe_core6 = require("@quantiya/codevibe-core");
53
53
 
54
54
  // src/logger.ts
55
55
  var import_os = __toESM(require("os"));
@@ -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);
@@ -205,23 +205,20 @@ var HttpApi = class {
205
205
  this.sendJson(res, 400, { success: false, error: "invalid JSON" });
206
206
  return;
207
207
  }
208
- if (!payload.sessionId || !payload.type || !payload.source || typeof payload.content !== "string") {
209
- this.sendJson(res, 400, {
210
- success: false,
211
- error: "missing required fields: sessionId, type, source, content"
212
- });
213
- return;
214
- }
215
- if (!isValidEventType(payload.type) || !isValidEventSource(payload.source)) {
208
+ if (!isHttpEventPayload(payload)) {
216
209
  this.sendJson(res, 400, {
217
210
  success: false,
218
- error: "invalid type or source enum value"
211
+ error: "invalid ordinary desktop event payload"
219
212
  });
220
213
  return;
221
214
  }
222
215
  try {
223
- const { eventId } = await this.handlers.pushEvent(payload);
224
- this.sendJson(res, 200, { success: true, data: { eventId } });
216
+ const result = await this.handlers.pushEvent(payload);
217
+ if (result.status === "DROPPED") {
218
+ this.sendJson(res, 503, { success: false, error: "event was not admitted", data: result });
219
+ } else {
220
+ this.sendJson(res, result.status === "PENDING" ? 202 : 200, { success: true, data: result });
221
+ }
225
222
  } catch (err) {
226
223
  logger.error("pushEvent failed in /event handler", { error: String(err) });
227
224
  const code = err?.code;
@@ -229,6 +226,10 @@ var HttpApi = class {
229
226
  this.sendJson(res, 404, { success: false, error: "session not found" });
230
227
  return;
231
228
  }
229
+ if (code === "INVALID_SYNC_INPUT") {
230
+ this.sendJson(res, 400, { success: false, error: "unsupported event payload; use its dedicated producer" });
231
+ return;
232
+ }
232
233
  this.sendJson(res, 500, { success: false, error: "event push failed" });
233
234
  }
234
235
  }
@@ -263,12 +264,13 @@ function isValidEventType(value) {
263
264
  if (typeof value !== "string") return false;
264
265
  return Object.values(import_codevibe_core2.EventType).includes(value);
265
266
  }
266
- function isValidEventSource(value) {
267
- if (typeof value !== "string") return false;
268
- return Object.values(import_codevibe_core2.EventSource).includes(value);
267
+ function isHttpEventPayload(value) {
268
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
269
+ const row = value;
270
+ return !Object.keys(row).some((key) => !["sessionId", "type", "source", "content", "metadata"].includes(key)) && typeof row.sessionId === "string" && /^[A-Za-z0-9_-]{1,256}$/.test(row.sessionId) && isValidEventType(row.type) && row.source === import_codevibe_core2.EventSource.DESKTOP && typeof row.content === "string";
269
271
  }
270
272
  async function readBody(req, maxBytes) {
271
- return new Promise((resolve3, reject) => {
273
+ return new Promise((resolve2, reject) => {
272
274
  let received = 0;
273
275
  const chunks = [];
274
276
  req.on("data", (chunk) => {
@@ -280,7 +282,7 @@ async function readBody(req, maxBytes) {
280
282
  }
281
283
  chunks.push(chunk);
282
284
  });
283
- req.on("end", () => resolve3(Buffer.concat(chunks).toString("utf8")));
285
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
284
286
  req.on("error", reject);
285
287
  });
286
288
  }
@@ -306,8 +308,8 @@ async function boundedStep(work, ms, onTimeout) {
306
308
  return Promise.reject(err);
307
309
  }
308
310
  })();
309
- const timeout = new Promise((resolve3) => {
310
- timer = setTimeout(() => resolve3("timeout"), ms);
311
+ const timeout = new Promise((resolve2) => {
312
+ timer = setTimeout(() => resolve2("timeout"), ms);
311
313
  if (timer.unref) timer.unref();
312
314
  });
313
315
  const outcome = await Promise.race([
@@ -346,6 +348,29 @@ function armShutdownWatchdog(exit, onFire, ms = SHUTDOWN_WATCHDOG_MS, durableObl
346
348
  return () => clearTimeout(timer);
347
349
  }
348
350
 
351
+ // src/durable-transcript-reader.ts
352
+ var import_codevibe_core3 = require("@quantiya/codevibe-core");
353
+ function readAdmittedTranscript(options) {
354
+ const stream = (value) => Object.freeze({ ...value, conversationId: options.conversationId });
355
+ return (0, import_codevibe_core3.readDesktopSyncFile)({
356
+ ...options,
357
+ parse: (line) => {
358
+ const parsed = JSON.parse(line);
359
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) && Number.isFinite(parsed.step_index) && typeof parsed.type === "string" ? parsed : null;
360
+ },
361
+ admission: {
362
+ restore: (source, initial) => options.admission.restore(stream(source), initial),
363
+ skip: (gap, source) => options.admission.skip(gap, stream(source)),
364
+ admit: (event, source) => options.admission.admit({
365
+ conversationId: options.conversationId,
366
+ filePath: source.filePath,
367
+ byteOffset: source.startOffset,
368
+ event
369
+ }, stream(source))
370
+ }
371
+ });
372
+ }
373
+
349
374
  // src/transcript-tailer.ts
350
375
  var OFFSET_FLUSH_INTERVAL_MS = 3e4;
351
376
  var ERROR_LOG_MIN_INTERVAL_MS = 1e4;
@@ -355,6 +380,30 @@ var CONVERSATION_DIR_REGEX = /^[0-9a-fA-F-]{36}$/;
355
380
  var SYSTEM_GENERATED_DIR = ".system_generated";
356
381
  var LOGS_DIR = "logs";
357
382
  var TRANSCRIPT_BASENAME = "transcript.jsonl";
383
+ var DISCOVERY_TICK_MS = 250;
384
+ var RESUME_SWEEP_EVERY_TICKS = 4;
385
+ var TRANSCRIPT_POLL_INTERVAL_MS = 100;
386
+ var MAX_FOLLOWED_TRANSCRIPTS = 4;
387
+ var FOLLOW_CATCH_UP_READ_DELAY_MS = 250;
388
+ var CHOKIDAR_ENV_OVERRIDES = ["CHOKIDAR_USEPOLLING", "CHOKIDAR_INTERVAL"];
389
+ function watchOneFilePolled(target, options) {
390
+ const saved = CHOKIDAR_ENV_OVERRIDES.map((name) => [name, process.env[name]]);
391
+ for (const [name] of saved) delete process.env[name];
392
+ let watcher;
393
+ try {
394
+ watcher = (0, import_chokidar.watch)(target, { ...options, usePolling: true });
395
+ } finally {
396
+ for (const [name, value] of saved) {
397
+ if (value === void 0) delete process.env[name];
398
+ else process.env[name] = value;
399
+ }
400
+ }
401
+ if (watcher.options?.usePolling !== true) {
402
+ void watcher.close().catch(() => void 0);
403
+ throw new Error("chokidar did not honour usePolling; refusing a native fs.watch");
404
+ }
405
+ return watcher;
406
+ }
358
407
  function defaultPaths() {
359
408
  const home = os2.homedir();
360
409
  return {
@@ -365,9 +414,41 @@ function defaultPaths() {
365
414
  var TranscriptTailer = class extends import_events.EventEmitter {
366
415
  constructor(options = {}) {
367
416
  super();
368
- this.watcher = null;
369
417
  this.isWatching = false;
370
418
  this.isShuttingDown = false;
419
+ this.ownedReads = /* @__PURE__ */ new Set();
420
+ this.retryTimers = /* @__PURE__ */ new Map();
421
+ /** Transcripts currently followed (stat-polled), keyed by conversation. */
422
+ this.followed = /* @__PURE__ */ new Map();
423
+ /** Conversations released by the follow cap during this lifetime. Their
424
+ * offsets stay protected from LRU eviction (as activeConversations are), so
425
+ * a later re-follow resumes exactly where it left off instead of replaying
426
+ * history. Cleared when its transcript goes away (unlink, confirmed
427
+ * absence, directory removal), when the sweep resets a never-read cursor
428
+ * for a replaced file, and on start(). A replacement found by the read
429
+ * chain keeps it: that read consumes the new file (Stage 2 R1-F6). */
430
+ this.releasedConversations = /* @__PURE__ */ new Set();
431
+ /** Last inode seen per transcript path by readNewLines: a different inode
432
+ * is a replacement (delete-then-recreate or atomic rename) and resets the
433
+ * cursor, as the former tree watch's `unlink` did. */
434
+ this.fileInodes = /* @__PURE__ */ new Map();
435
+ /** Per-path count of cursor resets (replacement, deletion, directory
436
+ * removal, unlink). A read in flight that sees the count change discards
437
+ * its stale cursor instead of writing it back, and re-queues itself so the
438
+ * new file is read from byte 0 (Stage 1 r7 S1-F14 / NB-K). */
439
+ this.cursorResets = /* @__PURE__ */ new Map();
440
+ /** Conversation directories seen in the brain root (snapshot at start, then
441
+ * maintained by the discovery tick). */
442
+ this.knownConversationDirs = /* @__PURE__ */ new Set();
443
+ /** Conversations created after start whose transcript does not exist yet;
444
+ * checked on every discovery tick. */
445
+ this.pendingTranscripts = /* @__PURE__ */ new Map();
446
+ /** Last swept stat of each unfollowed conversation's transcript (null when
447
+ * absent), for the resume sweep. */
448
+ this.transcriptSnapshots = /* @__PURE__ */ new Map();
449
+ this.rootMtimeMs = 0;
450
+ this.discoveryTimer = null;
451
+ this.discoveryTickCount = 0;
371
452
  /** Error-log rate limiting (incident 2026-09-22 — a wedged watcher wrote
372
453
  * 356,298 identical EMFILE errors / 96 MB before the daemon was killed). */
373
454
  this.lastErrorSignature = null;
@@ -394,11 +475,13 @@ var TranscriptTailer = class extends import_events.EventEmitter {
394
475
  const defaults = defaultPaths();
395
476
  this.brainRoot = options.brainRoot ?? defaults.brainRoot;
396
477
  this.offsetFilePath = options.offsetFilePath ?? defaults.offsetFilePath;
397
- this.usePolling = options.usePolling ?? false;
478
+ this.admission = options.admission;
479
+ if (this.admission && (typeof this.admission.restore !== "function" || typeof this.admission.admit !== "function" || typeof this.admission.skip !== "function")) throw new TypeError("Durable transcript admission requires restore, admit and skip");
398
480
  }
399
481
  /**
400
- * Start watching the brain root. Loads persisted offsets, prunes orphans,
401
- * then chokidar watches for new / changed transcript files.
482
+ * Start following transcripts. Loads persisted offsets, prunes orphans,
483
+ * snapshots the brain, then runs the discovery tick. Opens no directory
484
+ * watch (see the file header).
402
485
  */
403
486
  start() {
404
487
  if (this.isWatching) {
@@ -407,6 +490,8 @@ var TranscriptTailer = class extends import_events.EventEmitter {
407
490
  }
408
491
  this.startTime = Date.now();
409
492
  this.readGeneration++;
493
+ for (const timer of this.retryTimers.values()) clearTimeout(timer);
494
+ this.retryTimers.clear();
410
495
  this.loadPersistedOffsets();
411
496
  this.pruneOrphanedOffsets();
412
497
  if (!fs2.existsSync(this.brainRoot)) {
@@ -420,97 +505,35 @@ var TranscriptTailer = class extends import_events.EventEmitter {
420
505
  });
421
506
  }
422
507
  }
508
+ this.knownConversationDirs.clear();
509
+ this.pendingTranscripts.clear();
510
+ this.transcriptSnapshots.clear();
511
+ this.releasedConversations.clear();
512
+ this.discoveryTickCount = 0;
513
+ this.rootMtimeMs = this.statMtimeMs(this.brainRoot) ?? 0;
514
+ for (const conversationId of this.listConversationDirs() ?? []) {
515
+ this.knownConversationDirs.add(conversationId);
516
+ this.transcriptSnapshots.set(
517
+ conversationId,
518
+ this.snapshotTranscript(this.transcriptPathFor(conversationId))
519
+ );
520
+ }
423
521
  logger.info("Starting transcript tailer", {
424
522
  brainRoot: this.brainRoot,
425
523
  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();
524
+ loadedOffsets: this.offsets.size,
525
+ knownConversations: this.knownConversationDirs.size
510
526
  });
527
+ this.discoveryTimer = setInterval(() => this.discoveryTick(), DISCOVERY_TICK_MS);
528
+ if (this.discoveryTimer.unref) this.discoveryTimer.unref();
511
529
  this.flushTimer = setInterval(() => this.flushOffsets(), OFFSET_FLUSH_INTERVAL_MS);
512
530
  if (this.flushTimer.unref) this.flushTimer.unref();
513
531
  this.isWatching = true;
532
+ const generation = this.readGeneration;
533
+ setImmediate(() => {
534
+ if (!this.isWatching || this.isShuttingDown || generation !== this.readGeneration) return;
535
+ this.backfillActiveTranscripts();
536
+ });
514
537
  }
515
538
  /**
516
539
  * (#638 G4) SAFE 'error' emission. `EventEmitter.emit('error', …)` with NO
@@ -550,6 +573,8 @@ var TranscriptTailer = class extends import_events.EventEmitter {
550
573
  if (!this.isWatching) return;
551
574
  this.isShuttingDown = true;
552
575
  this.readGeneration++;
576
+ for (const timer of this.retryTimers.values()) clearTimeout(timer);
577
+ this.retryTimers.clear();
553
578
  if (this.flushTimer) {
554
579
  clearInterval(this.flushTimer);
555
580
  this.flushTimer = null;
@@ -559,10 +584,18 @@ var TranscriptTailer = class extends import_events.EventEmitter {
559
584
  } catch (error) {
560
585
  logger.warn("Final offset flush failed", { error });
561
586
  }
562
- if (this.watcher) {
563
- const watcher = this.watcher;
587
+ if (this.discoveryTimer) {
588
+ clearInterval(this.discoveryTimer);
589
+ this.discoveryTimer = null;
590
+ }
591
+ const watchers = Array.from(this.followed.values(), (f) => f.watcher);
592
+ this.followed.clear();
593
+ this.pendingTranscripts.clear();
594
+ this.knownConversationDirs.clear();
595
+ this.transcriptSnapshots.clear();
596
+ if (watchers.length > 0) {
564
597
  const outcome = await boundedStep(
565
- () => watcher.close(),
598
+ () => Promise.all(watchers.map((w) => w.close())),
566
599
  WATCHER_CLOSE_TIMEOUT_MS,
567
600
  () => logger.warn("chokidar watcher.close() timed out \u2014 abandoning it", {
568
601
  timeoutMs: WATCHER_CLOSE_TIMEOUT_MS
@@ -571,10 +604,10 @@ var TranscriptTailer = class extends import_events.EventEmitter {
571
604
  if (outcome === "timeout") {
572
605
  logger.warn("Transcript tailer watcher abandoned; relying on shutdown watchdog");
573
606
  }
574
- this.watcher = null;
575
607
  }
576
608
  this.activeReads.clear();
577
609
  this.pendingReads.clear();
610
+ this.cursorResets.clear();
578
611
  this.isWatching = false;
579
612
  this.isShuttingDown = false;
580
613
  logger.info(
@@ -584,6 +617,10 @@ var TranscriptTailer = class extends import_events.EventEmitter {
584
617
  this.lastErrorSignature = null;
585
618
  this.suppressedErrorCount = 0;
586
619
  }
620
+ /** Server shutdown joins reads it admitted before retiring the session. */
621
+ async waitForReads() {
622
+ await Promise.allSettled([...this.ownedReads]);
623
+ }
587
624
  /**
588
625
  * Best-effort sync flush. Safe to call from a process EXIT trap.
589
626
  * (The async stop() above is preferred when a regular shutdown is feasible.)
@@ -603,7 +640,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
603
640
  getActiveConversations() {
604
641
  return Array.from(this.activeConversations);
605
642
  }
606
- // ─── chokidar event handlers ─────────────────────────────────────────────
643
+ // ─── follower event handlers ─────────────────────────────────────────────
607
644
  onFileAdded(filePath) {
608
645
  if (this.isShuttingDown) return;
609
646
  const conversationId = this.extractConversationId(filePath);
@@ -625,57 +662,292 @@ var TranscriptTailer = class extends import_events.EventEmitter {
625
662
  this.emit("transcript-add", filePath, conversationId);
626
663
  void this.scheduleReadNewLines(filePath, conversationId);
627
664
  }
665
+ // ─── Discovery (no directory watch) ─────────────────────────────────────
628
666
  /**
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.
667
+ * One discovery tick. Never throws: a throw from a timer callback is an
668
+ * uncaughtException, which the daemon's process guard may treat as fatal.
649
669
  */
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;
670
+ discoveryTick() {
671
+ if (!this.isWatching || this.isShuttingDown) return;
672
+ try {
673
+ this.discoveryTickCount++;
674
+ const rootMtimeMs = this.statMtimeMs(this.brainRoot);
675
+ if (rootMtimeMs !== null && rootMtimeMs !== this.rootMtimeMs) {
676
+ this.rootMtimeMs = rootMtimeMs;
677
+ this.reconcileConversationDirs();
678
+ }
679
+ for (const [conversationId, transcriptPath] of Array.from(this.pendingTranscripts)) {
680
+ if (fs2.existsSync(transcriptPath)) {
681
+ this.pendingTranscripts.delete(conversationId);
682
+ this.followTranscript(conversationId, transcriptPath, "added");
683
+ }
684
+ }
685
+ if (this.discoveryTickCount % RESUME_SWEEP_EVERY_TICKS === 0) {
686
+ this.reconcileConversationDirs();
687
+ this.sweepForResumedTranscripts();
688
+ }
689
+ } catch (error) {
690
+ this.emitError(error, "Transcript discovery tick failed (non-fatal)");
691
+ }
692
+ }
693
+ /** Bring knownConversationDirs in line with the brain root's listing. */
694
+ reconcileConversationDirs() {
695
+ const present = this.listConversationDirs();
696
+ if (present === null) return;
697
+ const presentSet = new Set(present);
698
+ for (const conversationId of present) {
699
+ if (this.knownConversationDirs.has(conversationId)) continue;
700
+ this.knownConversationDirs.add(conversationId);
701
+ this.onConversationDirAdded(conversationId);
702
+ }
703
+ const tracked = /* @__PURE__ */ new Set([
704
+ ...this.knownConversationDirs,
705
+ ...this.pendingTranscripts.keys(),
706
+ ...this.followed.keys()
707
+ ]);
708
+ for (const conversationId of tracked) {
709
+ if (presentSet.has(conversationId)) continue;
710
+ this.knownConversationDirs.delete(conversationId);
711
+ this.pendingTranscripts.delete(conversationId);
712
+ this.transcriptSnapshots.delete(conversationId);
713
+ this.unfollow(conversationId, "conversation directory removed");
714
+ this.resetCursorForReplacement(
715
+ this.transcriptPathFor(conversationId),
716
+ conversationId,
717
+ "conversation directory removed"
718
+ );
719
+ this.activeConversations.delete(conversationId);
668
720
  }
669
721
  }
670
- onDirAdded(dirPath) {
722
+ onConversationDirAdded(conversationId) {
671
723
  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 });
724
+ logger.info("New brain directory discovered (pre-prompt)", {
725
+ dirPath: path2.join(this.brainRoot, conversationId),
726
+ conversationId
727
+ });
678
728
  this.emit("conversation-discovered", conversationId);
729
+ const transcriptPath = this.transcriptPathFor(conversationId);
730
+ this.transcriptSnapshots.set(conversationId, null);
731
+ if (fs2.existsSync(transcriptPath)) {
732
+ this.followTranscript(conversationId, transcriptPath, "added");
733
+ } else {
734
+ this.pendingTranscripts.set(conversationId, transcriptPath);
735
+ }
736
+ }
737
+ sweepForResumedTranscripts() {
738
+ for (const conversationId of this.knownConversationDirs) {
739
+ if (this.followed.has(conversationId)) continue;
740
+ if (this.pendingTranscripts.has(conversationId)) continue;
741
+ const transcriptPath = this.transcriptPathFor(conversationId);
742
+ const current = this.snapshotTranscript(transcriptPath);
743
+ const previous = this.transcriptSnapshots.get(conversationId) ?? null;
744
+ if (current === null) {
745
+ if (previous !== null && this.isAbsent(transcriptPath)) {
746
+ this.transcriptSnapshots.set(conversationId, null);
747
+ this.resetCursorForReplacement(transcriptPath, conversationId, "deleted while unfollowed");
748
+ }
749
+ continue;
750
+ }
751
+ this.transcriptSnapshots.set(conversationId, current);
752
+ if (previous !== null && current.ino !== previous.ino && !this.fileInodes.has(transcriptPath)) {
753
+ this.resetCursorForReplacement(transcriptPath, conversationId, "inode changed while unfollowed");
754
+ this.emit("transcript-rotated", transcriptPath, conversationId);
755
+ }
756
+ if (previous === null || current.size !== previous.size || current.mtimeMs !== previous.mtimeMs || current.ino !== previous.ino || this.hasUnreadTail(conversationId, transcriptPath, current.size)) {
757
+ this.followTranscript(conversationId, transcriptPath, "resumed");
758
+ }
759
+ }
760
+ }
761
+ /**
762
+ * A conversation released by the follow cap in this lifetime whose file
763
+ * holds bytes past its cursor: its eviction drain did not finish (e.g. a
764
+ * transient stat/open error), so the final message would otherwise wait for
765
+ * the next write (Stage 2 R1-F8). Limited to released conversations — the
766
+ * offsets file also holds other daemons' cursors (F2), and following those
767
+ * on stale offsets would pull another session's history in.
768
+ */
769
+ hasUnreadTail(conversationId, transcriptPath, size) {
770
+ if (!this.releasedConversations.has(conversationId)) return false;
771
+ if (this.activeReads.has(transcriptPath)) return false;
772
+ const entry = this.offsets.get(transcriptPath);
773
+ if (!entry) return false;
774
+ return size > entry.byteOffset + Buffer.byteLength(entry.carryover, "utf-8");
775
+ }
776
+ /**
777
+ * Start following one transcript by stat-polling that file (no `fs.watch`
778
+ * handle), then route it through the same handler a filesystem event would
779
+ * have: `add` for a transcript created after start, `change` for an existing
780
+ * one (first sight of it is treated as a resume).
781
+ */
782
+ followTranscript(conversationId, transcriptPath, how) {
783
+ if (this.isShuttingDown || !this.isWatching) return;
784
+ if (this.followed.has(conversationId)) return;
785
+ let watcher;
786
+ try {
787
+ watcher = watchOneFilePolled(transcriptPath, {
788
+ persistent: true,
789
+ ignoreInitial: true,
790
+ usePolling: true,
791
+ interval: TRANSCRIPT_POLL_INTERVAL_MS,
792
+ awaitWriteFinish: false
793
+ // partial-line carryover handles partial writes
794
+ });
795
+ } catch (error) {
796
+ this.emitError(error, "Could not follow transcript by polling");
797
+ return;
798
+ }
799
+ while (this.followed.size >= MAX_FOLLOWED_TRANSCRIPTS) {
800
+ let oldestId = null;
801
+ let oldestMs = Number.POSITIVE_INFINITY;
802
+ for (const [id, f] of this.followed) {
803
+ if (f.lastActivityMs < oldestMs) {
804
+ oldestMs = f.lastActivityMs;
805
+ oldestId = id;
806
+ }
807
+ }
808
+ if (oldestId === null) break;
809
+ this.unfollow(oldestId, "follow cap reached", { drainTail: true });
810
+ }
811
+ const followed = {
812
+ watcher,
813
+ transcriptPath,
814
+ lastActivityMs: Date.now()
815
+ };
816
+ this.followed.set(conversationId, followed);
817
+ const touch = () => {
818
+ followed.lastActivityMs = Date.now();
819
+ };
820
+ watcher.on("add", (filePath) => {
821
+ touch();
822
+ this.onFileAdded(filePath);
823
+ });
824
+ watcher.on("change", (filePath) => {
825
+ touch();
826
+ this.onFileChanged(filePath);
827
+ });
828
+ watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
829
+ watcher.on("error", (error) => {
830
+ this.emitError(error, "Chokidar error");
831
+ });
832
+ watcher.on("ready", () => {
833
+ const timer = setTimeout(() => {
834
+ if (this.followed.get(conversationId)?.watcher !== watcher) return;
835
+ if (!this.activeConversations.has(conversationId)) return;
836
+ void this.scheduleReadNewLines(transcriptPath, conversationId);
837
+ }, FOLLOW_CATCH_UP_READ_DELAY_MS);
838
+ if (timer.unref) timer.unref();
839
+ });
840
+ logger.info("Following transcript", {
841
+ conversationId,
842
+ how,
843
+ followedCount: this.followed.size,
844
+ pollIntervalMs: TRANSCRIPT_POLL_INTERVAL_MS
845
+ });
846
+ if (how === "added") this.onFileAdded(transcriptPath);
847
+ else this.onFileChanged(transcriptPath);
848
+ }
849
+ /**
850
+ * Stop following one transcript. Its offset is kept, its swept stat is
851
+ * refreshed so the resume sweep only re-follows it on a NEW write, and it
852
+ * leaves activeConversations so a re-follow is a resume.
853
+ *
854
+ * drainTail (eviction): read whatever is past the offset now. Otherwise a
855
+ * write that landed within the last poll interval would sit under the
856
+ * refreshed snapshot, unread until that conversation writes again — for a
857
+ * final message, indefinitely. An evicted conversation also joins
858
+ * releasedConversations, which protects its offset from LRU eviction so a
859
+ * re-follow never replays history. Without drainTail (its directory was
860
+ * removed) the offset is LRU-evictable.
861
+ */
862
+ unfollow(conversationId, reason, opts = {}) {
863
+ const followed = this.followed.get(conversationId);
864
+ if (!followed) return;
865
+ if (this.knownConversationDirs.has(conversationId)) {
866
+ this.transcriptSnapshots.set(conversationId, this.snapshotTranscript(followed.transcriptPath));
867
+ }
868
+ if (opts.drainTail) {
869
+ this.releasedConversations.add(conversationId);
870
+ void this.scheduleReadNewLines(followed.transcriptPath, conversationId);
871
+ } else {
872
+ this.releasedConversations.delete(conversationId);
873
+ }
874
+ this.followed.delete(conversationId);
875
+ this.activeConversations.delete(conversationId);
876
+ followed.watcher.close().catch((error) => {
877
+ this.emitError(error, "Transcript follower close failed (non-fatal)");
878
+ });
879
+ logger.info("Stopped following transcript", { conversationId, reason });
880
+ }
881
+ /**
882
+ * The file at this path is no longer the one the cursor describes (deleted,
883
+ * its directory removed, or replaced by a different inode). Restart it from
884
+ * byte 0, as the former tree watch's `unlink` → onFileUnlinked did. The
885
+ * released-follower offset protection ends with the file it protected —
886
+ * except when the reset is made by the read that is about to consume the
887
+ * new file (`keepProtection`): that read advances the cursor over content it
888
+ * delivers, so the cursor must stay protected or a later re-follow would
889
+ * replay it (Stage 2 R1-F6).
890
+ */
891
+ resetCursorForReplacement(filePath, conversationId, reason, opts = {}) {
892
+ const entry = this.offsets.get(filePath);
893
+ if (entry) {
894
+ logger.warn("Transcript replaced or removed; resetting offset", {
895
+ filePath,
896
+ conversationId,
897
+ reason,
898
+ previousOffset: entry.byteOffset
899
+ });
900
+ entry.byteOffset = 0;
901
+ entry.carryover = "";
902
+ entry.lastKnownSize = 0;
903
+ entry.lastModifiedMs = Date.now();
904
+ }
905
+ this.fileInodes.delete(filePath);
906
+ if (!opts.keepProtection) this.releasedConversations.delete(conversationId);
907
+ this.bumpCursorResets(filePath);
908
+ }
909
+ /** Only a read chain in flight can hold a stale cursor, so only then is the
910
+ * count kept; the chain's end prunes it (Stage 2 R1-F7). Outside resets
911
+ * land at an `await`, by which time the chain is registered in activeReads. */
912
+ bumpCursorResets(filePath) {
913
+ if (!this.activeReads.has(filePath)) return;
914
+ this.cursorResets.set(filePath, (this.cursorResets.get(filePath) ?? 0) + 1);
915
+ }
916
+ transcriptPathFor(conversationId) {
917
+ return path2.join(this.brainRoot, conversationId, SYSTEM_GENERATED_DIR, LOGS_DIR, TRANSCRIPT_BASENAME);
918
+ }
919
+ /** UUID-named directories directly under the brain root; null on failure. */
920
+ listConversationDirs() {
921
+ try {
922
+ return fs2.readdirSync(this.brainRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && CONVERSATION_DIR_REGEX.test(entry.name)).map((entry) => entry.name);
923
+ } catch {
924
+ return null;
925
+ }
926
+ }
927
+ snapshotTranscript(transcriptPath) {
928
+ try {
929
+ const stat = fs2.statSync(transcriptPath);
930
+ return stat.isFile() ? { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino } : null;
931
+ } catch {
932
+ return null;
933
+ }
934
+ }
935
+ /** True only when the path is confirmed absent (ENOENT / ENOTDIR). */
936
+ isAbsent(filePath) {
937
+ try {
938
+ fs2.statSync(filePath);
939
+ return false;
940
+ } catch (error) {
941
+ const code = error.code;
942
+ return code === "ENOENT" || code === "ENOTDIR";
943
+ }
944
+ }
945
+ statMtimeMs(dirPath) {
946
+ try {
947
+ return fs2.statSync(dirPath).mtimeMs;
948
+ } catch {
949
+ return null;
950
+ }
679
951
  }
680
952
  /**
681
953
  * Fires when chokidar reports a transcript.jsonl was deleted or moved
@@ -709,6 +981,9 @@ var TranscriptTailer = class extends import_events.EventEmitter {
709
981
  entry.carryover = "";
710
982
  entry.lastKnownSize = 0;
711
983
  entry.lastModifiedMs = Date.now();
984
+ this.fileInodes.delete(filePath);
985
+ this.releasedConversations.delete(conversationId);
986
+ this.bumpCursorResets(filePath);
712
987
  this.activeConversations.delete(conversationId);
713
988
  }
714
989
  onFileChanged(filePath) {
@@ -728,7 +1003,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
728
1003
  }
729
1004
  // ─── Backfill at startup ────────────────────────────────────────────────
730
1005
  /**
731
- * Walk the brain root once at startup and emit resume events for any
1006
+ * Walk the brain root once at startup and follow (as a resume) any
732
1007
  * transcript.jsonl that already exists and shows recent activity. This
733
1008
  * covers the case where agy is already running when codevibe-agy starts.
734
1009
  */
@@ -740,6 +1015,13 @@ var TranscriptTailer = class extends import_events.EventEmitter {
740
1015
  if (!dir.isDirectory()) continue;
741
1016
  const conversationId = dir.name;
742
1017
  if (!/^[0-9a-fA-F-]{36}$/.test(conversationId)) continue;
1018
+ if (!this.knownConversationDirs.has(conversationId)) {
1019
+ this.knownConversationDirs.add(conversationId);
1020
+ this.transcriptSnapshots.set(
1021
+ conversationId,
1022
+ this.snapshotTranscript(this.transcriptPathFor(conversationId))
1023
+ );
1024
+ }
743
1025
  const dirPath = path2.join(this.brainRoot, conversationId);
744
1026
  const transcriptPath = path2.join(
745
1027
  dirPath,
@@ -777,16 +1059,16 @@ var TranscriptTailer = class extends import_events.EventEmitter {
777
1059
  transcriptPath,
778
1060
  conversationId
779
1061
  });
780
- this.activeConversations.add(conversationId);
781
- this.ensureOffset(transcriptPath);
782
- this.emit("transcript-resume", transcriptPath, conversationId);
783
- void this.scheduleReadNewLines(transcriptPath, conversationId);
1062
+ this.followTranscript(conversationId, transcriptPath, "resumed");
784
1063
  } else {
785
1064
  logger.info("Backfilling recent transcriptless directory at startup", {
786
1065
  dirPath,
787
1066
  conversationId
788
1067
  });
789
1068
  this.emit("conversation-discovered", conversationId);
1069
+ if (!this.followed.has(conversationId)) {
1070
+ this.pendingTranscripts.set(conversationId, transcriptPath);
1071
+ }
790
1072
  }
791
1073
  }
792
1074
  } catch (error) {
@@ -807,12 +1089,23 @@ var TranscriptTailer = class extends import_events.EventEmitter {
807
1089
  conversationId,
808
1090
  error: String(error)
809
1091
  });
1092
+ if (this.admission && this.isWatching && generation === this.readGeneration && !this.retryTimers.has(filePath)) {
1093
+ const timer = setTimeout(() => {
1094
+ this.retryTimers.delete(filePath);
1095
+ if (this.isWatching && generation === this.readGeneration) void this.scheduleReadNewLines(filePath, conversationId);
1096
+ }, 5e3);
1097
+ timer.unref();
1098
+ this.retryTimers.set(filePath, timer);
1099
+ }
810
1100
  });
811
1101
  this.activeReads.set(filePath, read);
1102
+ this.ownedReads.add(read);
812
1103
  void read.finally(() => {
1104
+ this.ownedReads.delete(read);
813
1105
  if (this.activeReads.get(filePath) === read) {
814
1106
  this.activeReads.delete(filePath);
815
1107
  this.pendingReads.delete(filePath);
1108
+ this.cursorResets.delete(filePath);
816
1109
  }
817
1110
  });
818
1111
  return read;
@@ -830,6 +1123,23 @@ var TranscriptTailer = class extends import_events.EventEmitter {
830
1123
  logger.warn("No offset entry \u2014 skipping read", { filePath });
831
1124
  return;
832
1125
  }
1126
+ if (this.admission) {
1127
+ const result = await readAdmittedTranscript({
1128
+ filePath,
1129
+ conversationId,
1130
+ initialOffset: entry.byteOffset,
1131
+ admission: this.admission,
1132
+ isCurrent: () => generation === this.readGeneration && !this.isShuttingDown
1133
+ });
1134
+ if (result) {
1135
+ if (generation !== this.readGeneration) return;
1136
+ entry.byteOffset = result.position.byteOffset;
1137
+ entry.carryover = "";
1138
+ entry.lastKnownSize = result.size;
1139
+ entry.lastModifiedMs = result.mtimeMs;
1140
+ return;
1141
+ }
1142
+ }
833
1143
  let stat;
834
1144
  try {
835
1145
  stat = fs2.statSync(filePath);
@@ -837,6 +1147,12 @@ var TranscriptTailer = class extends import_events.EventEmitter {
837
1147
  logger.warn("stat failed; file may have been deleted", { filePath, error });
838
1148
  return;
839
1149
  }
1150
+ const lastInode = this.fileInodes.get(filePath);
1151
+ if (lastInode !== void 0 && lastInode !== stat.ino) {
1152
+ this.resetCursorForReplacement(filePath, conversationId, "inode changed", { keepProtection: true });
1153
+ this.emit("transcript-rotated", filePath, conversationId);
1154
+ }
1155
+ this.fileInodes.set(filePath, stat.ino);
840
1156
  if (stat.size < entry.lastKnownSize) {
841
1157
  logger.warn("Transcript file rotated (size shrunk); resetting offset", {
842
1158
  filePath,
@@ -849,6 +1165,12 @@ var TranscriptTailer = class extends import_events.EventEmitter {
849
1165
  entry.lastKnownSize = stat.size;
850
1166
  this.emit("transcript-rotated", filePath, conversationId);
851
1167
  }
1168
+ const resetsAtStart = this.cursorResets.get(filePath) ?? 0;
1169
+ const staleRead = () => {
1170
+ if ((this.cursorResets.get(filePath) ?? 0) === resetsAtStart) return false;
1171
+ this.pendingReads.add(filePath);
1172
+ return true;
1173
+ };
852
1174
  const carryoverBytes = Buffer.byteLength(entry.carryover, "utf-8");
853
1175
  const readStart = entry.byteOffset + carryoverBytes;
854
1176
  if (stat.size <= readStart) {
@@ -871,14 +1193,14 @@ var TranscriptTailer = class extends import_events.EventEmitter {
871
1193
  let lineStartOffset = entry.byteOffset;
872
1194
  try {
873
1195
  for await (const chunk of stream) {
874
- if (generation !== this.readGeneration) {
1196
+ if (generation !== this.readGeneration || staleRead()) {
875
1197
  stream.destroy();
876
1198
  return;
877
1199
  }
878
1200
  buffered += chunk;
879
1201
  let newlineIndex = buffered.indexOf("\n");
880
1202
  while (newlineIndex !== -1) {
881
- if (generation !== this.readGeneration) {
1203
+ if (generation !== this.readGeneration || staleRead()) {
882
1204
  stream.destroy();
883
1205
  return;
884
1206
  }
@@ -900,7 +1222,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
900
1222
  this.emitError(error);
901
1223
  return;
902
1224
  }
903
- if (generation !== this.readGeneration) return;
1225
+ if (generation !== this.readGeneration || staleRead()) return;
904
1226
  entry.byteOffset = lineStartOffset;
905
1227
  entry.carryover = buffered;
906
1228
  entry.lastKnownSize = stat.size;
@@ -964,7 +1286,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
964
1286
  let oldestMtime = Number.POSITIVE_INFINITY;
965
1287
  for (const [p, entry] of this.offsets.entries()) {
966
1288
  const cid = this.extractConversationId(p);
967
- if (cid && this.activeConversations.has(cid)) {
1289
+ if (cid && (this.activeConversations.has(cid) || this.releasedConversations.has(cid))) {
968
1290
  continue;
969
1291
  }
970
1292
  if (entry.lastModifiedMs < oldestMtime) {
@@ -1962,8 +2284,8 @@ function escapeShellArg(value) {
1962
2284
  return value.replace(/'/g, `'\\''`);
1963
2285
  }
1964
2286
  function sleep(ms) {
1965
- return new Promise((resolve3) => {
1966
- const t = setTimeout(resolve3, ms);
2287
+ return new Promise((resolve2) => {
2288
+ const t = setTimeout(resolve2, ms);
1967
2289
  if (t.unref) t.unref();
1968
2290
  });
1969
2291
  }
@@ -2894,7 +3216,7 @@ var PromptResponder = class {
2894
3216
  };
2895
3217
  }
2896
3218
  }
2897
- async sendFreeFormWhenNoApprovalActive(text) {
3219
+ async sendFreeFormWhenNoApprovalActive(text, beforeType) {
2898
3220
  const target = this.resolveTmuxTarget();
2899
3221
  if (!target) return { ok: false, reason: "no-tmux-target" };
2900
3222
  if (!this.paneObserver) {
@@ -2914,9 +3236,20 @@ var PromptResponder = class {
2914
3236
  }
2915
3237
  return null;
2916
3238
  };
3239
+ let preparationFailed = false;
2917
3240
  try {
2918
3241
  const beforeText = await chooserIsAbsent();
2919
3242
  if (beforeText) return beforeText;
3243
+ if (beforeType) {
3244
+ try {
3245
+ await beforeType();
3246
+ } catch (error) {
3247
+ preparationFailed = true;
3248
+ throw error;
3249
+ }
3250
+ const afterPreparation = await chooserIsAbsent();
3251
+ if (afterPreparation) return afterPreparation;
3252
+ }
2920
3253
  await this.typeLiteral(target, text);
2921
3254
  await delay(ENTER_DELAY_MS);
2922
3255
  const beforeEnter = await chooserIsAbsent();
@@ -2924,6 +3257,7 @@ var PromptResponder = class {
2924
3257
  await this.sendKey(target, "Enter");
2925
3258
  return { ok: true };
2926
3259
  } catch (error) {
3260
+ if (preparationFailed) throw error;
2927
3261
  return {
2928
3262
  ok: false,
2929
3263
  reason: "tmux-failed",
@@ -3056,6 +3390,16 @@ var MobilePromptDeduper = class {
3056
3390
  this.replaceEntries(sessionId, entries);
3057
3391
  return match;
3058
3392
  }
3393
+ /** Inspect without spending the token before durable native admission. */
3394
+ peekIfDuplicate(sessionId, echo) {
3395
+ const normalized = this.normalize(echo);
3396
+ if (!normalized) return null;
3397
+ for (const entry of this.validEntries(sessionId)) {
3398
+ const matchType = this.matchType(entry.normalized, normalized);
3399
+ if (matchType) return { matchType, originalLength: entry.normalized.length, echoLength: normalized.length };
3400
+ }
3401
+ return null;
3402
+ }
3059
3403
  // ─── Internals ──────────────────────────────────────────────────────────
3060
3404
  sweep() {
3061
3405
  const cutoff = this.now() - this.expiryMs;
@@ -3135,14 +3479,14 @@ function sanitizeErrorMessage(msg) {
3135
3479
  return out.trim().substring(0, 100);
3136
3480
  }
3137
3481
  function fireDaemonBeacon(name, params) {
3138
- return new Promise((resolve3) => {
3482
+ return new Promise((resolve2) => {
3139
3483
  let req;
3140
3484
  let settled = false;
3141
3485
  const settle = () => {
3142
3486
  if (settled) return;
3143
3487
  settled = true;
3144
3488
  clearTimeout(overall);
3145
- resolve3();
3489
+ resolve2();
3146
3490
  };
3147
3491
  const overall = setTimeout(() => {
3148
3492
  try {
@@ -3212,7 +3556,7 @@ function fireDaemonBeacon(name, params) {
3212
3556
 
3213
3557
  // src/event-mapper.ts
3214
3558
  var import_uuid2 = require("uuid");
3215
- var import_codevibe_core3 = require("@quantiya/codevibe-core");
3559
+ var import_codevibe_core4 = require("@quantiya/codevibe-core");
3216
3560
  var CONTENT_MAX = 150 * 1024;
3217
3561
  var TOOL_OUTPUT_MAX = 50 * 1024;
3218
3562
  var TOOL_DIFF_MAX = 100 * 1024;
@@ -3220,10 +3564,10 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
3220
3564
  const { event } = emit;
3221
3565
  const base = {
3222
3566
  sessionId,
3223
- source: import_codevibe_core3.EventSource.DESKTOP
3567
+ source: import_codevibe_core4.EventSource.DESKTOP
3224
3568
  };
3225
3569
  const orderingKey = `${sessionId}:${emit.conversationId}`;
3226
- const nextTimestamp = () => (0, import_codevibe_core3.prepareEventTimestamp)({ orderingKey, agentClock: event.created_at, notBeforeMs });
3570
+ const nextTimestamp = () => (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey, agentClock: event.created_at, notBeforeMs });
3227
3571
  const baseMetadata = {
3228
3572
  step_index: event.step_index,
3229
3573
  agy_source: event.source,
@@ -3263,7 +3607,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
3263
3607
  {
3264
3608
  ...base,
3265
3609
  timestamp: nextTimestamp(),
3266
- type: import_codevibe_core3.EventType.USER_PROMPT,
3610
+ type: import_codevibe_core4.EventType.USER_PROMPT,
3267
3611
  content: truncate(cleaned, CONTENT_MAX),
3268
3612
  metadata: { ...baseMetadata }
3269
3613
  }
@@ -3278,7 +3622,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
3278
3622
  events.push({
3279
3623
  ...base,
3280
3624
  timestamp: nextTimestamp(),
3281
- type: import_codevibe_core3.EventType.REASONING,
3625
+ type: import_codevibe_core4.EventType.REASONING,
3282
3626
  content: truncate(event.thinking, CONTENT_MAX),
3283
3627
  metadata: { ...baseMetadata }
3284
3628
  });
@@ -3287,7 +3631,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
3287
3631
  events.push({
3288
3632
  ...base,
3289
3633
  timestamp: nextTimestamp(),
3290
- type: import_codevibe_core3.EventType.ASSISTANT_RESPONSE,
3634
+ type: import_codevibe_core4.EventType.ASSISTANT_RESPONSE,
3291
3635
  content: truncate(event.content, CONTENT_MAX),
3292
3636
  metadata: { ...baseMetadata }
3293
3637
  });
@@ -3324,7 +3668,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
3324
3668
  {
3325
3669
  ...base,
3326
3670
  timestamp: nextTimestamp(),
3327
- type: import_codevibe_core3.EventType.NOTIFICATION,
3671
+ type: import_codevibe_core4.EventType.NOTIFICATION,
3328
3672
  content: truncate(content, CONTENT_MAX),
3329
3673
  metadata: {
3330
3674
  ...baseMetadata,
@@ -3344,7 +3688,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
3344
3688
  {
3345
3689
  ...base,
3346
3690
  timestamp: nextTimestamp(),
3347
- type: import_codevibe_core3.EventType.TOOL_USE,
3691
+ type: import_codevibe_core4.EventType.TOOL_USE,
3348
3692
  content: formatToolResultContent(event.type, toolName, truncatedContent, isErrorStatus),
3349
3693
  metadata: {
3350
3694
  ...baseMetadata,
@@ -3502,7 +3846,7 @@ function truncate(s, maxBytes) {
3502
3846
  }
3503
3847
 
3504
3848
  // src/tool-activity-integration.ts
3505
- var import_codevibe_core4 = require("@quantiya/codevibe-core");
3849
+ var import_codevibe_core5 = require("@quantiya/codevibe-core");
3506
3850
  var TOOL_ACTIVITY_CONTENT = "tool_activity";
3507
3851
  var DEFAULT_WINDOW_MS = 1e4;
3508
3852
  var DEFAULT_MAX_OCCURRENCES = 50;
@@ -3569,9 +3913,9 @@ function agyOccurrenceId(emit, matchedCall) {
3569
3913
  createdAt: emit.event.created_at,
3570
3914
  stepIndex: emit.event.step_index,
3571
3915
  type: emit.event.type,
3572
- contentDigest: (0, import_codevibe_core4.digestOf)(emit.event.content ?? "")
3916
+ contentDigest: (0, import_codevibe_core5.digestOf)(emit.event.content ?? "")
3573
3917
  };
3574
- return `agy:${(0, import_codevibe_core4.digestOf)(identity).slice("sha256:".length)}`;
3918
+ return `agy:${(0, import_codevibe_core5.digestOf)(identity).slice("sha256:".length)}`;
3575
3919
  }
3576
3920
  var AgyToolActivityIntegration = class {
3577
3921
  constructor(deps) {
@@ -3580,31 +3924,34 @@ var AgyToolActivityIntegration = class {
3580
3924
  this.sessionsToRehome = /* @__PURE__ */ new Set();
3581
3925
  this.deps = deps;
3582
3926
  }
3583
- isActive() {
3584
- return this.consolidator !== null;
3927
+ isActive(sessionId) {
3928
+ return this.consolidator !== null && (sessionId === void 0 || this.backendSessionId === sessionId);
3585
3929
  }
3586
3930
  async start(sessionId) {
3587
3931
  if (this.consolidator && this.backendSessionId === sessionId) return;
3588
3932
  if (this.consolidator) await this.stop();
3589
- const outbox = new import_codevibe_core4.ToolActivityOutbox({
3933
+ const outbox = new import_codevibe_core5.ToolActivityOutbox({
3590
3934
  agent: "agy",
3591
3935
  logger: this.deps.logger,
3592
3936
  root: this.deps.root
3593
3937
  });
3594
- await this.rehomePendingWindows(outbox, sessionId);
3595
- const ledger = new import_codevibe_core4.ToolActivityLedger({
3938
+ const ledger = new import_codevibe_core5.ToolActivityLedger({
3596
3939
  agent: "agy",
3597
3940
  sessionId,
3598
3941
  logger: this.deps.logger,
3599
3942
  root: this.deps.root
3600
3943
  });
3601
- const consolidator = new import_codevibe_core4.ToolActivityConsolidator({
3944
+ await this.deps.recoverPending?.(sessionId, outbox, ledger);
3945
+ const legacyTransport = this.buildTransport();
3946
+ const transport = this.deps.selectTransport ? await this.deps.selectTransport(sessionId, legacyTransport) : legacyTransport;
3947
+ if (transport.protocol !== "SYNC_V2") await this.rehomePendingWindows(outbox, sessionId);
3948
+ const consolidator = new import_codevibe_core5.ToolActivityConsolidator({
3602
3949
  agent: "agy",
3603
3950
  sessionId,
3604
3951
  logger: this.deps.logger,
3605
3952
  outbox,
3606
3953
  ledger,
3607
- transport: this.buildTransport(),
3954
+ transport,
3608
3955
  windowMs: this.deps.windowMs ?? DEFAULT_WINDOW_MS,
3609
3956
  maxOccurrences: DEFAULT_MAX_OCCURRENCES
3610
3957
  });
@@ -3636,7 +3983,7 @@ var AgyToolActivityIntegration = class {
3636
3983
  ...target ? { normalizedTarget: target } : {},
3637
3984
  ts: emit.event.created_at,
3638
3985
  byteOffset: emit.byteOffset,
3639
- ...inputIdentity ? { digest: (0, import_codevibe_core4.digestOf)(inputIdentity) } : {}
3986
+ ...inputIdentity ? { digest: (0, import_codevibe_core5.digestOf)(inputIdentity) } : {}
3640
3987
  });
3641
3988
  }
3642
3989
  async flush() {
@@ -3686,7 +4033,8 @@ var AgyToolActivityIntegration = class {
3686
4033
  }
3687
4034
  }
3688
4035
  if (!eligible) continue;
3689
- for (const envelope of pending.valid) {
4036
+ const legacyWindows = pending.valid.filter((envelope) => envelope.syncSessionGenerationId === void 0);
4037
+ for (const envelope of legacyWindows) {
3690
4038
  const rehomed = { ...envelope, backendSessionId: replacementSessionId };
3691
4039
  await outbox.persist(replacementSessionId, rehomed);
3692
4040
  await outbox.prune(oldSessionId, envelope.clientEventId);
@@ -3695,7 +4043,7 @@ var AgyToolActivityIntegration = class {
3695
4043
  this.deps.logger.info("[tool-activity] Rehomed durable Agy windows", {
3696
4044
  oldSessionId,
3697
4045
  replacementSessionId,
3698
- count: pending.valid.length
4046
+ count: legacyWindows.length
3699
4047
  });
3700
4048
  }
3701
4049
  }
@@ -3705,7 +4053,7 @@ var AgyToolActivityIntegration = class {
3705
4053
  if (!session || session.sessionId !== sessionId) {
3706
4054
  throw terminalSessionError(sessionId);
3707
4055
  }
3708
- const encrypted = (0, import_codevibe_core4.encryptForEmit)(
4056
+ const encrypted = (0, import_codevibe_core5.encryptForEmit)(
3709
4057
  override?.sessionKey ?? session.sessionKey ?? null,
3710
4058
  session.sessionIsEncrypted ?? false,
3711
4059
  TOOL_ACTIVITY_CONTENT,
@@ -3717,8 +4065,8 @@ var AgyToolActivityIntegration = class {
3717
4065
  const sessionKeyGen = override?.sessionKeyGen ?? session.sessionKeyGen;
3718
4066
  const input = {
3719
4067
  sessionId,
3720
- type: import_codevibe_core4.EventType.TOOL_ACTIVITY,
3721
- source: import_codevibe_core4.EventSource.DESKTOP,
4068
+ type: import_codevibe_core5.EventType.TOOL_ACTIVITY,
4069
+ source: import_codevibe_core5.EventSource.DESKTOP,
3722
4070
  content: encrypted.content,
3723
4071
  metadata: encrypted.metadata,
3724
4072
  timestamp: envelope.windowStart,
@@ -3733,10 +4081,10 @@ var AgyToolActivityIntegration = class {
3733
4081
  try {
3734
4082
  await sendOnce(sessionId, envelope);
3735
4083
  } catch (error) {
3736
- if ((0, import_codevibe_core4.isTerminalSessionError)(error)) {
4084
+ if ((0, import_codevibe_core5.isTerminalSessionError)(error)) {
3737
4085
  throw new Error(`[tool-activity] Agy session ${sessionId} retired; awaiting replacement`);
3738
4086
  }
3739
- if ((0, import_codevibe_core4.isSessionKeyStaleError)(error) && this.deps.refreshSessionKey) {
4087
+ if ((0, import_codevibe_core5.isSessionKeyStaleError)(error) && this.deps.refreshSessionKey) {
3740
4088
  const refreshed = await this.deps.refreshSessionKey(sessionId);
3741
4089
  if (refreshed) {
3742
4090
  await sendOnce(sessionId, envelope, refreshed);
@@ -3798,8 +4146,12 @@ var McpServer = class _McpServer {
3798
4146
  * under this single sessionId. /resume does NOT create a new
3799
4147
  * session — same row. */
3800
4148
  this.session = null;
3801
- this.promptPublications = new import_codevibe_core5.PromptPublicationTracker();
4149
+ this.promptPublications = new import_codevibe_core6.PromptPublicationTracker();
3802
4150
  this.transcriptHandlers = /* @__PURE__ */ new Set();
4151
+ this.nativeSourceSelection = null;
4152
+ this.nativeSources = /* @__PURE__ */ new Map();
4153
+ this.nativeToolStart = null;
4154
+ this.nativeAdmissionChain = Promise.resolve();
3803
4155
  this.toolActivityKeyRefresh = null;
3804
4156
  // E1 (§4/§6a-4) — per-promptId durable raise entries for the agy producer.
3805
4157
  // Keyed by the E1 promptId (= derive(ownerToken)); each holds the SECRET
@@ -3952,9 +4304,23 @@ var McpServer = class _McpServer {
3952
4304
  this.wrapperPid = options.wrapperPid ?? null;
3953
4305
  this.cliLogPath = options.cliLogPath ?? null;
3954
4306
  this.launchSettleTimeoutMs = options.launchSettleTimeoutMs ?? LAUNCH_SETTLE_TIMEOUT_MS;
3955
- this.appSyncClient = options.appSyncClient ?? new import_codevibe_core5.AppSyncClient();
4307
+ this.appSyncClient = options.appSyncClient ?? new import_codevibe_core6.AppSyncClient();
3956
4308
  this.toolActivity = options.toolActivityIntegration === void 0 ? new AgyToolActivityIntegration({
3957
4309
  logger,
4310
+ selectTransport: async (sessionId, legacyTransport) => {
4311
+ const session = this.session;
4312
+ if (!session || session.sessionId !== sessionId) throw Error("SYNC_SOURCE_OWNER_CHANGED");
4313
+ const selection = await this.selectNativeSource(session);
4314
+ if (this.session !== session || !this.started) throw Error("SYNC_SOURCE_OWNER_CHANGED");
4315
+ return selection.protocol === "LEGACY" ? legacyTransport : this.appSyncClient.toolActivitySyncTransport({
4316
+ sessionScope: selection.sessionScope,
4317
+ getEncryptionContext: selection.getEncryptionContext
4318
+ });
4319
+ },
4320
+ recoverPending: async (sessionId, outbox, ledger) => {
4321
+ await this.appSyncClient.recoverCompanionSyncSources({ sessionId });
4322
+ await this.appSyncClient.recoverToolActivityWindows({ sessionId, outbox, ledger });
4323
+ },
3958
4324
  createEvent: (input) => this.appSyncClient.createEvent(input),
3959
4325
  getSession: () => this.session,
3960
4326
  refreshSessionKey: (sessionId) => this.refreshToolActivitySessionKey(sessionId),
@@ -3970,7 +4336,11 @@ var McpServer = class _McpServer {
3970
4336
  this.workingDirectory = process.cwd();
3971
4337
  this.approvalDetector = options.approvalDetector ?? new ApprovalDetector(options.detectorOptions);
3972
4338
  this.paneObserver = options.paneObserver ?? new TmuxPaneObserver();
3973
- this.transcriptTailer = options.transcriptTailer ?? new TranscriptTailer();
4339
+ this.transcriptTailer = options.transcriptTailer ?? new TranscriptTailer({ admission: {
4340
+ restore: (source, initial) => this.restoreNativeSource(source, initial),
4341
+ admit: (emit, source) => this.admitNativeSource(emit, source),
4342
+ skip: (gap, source) => this.skipNativeSource(gap, source)
4343
+ } });
3974
4344
  this.mobileDeduper = options.mobileDeduper ?? new MobilePromptDeduper();
3975
4345
  this.promptResponder = options.promptResponder ?? new PromptResponder({
3976
4346
  tmuxTarget: this.tmuxTarget ?? void 0,
@@ -4005,7 +4375,7 @@ var McpServer = class _McpServer {
4005
4375
  const authed = await this.appSyncClient.authenticateWithStoredTokens();
4006
4376
  if (!authed) {
4007
4377
  const kind = this.appSyncClient.getLastAuthFailureKind();
4008
- const failure = (0, import_codevibe_core5.describeStoredAuthFailure)(
4378
+ const failure = (0, import_codevibe_core6.describeStoredAuthFailure)(
4009
4379
  kind,
4010
4380
  this.appSyncClient.getLastAuthFailureDetail()
4011
4381
  );
@@ -4024,9 +4394,32 @@ var McpServer = class _McpServer {
4024
4394
  this.stopRequestedDuringCreate = false;
4025
4395
  this.registerSignalHandlers();
4026
4396
  this.startTmuxLifecycleMonitor();
4027
- await (0, import_codevibe_core5.registerDeviceEncryptionKey)(this.appSyncClient, logger);
4397
+ try {
4398
+ const statusUserId = this.appSyncClient.getCurrentUserId();
4399
+ const signal = AbortSignal.timeout(5e3);
4400
+ const timeout = new Promise((_, reject) => signal.addEventListener(
4401
+ "abort",
4402
+ () => reject(new Error("status read timed out")),
4403
+ { once: true }
4404
+ ));
4405
+ const subscription = await Promise.race([this.appSyncClient.getSubscriptionStatus(signal), timeout]);
4406
+ if (this.appSyncClient.getCurrentUserId() !== statusUserId) throw new Error("account changed during read");
4407
+ const sync = await Promise.race([this.appSyncClient.getCompanionSyncState(signal), timeout]);
4408
+ if (this.appSyncClient.getCurrentUserId() !== statusUserId) throw new Error("account changed during read");
4409
+ if (subscription.tier !== sync.tier) throw new Error("tier changed during read");
4410
+ logger.info("Standalone Companion sync status", {
4411
+ tier: sync.tier,
4412
+ protocol: sync.protocol,
4413
+ syncState: sync.usage?.syncState ?? null,
4414
+ resetAtEpochMs: sync.resetAtEpochMs
4415
+ });
4416
+ } catch {
4417
+ logger.warn("Standalone Companion sync status unavailable; cloud admission remains authoritative");
4418
+ }
4028
4419
  if (!this.started) return { httpPort: 0 };
4029
- (0, import_codevibe_core5.startDeviceKeyWatcher)(this.appSyncClient, logger);
4420
+ await (0, import_codevibe_core6.registerDeviceEncryptionKey)(this.appSyncClient, logger);
4421
+ if (!this.started) return { httpPort: 0 };
4422
+ (0, import_codevibe_core6.startDeviceKeyWatcher)(this.appSyncClient, logger);
4030
4423
  try {
4031
4424
  const swept = await this.appSyncClient.sweepOrphanSessions({
4032
4425
  agentType: "ANTIGRAVITY",
@@ -4126,8 +4519,8 @@ var McpServer = class _McpServer {
4126
4519
  let hostedRetirementError = null;
4127
4520
  if (launchSettlement) {
4128
4521
  let timer;
4129
- const timeout = new Promise((resolve3) => {
4130
- timer = setTimeout(resolve3, this.launchSettleTimeoutMs);
4522
+ const timeout = new Promise((resolve2) => {
4523
+ timer = setTimeout(resolve2, this.launchSettleTimeoutMs);
4131
4524
  timer.unref?.();
4132
4525
  });
4133
4526
  try {
@@ -4166,6 +4559,9 @@ var McpServer = class _McpServer {
4166
4559
  }
4167
4560
  this.listenerHandles = [];
4168
4561
  await Promise.allSettled([...this.transcriptHandlers]);
4562
+ await this.transcriptTailer.waitForReads();
4563
+ await this.nativeAdmissionChain;
4564
+ await this.stopNativeSources();
4169
4565
  if (this.session) {
4170
4566
  try {
4171
4567
  this.appSyncClient.stopHeartbeat(this.session.sessionId);
@@ -4176,7 +4572,7 @@ var McpServer = class _McpServer {
4176
4572
  } catch (err) {
4177
4573
  logger.warn("[tool-activity] Agy shutdown drain failed", { error: String(err) });
4178
4574
  }
4179
- const retired = await this.deactivateLaunchRow(this.session.sessionId);
4575
+ const retired = await this.deactivateLaunchRow(this.session.sessionId, this.session);
4180
4576
  if (retired) this.durableObligationOpen = false;
4181
4577
  if (!retired) {
4182
4578
  hostedRetirementError = new Error(
@@ -4258,7 +4654,7 @@ var McpServer = class _McpServer {
4258
4654
  * their settlement marker/obligation alive on `false` instead of treating the
4259
4655
  * attempt as completion. The log stays inside so no failure is ever silent.
4260
4656
  */
4261
- async deactivateLaunchRow(sessionId) {
4657
+ async deactivateLaunchRow(sessionId, quiescentSession) {
4262
4658
  try {
4263
4659
  this.appSyncClient.stopHeartbeat(sessionId);
4264
4660
  } catch (error) {
@@ -4269,7 +4665,14 @@ var McpServer = class _McpServer {
4269
4665
  }
4270
4666
  for (let attempt = 1; attempt <= SESSION_RETIRE_MAX_ATTEMPTS; attempt += 1) {
4271
4667
  try {
4272
- await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
4668
+ const retired = await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
4669
+ if (quiescentSession && retired?.sessionGenerationId) {
4670
+ await this.appSyncClient.completeDesktopSyncRetirement({ sessionId }, retired, async () => {
4671
+ await Promise.allSettled([...this.transcriptHandlers]);
4672
+ await this.nativeAdmissionChain;
4673
+ await this.stopNativeSources(quiescentSession);
4674
+ });
4675
+ }
4273
4676
  logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
4274
4677
  return true;
4275
4678
  } catch (err) {
@@ -4280,7 +4683,7 @@ var McpServer = class _McpServer {
4280
4683
  error: String(err)
4281
4684
  });
4282
4685
  if (attempt < SESSION_RETIRE_MAX_ATTEMPTS) {
4283
- await new Promise((resolve3) => setTimeout(resolve3, SESSION_RETIRE_RETRY_MS * attempt));
4686
+ await new Promise((resolve2) => setTimeout(resolve2, SESSION_RETIRE_RETRY_MS * attempt));
4284
4687
  }
4285
4688
  }
4286
4689
  }
@@ -4313,7 +4716,7 @@ var McpServer = class _McpServer {
4313
4716
  let sessionKey = null;
4314
4717
  let sessionKeyGen = null;
4315
4718
  try {
4316
- const result = await (0, import_codevibe_core5.resumeOrCreateSession)(
4719
+ const result = await (0, import_codevibe_core6.resumeOrCreateSession)(
4317
4720
  {
4318
4721
  sessionId,
4319
4722
  userId,
@@ -4399,8 +4802,9 @@ var McpServer = class _McpServer {
4399
4802
  const stop = this.appSyncClient.subscribeToEvents(
4400
4803
  sessionId,
4401
4804
  (evt) => {
4402
- void this.handleMobileEvent(evt).catch((err) => {
4805
+ return this.handleMobileEvent(evt).catch((err) => {
4403
4806
  logger.error("handleMobileEvent failed", { error: String(err) });
4807
+ if ((0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) throw err;
4404
4808
  });
4405
4809
  },
4406
4810
  (err) => logger.warn("AppSync subscription error", { error: String(err) }),
@@ -4482,7 +4886,7 @@ var McpServer = class _McpServer {
4482
4886
  return activeId === conversationId;
4483
4887
  }
4484
4888
  // ─── Transcript flow ────────────────────────────────────────────────────
4485
- async handleTranscriptEmit(emit) {
4889
+ async handleTranscriptEmit(emit, admission) {
4486
4890
  if (!this.started) return;
4487
4891
  const { conversationId, byteOffset, event } = emit;
4488
4892
  if (this.sessionDisabled) {
@@ -4508,14 +4912,18 @@ var McpServer = class _McpServer {
4508
4912
  }
4509
4913
  }
4510
4914
  const mapped = mapTranscriptEvent(emit, session.sessionId, notBeforeMs);
4915
+ if (admission && (mapped.events.length > 2 || mapped.events.some((input) => !["USER_PROMPT", "ASSISTANT_RESPONSE", "REASONING", "TOOL_USE", "NOTIFICATION"].includes(input.type)))) {
4916
+ throw Error("SYNC_NATIVE_SPECIALIZED_PRODUCER_REQUIRED");
4917
+ }
4511
4918
  for (const reg of mapped.pendingCallRegistrations) {
4512
4919
  this.approvalDetector.registerPendingCall(reg);
4513
4920
  }
4514
- for (const input of mapped.events) {
4515
- if (input.type === import_codevibe_core5.EventType.USER_PROMPT || input.type === import_codevibe_core5.EventType.ASSISTANT_RESPONSE || input.type === import_codevibe_core5.EventType.NOTIFICATION) {
4516
- await this.flushToolActivityAtBoundary(input.type);
4921
+ for (const [index, input] of mapped.events.entries()) {
4922
+ if (input.type === import_codevibe_core6.EventType.USER_PROMPT || input.type === import_codevibe_core6.EventType.ASSISTANT_RESPONSE || input.type === import_codevibe_core6.EventType.NOTIFICATION) {
4923
+ if (admission) await this.toolActivity.flush();
4924
+ else await this.flushToolActivityAtBoundary(input.type);
4517
4925
  }
4518
- if (input.type === import_codevibe_core5.EventType.TOOL_USE && typeof input.metadata === "object" && input.metadata) {
4926
+ if (input.type === import_codevibe_core6.EventType.TOOL_USE && typeof input.metadata === "object" && input.metadata) {
4519
4927
  const meta = input.metadata;
4520
4928
  const resultStep = typeof meta.step_index === "number" ? meta.step_index : void 0;
4521
4929
  const agyType = typeof meta.agy_type === "string" ? meta.agy_type : void 0;
@@ -4532,6 +4940,7 @@ var McpServer = class _McpServer {
4532
4940
  continue;
4533
4941
  }
4534
4942
  } catch (error) {
4943
+ if (admission) throw error;
4535
4944
  logger.warn("[tool-activity] Agy result observation failed; emitting raw TOOL_USE fallback", {
4536
4945
  sessionId: session.sessionId,
4537
4946
  conversationId,
@@ -4541,11 +4950,12 @@ var McpServer = class _McpServer {
4541
4950
  }
4542
4951
  }
4543
4952
  }
4544
- if (input.type === import_codevibe_core5.EventType.USER_PROMPT && input.source === import_codevibe_core5.EventSource.DESKTOP) {
4953
+ if (input.type === import_codevibe_core6.EventType.USER_PROMPT && input.source === import_codevibe_core6.EventSource.DESKTOP) {
4545
4954
  const clearedPromptIds = this.approvalDetector.onPanePromptCleared();
4546
4955
  await this.resolveAcceptedInputPrompts(session, clearedPromptIds);
4547
- const dedupe = this.mobileDeduper.consumeIfDuplicate(session.sessionId, input.content);
4956
+ const dedupe = admission ? null : this.mobileDeduper.consumeIfDuplicate(session.sessionId, input.content);
4548
4957
  if (dedupe) {
4958
+ if (admission) admission.echoContent = input.content;
4549
4959
  logger.info("Suppressed desktop echo of mobile prompt", {
4550
4960
  sessionId: session.sessionId,
4551
4961
  matchType: dedupe.matchType
@@ -4553,6 +4963,10 @@ var McpServer = class _McpServer {
4553
4963
  continue;
4554
4964
  }
4555
4965
  }
4966
+ if (admission) {
4967
+ admission.outputs[index] = input;
4968
+ continue;
4969
+ }
4556
4970
  try {
4557
4971
  if (!await this.createEncryptedEvent(session, input)) continue;
4558
4972
  } catch (err) {
@@ -4564,6 +4978,161 @@ var McpServer = class _McpServer {
4564
4978
  }
4565
4979
  }
4566
4980
  }
4981
+ selectNativeSource(session) {
4982
+ if (!this.started || this.session !== session) return Promise.reject(Error("SYNC_SOURCE_OWNER_CHANGED"));
4983
+ if (this.nativeSourceSelection?.session === session) return this.nativeSourceSelection.pending;
4984
+ const pending = this.appSyncClient.selectCompanionSourceTransport({ sessionId: session.sessionId });
4985
+ const selected = { session, pending };
4986
+ this.nativeSourceSelection = selected;
4987
+ void pending.catch(() => {
4988
+ if (this.nativeSourceSelection === selected) this.nativeSourceSelection = null;
4989
+ });
4990
+ return pending;
4991
+ }
4992
+ assertNativeOwner(owner) {
4993
+ if (!this.started || this.sessionDisabled || this.session !== owner.session || owner.controller.signal.aborted) {
4994
+ throw Error("SYNC_SOURCE_OWNER_CHANGED");
4995
+ }
4996
+ }
4997
+ async nativeOwner(source) {
4998
+ if (!this.started || this.sessionDisabled) throw Error("SYNC_SOURCE_STOPPED");
4999
+ await this.ensureLaunchSession();
5000
+ const session = this.session;
5001
+ if (!session || !this.isMainConversation(source.conversationId)) throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
5002
+ const selection = await this.selectNativeSource(session);
5003
+ if (!this.started || this.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
5004
+ if (selection.protocol === "LEGACY") return null;
5005
+ if (!this.toolActivity?.isActive(session.sessionId)) {
5006
+ if (this.nativeToolStart && this.nativeToolStart.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
5007
+ if (!this.nativeToolStart) {
5008
+ const start = this.toolActivity ? this.toolActivity.start(session.sessionId) : Promise.reject(Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE"));
5009
+ const attempt = { session, pending: start };
5010
+ this.nativeToolStart = attempt;
5011
+ void start.finally(() => {
5012
+ if (this.nativeToolStart === attempt) this.nativeToolStart = null;
5013
+ }).catch(() => void 0);
5014
+ }
5015
+ await this.nativeToolStart.pending;
5016
+ }
5017
+ if (!this.toolActivity?.isActive(session.sessionId) || this.session !== session || !this.started) throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
5018
+ const streamSha256 = crypto3.createHash("sha256").update(JSON.stringify([source.filePath, source.conversationId])).digest("hex");
5019
+ const key = `${selection.sessionScope.sessionId}:${selection.sessionScope.sessionGenerationId}:${streamSha256}`;
5020
+ const existing = this.nativeSources.get(key);
5021
+ if (existing) {
5022
+ this.assertNativeOwner(existing);
5023
+ return existing;
5024
+ }
5025
+ const owner = {
5026
+ session,
5027
+ selection,
5028
+ streamSha256,
5029
+ cursor: selection.writer.sourcePair({ provider: "agy", streamSha256 }),
5030
+ controller: new AbortController(),
5031
+ drain: null,
5032
+ drainRequested: false,
5033
+ timer: null,
5034
+ retryMs: 5e3
5035
+ };
5036
+ this.nativeSources.set(key, owner);
5037
+ return owner;
5038
+ }
5039
+ async restoreNativeSource(source, initial) {
5040
+ const owner = await this.nativeOwner(source);
5041
+ if (!owner) return null;
5042
+ const position = await owner.cursor.open(initial);
5043
+ this.assertNativeOwner(owner);
5044
+ await owner.selection.writer.mobileEchoes("agy").reconcile(owner.streamSha256, position.byteOffset);
5045
+ this.drainNativeSource(owner);
5046
+ return position;
5047
+ }
5048
+ async skipNativeSource(gap, source) {
5049
+ const owner = await this.nativeOwner(source);
5050
+ if (!owner) throw Error("SYNC_SOURCE_PROTOCOL_CHANGED");
5051
+ await owner.cursor.skip(gap);
5052
+ this.assertNativeOwner(owner);
5053
+ }
5054
+ async admitNativeSource(emit, source) {
5055
+ const owner = await this.nativeOwner(source);
5056
+ if (!owner) throw Error("SYNC_SOURCE_PROTOCOL_CHANGED");
5057
+ const run = this.nativeAdmissionChain.then(async () => {
5058
+ this.assertNativeOwner(owner);
5059
+ if ((await owner.cursor.open(0)).byteOffset >= source.endOffset) return;
5060
+ const admission = { outputs: [null, null] };
5061
+ await this.handleTranscriptEmit(emit, admission);
5062
+ this.assertNativeOwner(owner);
5063
+ const echoFrame = {
5064
+ streamSha256: owner.streamSha256,
5065
+ occurrence: `${source.startOffset}:${source.endOffset}`,
5066
+ sourceSha256: source.sha256,
5067
+ startOffset: source.startOffset,
5068
+ endOffset: source.endOffset
5069
+ };
5070
+ const echoes = owner.selection.writer.mobileEchoes("agy");
5071
+ for (let index = 0; index < admission.outputs.length; index++) {
5072
+ const input = admission.outputs[index];
5073
+ if (input?.type === import_codevibe_core6.EventType.USER_PROMPT && await echoes.claim(echoFrame, input.content, emit.event.created_at)) admission.outputs[index] = null;
5074
+ }
5075
+ const factory = (index) => {
5076
+ const input = admission.outputs[index];
5077
+ return input ? (id) => owner.selection.prepareEvent(input, id) : null;
5078
+ };
5079
+ await owner.cursor.admit({
5080
+ provider: "agy",
5081
+ streamSha256: owner.streamSha256,
5082
+ occurrence: `${source.startOffset}:${source.endOffset}`,
5083
+ sourceSha256: source.sha256,
5084
+ startOffset: source.startOffset,
5085
+ endOffset: source.endOffset,
5086
+ anchor: source.anchor
5087
+ }, [factory(0), factory(1)]);
5088
+ this.assertNativeOwner(owner);
5089
+ await echoes.reconcile(owner.streamSha256, source.endOffset);
5090
+ this.drainNativeSource(owner);
5091
+ });
5092
+ this.nativeAdmissionChain = run.catch((error) => {
5093
+ logger.warn("[sync-source] Agy admission retained", { error: String(error) });
5094
+ });
5095
+ await run;
5096
+ }
5097
+ drainNativeSource(owner) {
5098
+ if (!this.started || this.session !== owner.session || owner.controller.signal.aborted) return;
5099
+ owner.drainRequested = true;
5100
+ if (owner.drain || owner.timer) return;
5101
+ owner.drainRequested = false;
5102
+ const run = (async () => {
5103
+ let failed = false;
5104
+ try {
5105
+ if (await owner.cursor.drain(64, owner.controller.signal) >= 64) owner.drainRequested = true;
5106
+ owner.retryMs = 5e3;
5107
+ } catch (error) {
5108
+ failed = true;
5109
+ owner.retryMs = Math.min(owner.retryMs * 2, 6e4);
5110
+ if (!owner.controller.signal.aborted) logger.warn("[sync-source] Agy delivery retained", { error: String(error) });
5111
+ }
5112
+ owner.drain = null;
5113
+ if (!this.started || this.session !== owner.session || owner.controller.signal.aborted) return;
5114
+ if (failed) {
5115
+ owner.timer = setTimeout(() => {
5116
+ owner.timer = null;
5117
+ this.drainNativeSource(owner);
5118
+ }, owner.retryMs);
5119
+ owner.timer.unref();
5120
+ } else if (owner.drainRequested) this.drainNativeSource(owner);
5121
+ })();
5122
+ owner.drain = run;
5123
+ void run.catch((error) => logger.error("[sync-source] unexpected Agy drain failure", { error: String(error) }));
5124
+ }
5125
+ async stopNativeSources(session) {
5126
+ const owned = [...this.nativeSources].filter(([, owner]) => !session || owner.session === session);
5127
+ for (const [, owner] of owned) {
5128
+ if (owner.timer) clearTimeout(owner.timer);
5129
+ owner.timer = null;
5130
+ owner.controller.abort(Error("SYNC_SOURCE_STOPPED"));
5131
+ }
5132
+ await Promise.all(owned.map(([, owner]) => owner.drain?.catch(() => void 0)));
5133
+ for (const [key] of owned) this.nativeSources.delete(key);
5134
+ if (!session || this.nativeSourceSelection?.session === session) this.nativeSourceSelection = null;
5135
+ }
4567
5136
  async flushToolActivityAtBoundary(boundary) {
4568
5137
  try {
4569
5138
  await this.toolActivity?.flush();
@@ -4593,18 +5162,18 @@ var McpServer = class _McpServer {
4593
5162
  const expectedSession = this.session;
4594
5163
  try {
4595
5164
  const session = await this.appSyncClient.getSession(sessionId);
4596
- if (session?.status === import_codevibe_core5.SessionStatus.INACTIVE) {
5165
+ if (session?.status === import_codevibe_core6.SessionStatus.INACTIVE) {
4597
5166
  const terminal = new Error(`Antigravity session is no longer active: ${sessionId}`);
4598
5167
  terminal.errorType = "SessionTerminal";
4599
5168
  throw terminal;
4600
5169
  }
4601
- if (!session || session.status !== import_codevibe_core5.SessionStatus.ACTIVE || !session.isEncrypted) {
5170
+ if (!session || session.status !== import_codevibe_core6.SessionStatus.ACTIVE || !session.isEncrypted) {
4602
5171
  return null;
4603
5172
  }
4604
5173
  const sessionKeyGen = session.sessionKeyGen ?? null;
4605
5174
  const encryptedKeys = session.encryptedKeys ?? [];
4606
5175
  if (encryptedKeys.length === 0) return null;
4607
- const sessionKey = await import_codevibe_core5.keychainManager.getSessionKey(
5176
+ const sessionKey = await import_codevibe_core6.keychainManager.getSessionKey(
4608
5177
  sessionId,
4609
5178
  encryptedKeys,
4610
5179
  sessionKeyGen
@@ -4617,7 +5186,7 @@ var McpServer = class _McpServer {
4617
5186
  liveSession.sessionIsEncrypted = true;
4618
5187
  return { sessionKey, sessionKeyGen };
4619
5188
  } catch (error) {
4620
- if ((0, import_codevibe_core5.isTerminalSessionError)(error)) throw error;
5189
+ if ((0, import_codevibe_core6.isTerminalSessionError)(error)) throw error;
4621
5190
  logger.warn("[tool-activity] Agy session-key refresh failed; window remains retryable", {
4622
5191
  sessionId,
4623
5192
  error: String(error)
@@ -4637,7 +5206,7 @@ var McpServer = class _McpServer {
4637
5206
  * an encrypted keyless one (drop).
4638
5207
  */
4639
5208
  encryptOutbound(session, input) {
4640
- const enc = (0, import_codevibe_core5.encryptForEmit)(
5209
+ const enc = (0, import_codevibe_core6.encryptForEmit)(
4641
5210
  session.sessionKey ?? null,
4642
5211
  session.sessionIsEncrypted ?? false,
4643
5212
  input.content,
@@ -4667,7 +5236,7 @@ var McpServer = class _McpServer {
4667
5236
  const promptId = input.producerKind === "PLUGIN_APPROVAL" ? input.promptId : void 0;
4668
5237
  const promptEntry = promptId ? this.e1PromptRaises.get(promptId) : void 0;
4669
5238
  const sendOnce = async () => {
4670
- if (promptId && !(0, import_codevibe_core5.isOpenPromptRaise)(
5239
+ if (promptId && !(0, import_codevibe_core6.isOpenPromptRaise)(
4671
5240
  promptEntry,
4672
5241
  this.e1PromptRaises.get(promptId),
4673
5242
  this.e1NeedsResolution.has(promptId)
@@ -4680,7 +5249,7 @@ var McpServer = class _McpServer {
4680
5249
  try {
4681
5250
  return await sendOnce();
4682
5251
  } catch (error) {
4683
- if (!(0, import_codevibe_core5.isSessionKeyStaleError)(error)) throw error;
5252
+ if (!(0, import_codevibe_core6.isSessionKeyStaleError)(error)) throw error;
4684
5253
  const refreshed = await this.refreshToolActivitySessionKey(session.sessionId);
4685
5254
  if (!refreshed) throw error;
4686
5255
  return sendOnce();
@@ -4701,7 +5270,7 @@ var McpServer = class _McpServer {
4701
5270
  * NOTIFICATION carrier, no fabricated options).
4702
5271
  */
4703
5272
  newE1Raise(sessionId, mobileActionable, title) {
4704
- const { record } = (0, import_codevibe_core5.newPromptRaise)({
5273
+ const { record } = (0, import_codevibe_core6.newPromptRaise)({
4705
5274
  sessionId,
4706
5275
  producerKind: "PLUGIN_APPROVAL",
4707
5276
  mobileActionable,
@@ -4737,14 +5306,12 @@ var McpServer = class _McpServer {
4737
5306
  const operation = (async () => {
4738
5307
  try {
4739
5308
  if (this.promptPublications.isPublishing(promptId)) await this.promptPublications.waitFor(promptId);
4740
- const ack = await this.appSyncClient.createEvent({
5309
+ const acknowledged = await this.appSyncClient.resolveCompanionPrompt({
4741
5310
  sessionId: entry.record.sessionId || sessionId,
4742
- type: import_codevibe_core5.EventType.NOTIFICATION,
4743
- source: import_codevibe_core5.EventSource.DESKTOP,
4744
- resolvedPromptId: promptId,
5311
+ promptId,
4745
5312
  ownerToken: entry.record.ownerToken
4746
5313
  });
4747
- if (ack?.promptId !== promptId) return false;
5314
+ if (!acknowledged) return false;
4748
5315
  this.e1NeedsResolution.delete(promptId);
4749
5316
  this.e1NeedsReRaise.delete(promptId);
4750
5317
  this.e1PromptRaises.delete(promptId);
@@ -4797,27 +5364,43 @@ var McpServer = class _McpServer {
4797
5364
  * Content is E2E-encrypted; the raise identity rides plaintext top-level.
4798
5365
  * Fail-closed on a missing session key — never cleartext.
4799
5366
  */
4800
- async emitSuppressedPromptCarrier(session, record, customBannerText) {
5367
+ async emitSuppressedPromptCarrier(session, record, reRaiseToken, customBannerText) {
4801
5368
  const bannerText = customBannerText || "\u26A0\uFE0F A prompt is waiting in your desktop terminal, but its options could not be shown here. Please answer it on your desktop.";
4802
5369
  const input = {
4803
5370
  sessionId: session.sessionId,
4804
- type: import_codevibe_core5.EventType.NOTIFICATION,
4805
- source: import_codevibe_core5.EventSource.DESKTOP,
5371
+ type: import_codevibe_core6.EventType.NOTIFICATION,
5372
+ source: import_codevibe_core6.EventSource.DESKTOP,
4806
5373
  content: bannerText,
4807
5374
  metadata: {
4808
5375
  e1SuppressedPrompt: true
4809
5376
  },
4810
- ...(0, import_codevibe_core5.raiseFieldsFromRecord)(record),
5377
+ ...(0, import_codevibe_core6.raiseFieldsFromRecord)(record),
4811
5378
  notificationText: bannerText,
4812
- timestamp: (0, import_codevibe_core5.prepareEventTimestamp)({ orderingKey: session.sessionId })
5379
+ timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: session.sessionId })
4813
5380
  };
4814
- await this.flushToolActivityAtBoundary(import_codevibe_core5.EventType.NOTIFICATION);
4815
- if (!await this.createEncryptedEvent(session, input)) return false;
4816
- logger.info("E1: emitted suppressed-prompt badge carrier (mobileActionable=false)", {
4817
- sessionId: session.sessionId,
4818
- promptId: record.promptId
4819
- });
4820
- return true;
5381
+ await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.NOTIFICATION);
5382
+ return this.publishE1Prompt(session, input, reRaiseToken);
5383
+ }
5384
+ async publishE1Prompt(session, input, reRaiseToken) {
5385
+ const promptId = input.promptId, entry = promptId ? this.e1PromptRaises.get(promptId) : void 0;
5386
+ if (!promptId || !(0, import_codevibe_core6.isOpenPromptRaise)(entry, this.e1PromptRaises.get(promptId), this.e1NeedsResolution.has(promptId))) return "CLOSED";
5387
+ if (!this.started || this.session !== session || input.sessionId !== session.sessionId) return "RETRY";
5388
+ const { notificationText: _legacyHint, ...plain } = input;
5389
+ const settle = (disposition) => {
5390
+ if ((0, import_codevibe_core6.isOpenPromptRaise)(entry, this.e1PromptRaises.get(promptId), this.e1NeedsResolution.has(promptId))) {
5391
+ if (disposition === "RETRY") this.e1NeedsReRaise.add(promptId);
5392
+ else if (disposition === "RERAISED" || disposition === "OMITTED") this.e1NeedsReRaise.delete(promptId);
5393
+ }
5394
+ return disposition;
5395
+ };
5396
+ return this.promptPublications.run(promptId, () => this.appSyncClient.publishCompanionPrompt(
5397
+ plain,
5398
+ async () => !!await this.createEncryptedEvent(session, input),
5399
+ { reRaiseToken }
5400
+ ).then(settle, (error) => {
5401
+ settle("RETRY");
5402
+ throw error;
5403
+ }));
4821
5404
  }
4822
5405
  /**
4823
5406
  * E1 (§6a-2b) — badge-only raise for a SUPPRESSED prompt, applying the content-key
@@ -4842,7 +5425,7 @@ var McpServer = class _McpServer {
4842
5425
  entry.contentPlain = customBannerText;
4843
5426
  }
4844
5427
  }
4845
- this.emitSuppressedPromptCarrier(session, record, customBannerText).catch((e) => {
5428
+ this.emitSuppressedPromptCarrier(session, record, void 0, customBannerText).catch((e) => {
4846
5429
  logger.error("E1: failed to emit suppressed-prompt badge carrier", {
4847
5430
  sessionId: session.sessionId,
4848
5431
  promptId: record.promptId,
@@ -4879,8 +5462,8 @@ var McpServer = class _McpServer {
4879
5462
  ...snap.metadataPlain !== void 0 && { metadataPlain: snap.metadataPlain }
4880
5463
  };
4881
5464
  this.e1PromptRaises.set(entry.record.promptId, entry);
4882
- const acked = await this.reRaiseOneE1(replacementSession, entry, opts);
4883
- if (!acked && !entry.resolutionStarted) unacked.push(snap);
5465
+ const disposition = await this.reRaiseOneE1(replacementSession, entry, opts);
5466
+ if (disposition !== "RERAISED" && disposition !== "OMITTED" && !entry.resolutionStarted) unacked.push(snap);
4884
5467
  }
4885
5468
  if (!this.e1TtlRefreshTimer) this.startE1TtlRefresh();
4886
5469
  return unacked;
@@ -4940,7 +5523,7 @@ var McpServer = class _McpServer {
4940
5523
  if (this.reRaiseSuperseded(gen)) return;
4941
5524
  const slice = Math.min(50, deadline - Date.now());
4942
5525
  if (slice <= 0) return;
4943
- await new Promise((resolve3) => setTimeout(resolve3, slice));
5526
+ await new Promise((resolve2) => setTimeout(resolve2, slice));
4944
5527
  }
4945
5528
  }
4946
5529
  /**
@@ -4968,57 +5551,65 @@ var McpServer = class _McpServer {
4968
5551
  */
4969
5552
  async reRaiseOneE1(session, entry, opts) {
4970
5553
  const rec = entry.record;
4971
- if (this.e1NeedsResolution.has(rec.promptId)) return false;
4972
- if (!(0, import_codevibe_core5.canRecoverPromptRaise)(entry, this.promptPublications.isPublishing(rec.promptId))) {
4973
- return false;
5554
+ if (this.e1NeedsResolution.has(rec.promptId)) return "CLOSED";
5555
+ if (!(0, import_codevibe_core6.canRecoverPromptRaise)(entry, this.promptPublications.isPublishing(rec.promptId))) {
5556
+ return "RETRY";
4974
5557
  }
4975
5558
  const actionable = rec.mobileActionable && entry.contentPlain !== void 0;
4976
5559
  const retryUntilAck = opts?.retryUntilAck === true;
4977
5560
  const gen = opts?.gen;
4978
5561
  const MAX_ATTEMPTS = 4;
4979
5562
  for (let attempt = 1; ; attempt++) {
4980
- if (!(0, import_codevibe_core5.isOpenPromptRaise)(
5563
+ if (!(0, import_codevibe_core6.isOpenPromptRaise)(
4981
5564
  entry,
4982
5565
  this.e1PromptRaises.get(rec.promptId),
4983
5566
  this.e1NeedsResolution.has(rec.promptId)
4984
- )) return false;
5567
+ )) return "CLOSED";
4985
5568
  if (retryUntilAck && this.reRaiseSuperseded(gen)) {
4986
5569
  this.e1NeedsReRaise.add(rec.promptId);
4987
5570
  logger.warn("E1: block-until-ack re-raise aborted (lifecycle superseded/stopping) \u2014 flagged for retry-until-ack (entry retained)", {
4988
5571
  sessionId: session.sessionId,
4989
5572
  promptId: rec.promptId
4990
5573
  });
4991
- return false;
5574
+ return "RETRY";
4992
5575
  }
4993
5576
  try {
5577
+ let disposition;
4994
5578
  if (actionable) {
4995
5579
  const input = {
4996
5580
  sessionId: session.sessionId,
4997
- type: import_codevibe_core5.EventType.INTERACTIVE_PROMPT,
4998
- source: import_codevibe_core5.EventSource.DESKTOP,
5581
+ type: import_codevibe_core6.EventType.INTERACTIVE_PROMPT,
5582
+ source: import_codevibe_core6.EventSource.DESKTOP,
4999
5583
  content: entry.contentPlain,
5000
5584
  metadata: entry.metadataPlain ?? {},
5001
- ...(0, import_codevibe_core5.raiseFieldsFromRecord)(rec),
5002
- timestamp: (0, import_codevibe_core5.prepareEventTimestamp)({ orderingKey: session.sessionId })
5585
+ ...(0, import_codevibe_core6.raiseFieldsFromRecord)(rec),
5586
+ timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: session.sessionId })
5003
5587
  };
5004
- await this.flushToolActivityAtBoundary(import_codevibe_core5.EventType.INTERACTIVE_PROMPT);
5005
- if (!(0, import_codevibe_core5.isOpenPromptRaise)(
5588
+ await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.INTERACTIVE_PROMPT);
5589
+ if (!(0, import_codevibe_core6.isOpenPromptRaise)(
5006
5590
  entry,
5007
5591
  this.e1PromptRaises.get(rec.promptId),
5008
5592
  this.e1NeedsResolution.has(rec.promptId)
5009
- )) return false;
5010
- if (!await this.createEncryptedEvent(session, input)) return false;
5593
+ )) return "CLOSED";
5594
+ disposition = await this.publishE1Prompt(session, input, opts?.reRaiseToken);
5011
5595
  } else {
5012
5596
  rec.mobileActionable = false;
5013
- if (!await this.emitSuppressedPromptCarrier(session, rec, entry.contentPlain)) return false;
5597
+ disposition = await this.emitSuppressedPromptCarrier(
5598
+ session,
5599
+ rec,
5600
+ opts?.reRaiseToken,
5601
+ entry.contentPlain
5602
+ );
5014
5603
  }
5604
+ if (disposition === "CLOSED" || disposition === "RETRY") return disposition;
5015
5605
  this.e1NeedsReRaise.delete(rec.promptId);
5016
- logger.info("E1: re-raised prompt onto replacement session", {
5606
+ logger.info("E1: prompt publication settled", {
5017
5607
  sessionId: session.sessionId,
5018
5608
  promptId: rec.promptId,
5019
- actionable
5609
+ actionable,
5610
+ disposition
5020
5611
  });
5021
- return true;
5612
+ return disposition;
5022
5613
  } catch (e) {
5023
5614
  if (!retryUntilAck && attempt >= MAX_ATTEMPTS) {
5024
5615
  this.e1NeedsReRaise.add(rec.promptId);
@@ -5027,7 +5618,7 @@ var McpServer = class _McpServer {
5027
5618
  promptId: rec.promptId,
5028
5619
  error: e instanceof Error ? e.message : String(e)
5029
5620
  });
5030
- return false;
5621
+ return "RETRY";
5031
5622
  }
5032
5623
  if (retryUntilAck) {
5033
5624
  const delay2 = Math.min(
@@ -5036,7 +5627,7 @@ var McpServer = class _McpServer {
5036
5627
  );
5037
5628
  await this.interruptibleBackoff(delay2, gen);
5038
5629
  } else {
5039
- await new Promise((resolve3) => setTimeout(resolve3, 250 * attempt));
5630
+ await new Promise((resolve2) => setTimeout(resolve2, 250 * attempt));
5040
5631
  }
5041
5632
  }
5042
5633
  }
@@ -5075,16 +5666,14 @@ var McpServer = class _McpServer {
5075
5666
  const session = this.session;
5076
5667
  if (!session) return;
5077
5668
  const sid = session.sessionId;
5078
- const promptIds = [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sid && !this.e1NeedsResolution.has(e.record.promptId) && (0, import_codevibe_core5.canRecoverPromptRaise)(e, this.promptPublications.isPublishing(e.record.promptId))).map((e) => e.record.promptId);
5669
+ const promptIds = [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sid && !this.e1NeedsResolution.has(e.record.promptId) && (0, import_codevibe_core6.canRecoverPromptRaise)(e, this.promptPublications.isPublishing(e.record.promptId))).map((e) => e.record.promptId);
5079
5670
  if (promptIds.length === 0) return;
5080
5671
  try {
5081
- const agedOut = await this.appSyncClient.refreshOpenPromptTtl(promptIds);
5082
- for (const promptId of agedOut) {
5672
+ await this.appSyncClient.refreshSessionOpenPromptTtl({ sessionId: sid, promptIds }, async (promptId, reRaiseToken) => {
5083
5673
  const entry = this.e1PromptRaises.get(promptId);
5084
- if (entry && this.session && entry.record.sessionId === this.session.sessionId) {
5085
- await this.reRaiseOneE1(this.session, entry);
5086
- }
5087
- }
5674
+ if (!entry || this.session !== session || entry.record.sessionId !== sid || this.e1NeedsResolution.has(promptId)) return "CLOSED";
5675
+ return this.reRaiseOneE1(session, entry, { reRaiseToken });
5676
+ });
5088
5677
  } catch (e) {
5089
5678
  logger.warn("E1: TTL-refresh tick failed (non-fatal, retries next tick)", {
5090
5679
  error: e instanceof Error ? e.message : String(e)
@@ -5145,6 +5734,7 @@ var McpServer = class _McpServer {
5145
5734
  }
5146
5735
  this.subscription = null;
5147
5736
  }
5737
+ await this.stopNativeSources(old);
5148
5738
  try {
5149
5739
  await this.toolActivity?.prepareForReplacement();
5150
5740
  } catch (error) {
@@ -5187,10 +5777,10 @@ var McpServer = class _McpServer {
5187
5777
  }
5188
5778
  const result = { ...evt };
5189
5779
  try {
5190
- result.content = import_codevibe_core5.cryptoService.decryptContent(evt.content, session.sessionKey);
5780
+ result.content = import_codevibe_core6.cryptoService.decryptContent(evt.content, session.sessionKey);
5191
5781
  const rawMeta = parseMaybeJson(evt.metadata);
5192
5782
  if (rawMeta && typeof rawMeta === "object" && typeof rawMeta.encrypted === "string") {
5193
- const decryptedMeta = import_codevibe_core5.cryptoService.decryptMetadata(
5783
+ const decryptedMeta = import_codevibe_core6.cryptoService.decryptMetadata(
5194
5784
  rawMeta.encrypted,
5195
5785
  session.sessionKey
5196
5786
  );
@@ -5314,35 +5904,35 @@ var McpServer = class _McpServer {
5314
5904
  const content = state.paneDisplayHeader || state.matchedPaneHeader || state.pendingCall?.command || state.pendingCall?.filePath || "approval requested";
5315
5905
  const input = {
5316
5906
  sessionId: session.sessionId,
5317
- type: import_codevibe_core5.EventType.INTERACTIVE_PROMPT,
5318
- source: import_codevibe_core5.EventSource.DESKTOP,
5907
+ type: import_codevibe_core6.EventType.INTERACTIVE_PROMPT,
5908
+ source: import_codevibe_core6.EventSource.DESKTOP,
5319
5909
  content,
5320
5910
  metadata: optionsForMobile,
5321
5911
  // E1 (§4) — raise identity top-level: promptId + ownerToken/producerKind/
5322
5912
  // mobileActionable (the backend's classifyRaise upserts the open-prompt row).
5323
- ...(0, import_codevibe_core5.raiseFieldsFromRecord)(e1rec),
5913
+ ...(0, import_codevibe_core6.raiseFieldsFromRecord)(e1rec),
5324
5914
  // Per-conversation monotonic timestamp (event-timestamp-ordering fix
5325
5915
  // v5.4, 2026-05-24). orderingKey scopes the counter to this conv so
5326
5916
  // INTERACTIVE_PROMPT can't be forced to lastMs+1 by a newer event
5327
5917
  // from a sibling conversation under v9's multi-conv-per-session
5328
5918
  // model.
5329
- timestamp: (0, import_codevibe_core5.prepareEventTimestamp)({
5919
+ timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({
5330
5920
  orderingKey: `${session.sessionId}:${state.conversationId}`
5331
5921
  })
5332
5922
  };
5333
5923
  try {
5334
5924
  this.stashE1RaiseContent(e1rec.promptId, content, optionsForMobile);
5335
- await this.flushToolActivityAtBoundary(import_codevibe_core5.EventType.INTERACTIVE_PROMPT);
5925
+ await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.INTERACTIVE_PROMPT);
5336
5926
  if (this.e1NeedsResolution.has(e1rec.promptId) || this.e1PromptRaises.get(e1rec.promptId)?.record !== e1rec) return;
5337
- if (!await this.createEncryptedEvent(session, input)) return;
5927
+ await this.publishE1Prompt(session, input);
5338
5928
  } catch (err) {
5339
5929
  logger.error("createEvent INTERACTIVE_PROMPT failed", {
5340
5930
  promptId: state.promptId,
5341
5931
  error: String(err)
5342
5932
  });
5343
- this.approvalDetector.rollbackPrompt(state.promptId);
5344
- if (state.paneHash) this.paneObserver.resetPromptHash(state.paneHash);
5345
- this.e1PromptRaises.delete(state.promptId);
5933
+ if (this.e1PromptRaises.get(e1rec.promptId)?.record === e1rec && !this.e1NeedsResolution.has(e1rec.promptId)) {
5934
+ this.e1NeedsReRaise.add(e1rec.promptId);
5935
+ }
5346
5936
  }
5347
5937
  }
5348
5938
  // ─── Mobile → desktop flow ──────────────────────────────────────────────
@@ -5380,7 +5970,7 @@ var McpServer = class _McpServer {
5380
5970
  if (shouldDecrypt && sessionKey) {
5381
5971
  try {
5382
5972
  logger.info("Decrypting attachment", { id: attachment.id });
5383
- buffer = import_codevibe_core5.cryptoService.decryptData(buffer, sessionKey);
5973
+ buffer = import_codevibe_core6.cryptoService.decryptData(buffer, sessionKey);
5384
5974
  logger.info("Attachment decrypted successfully", {
5385
5975
  id: attachment.id,
5386
5976
  decryptedSize: buffer.length
@@ -5435,7 +6025,7 @@ var McpServer = class _McpServer {
5435
6025
  await this.ensureLaunchSession();
5436
6026
  if (!this.session) return;
5437
6027
  const session = this.session;
5438
- if (evt.source !== import_codevibe_core5.EventSource.MOBILE) return;
6028
+ if (evt.source !== import_codevibe_core6.EventSource.MOBILE) return;
5439
6029
  if (evt.sessionId !== session.sessionId) {
5440
6030
  logger.warn("Dropping mobile event with mismatched sessionId", {
5441
6031
  expected: session.sessionId,
@@ -5446,19 +6036,20 @@ var McpServer = class _McpServer {
5446
6036
  let decrypted;
5447
6037
  try {
5448
6038
  decrypted = this.decryptInbound(session, evt);
5449
- } catch {
6039
+ } catch (error) {
6040
+ if ((0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) throw error;
5450
6041
  return;
5451
6042
  }
5452
6043
  evt = decrypted;
5453
- await this.markDelivered(evt);
5454
- if (evt.type === import_codevibe_core5.EventType.USER_PROMPT || evt.type === import_codevibe_core5.EventType.PROMPT_RESPONSE) {
6044
+ if (!await this.markDelivered(evt)) return;
6045
+ if (evt.type === import_codevibe_core6.EventType.USER_PROMPT || evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE) {
5455
6046
  const mobileFloorMs = Date.parse(evt.timestamp);
5456
6047
  this.mobilePromptFloorBySession.set(session.sessionId, {
5457
6048
  floorMs: Number.isNaN(mobileFloorMs) ? Date.now() : mobileFloorMs,
5458
6049
  setAtWallMs: Date.now()
5459
6050
  });
5460
6051
  }
5461
- if (evt.type === import_codevibe_core5.EventType.USER_PROMPT || evt.type === import_codevibe_core5.EventType.PROMPT_RESPONSE) {
6052
+ if (evt.type === import_codevibe_core6.EventType.USER_PROMPT || evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE) {
5462
6053
  let promptContent = evt.content;
5463
6054
  const attachments = evt.attachments ?? [];
5464
6055
  if (attachments.length > 0) {
@@ -5497,16 +6088,16 @@ var McpServer = class _McpServer {
5497
6088
  const rawMeta = parseMaybeJson(evt.metadata);
5498
6089
  const meta = rawMeta && typeof rawMeta === "object" ? rawMeta : {};
5499
6090
  const evtAny = evt;
5500
- const responsePromptId = evt.type === import_codevibe_core5.EventType.PROMPT_RESPONSE ? typeof meta.promptId === "string" && meta.promptId || typeof meta.prompt_id === "string" && meta.prompt_id || typeof evtAny.promptId === "string" && evtAny.promptId || typeof evtAny.prompt_id === "string" && evtAny.prompt_id || null : null;
6091
+ const responsePromptId = evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE ? typeof meta.promptId === "string" && meta.promptId || typeof meta.prompt_id === "string" && meta.prompt_id || typeof evtAny.promptId === "string" && evtAny.promptId || typeof evtAny.prompt_id === "string" && evtAny.prompt_id || null : null;
5501
6092
  const advertisedOptionNumbers = Array.from(new Set(
5502
6093
  pendingPrompts.flatMap((prompt) => Object.keys(prompt.submitMap))
5503
6094
  ));
5504
- const intent = (0, import_codevibe_core5.classifyMobilePromptInput)(
6095
+ const intent = (0, import_codevibe_core6.classifyMobilePromptInput)(
5505
6096
  promptContent,
5506
6097
  advertisedOptionNumbers
5507
6098
  );
5508
6099
  if (intent.kind === "option") {
5509
- const activePrompt = evt.type === import_codevibe_core5.EventType.PROMPT_RESPONSE ? pendingPrompts.find((prompt) => prompt.promptId === responsePromptId) ?? null : pendingPrompts.length === 1 ? pendingPrompts[0] : null;
6100
+ const activePrompt = evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE ? pendingPrompts.find((prompt) => prompt.promptId === responsePromptId) ?? null : pendingPrompts.length === 1 ? pendingPrompts[0] : null;
5510
6101
  if (!activePrompt || !Object.prototype.hasOwnProperty.call(activePrompt.submitMap, intent.option)) {
5511
6102
  logger.warn("Numeric input does not identify one current advertised prompt; dropping", {
5512
6103
  sessionId: session.sessionId,
@@ -5516,7 +6107,7 @@ var McpServer = class _McpServer {
5516
6107
  });
5517
6108
  return;
5518
6109
  }
5519
- if (evt.type === import_codevibe_core5.EventType.PROMPT_RESPONSE) {
6110
+ if (evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE) {
5520
6111
  if (responsePromptId !== activePrompt.promptId) {
5521
6112
  logger.warn("PROMPT_RESPONSE does not identify the current advertised prompt; dropping", {
5522
6113
  sessionId: session.sessionId,
@@ -5526,13 +6117,13 @@ var McpServer = class _McpServer {
5526
6117
  return;
5527
6118
  }
5528
6119
  }
5529
- this.mobileDeduper.track(session.sessionId, intent.option);
6120
+ await this.trackDurableMobileEcho(session, evt, intent.option, "primary");
5530
6121
  const optionResult = await this.promptResponder.sendApprovalReply(
5531
6122
  activePrompt.promptId,
5532
6123
  intent.option
5533
6124
  );
5534
6125
  if (!optionResult.ok) {
5535
- this.mobileDeduper.forget(session.sessionId, intent.option);
6126
+ if (!(0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) this.mobileDeduper.forget(session.sessionId, intent.option);
5536
6127
  return;
5537
6128
  }
5538
6129
  await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
@@ -5541,7 +6132,7 @@ var McpServer = class _McpServer {
5541
6132
  return;
5542
6133
  }
5543
6134
  promptContent = intent.followUpText;
5544
- await new Promise((resolve3) => setTimeout(resolve3, 250));
6135
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
5545
6136
  } else {
5546
6137
  const dismissal = await this.promptResponder.dismissActivePrompt();
5547
6138
  if (!dismissal.ok) {
@@ -5554,10 +6145,12 @@ var McpServer = class _McpServer {
5554
6145
  const clearedPromptIds = this.approvalDetector.onPanePromptCleared();
5555
6146
  await this.resolveAcceptedInputPrompts(session, clearedPromptIds);
5556
6147
  }
5557
- this.mobileDeduper.track(session.sessionId, promptContent);
5558
- const result = await this.promptResponder.sendFreeFormWhenNoApprovalActive(promptContent);
6148
+ const result = await this.promptResponder.sendFreeFormWhenNoApprovalActive(
6149
+ promptContent,
6150
+ () => this.trackDurableMobileEcho(session, evt, promptContent, "free_form")
6151
+ );
5559
6152
  if (!result.ok) {
5560
- this.mobileDeduper.forget(session.sessionId, promptContent);
6153
+ if (!(0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) this.mobileDeduper.forget(session.sessionId, promptContent);
5561
6154
  logger.warn("sendFreeForm failed", { reason: result.reason, details: result.details });
5562
6155
  return;
5563
6156
  }
@@ -5565,18 +6158,37 @@ var McpServer = class _McpServer {
5565
6158
  return;
5566
6159
  }
5567
6160
  }
6161
+ async trackDurableMobileEcho(session, event, content, lane) {
6162
+ if (!(0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(event)) {
6163
+ this.mobileDeduper.track(session.sessionId, content);
6164
+ return;
6165
+ }
6166
+ const selected = await this.selectNativeSource(session);
6167
+ if (selected.protocol !== "SYNC_V2" || !this.started || this.sessionDisabled || this.session !== session || event.sessionId !== selected.sessionScope.sessionId || event.sessionGenerationId !== selected.sessionScope.sessionGenerationId) throw Error("SYNC_ECHO_OWNER_CHANGED");
6168
+ if (content.trim()) await selected.writer.mobileEchoes("agy").arm(
6169
+ { eventId: event.eventId, timestamp: event.timestamp, type: event.type, lane },
6170
+ content,
6171
+ { nowMs: Date.now(), windowMs: 15e3, clockPrecisionMs: 1e3, allowSuffix: true, normalization: "whitespace" }
6172
+ );
6173
+ if (!this.started || this.sessionDisabled || this.session !== session) throw Error("SYNC_ECHO_OWNER_CHANGED");
6174
+ }
5568
6175
  async emitPromptSafetyNotification(session, content) {
5569
6176
  const input = {
5570
6177
  sessionId: session.sessionId,
5571
- type: import_codevibe_core5.EventType.NOTIFICATION,
5572
- source: import_codevibe_core5.EventSource.DESKTOP,
6178
+ type: import_codevibe_core6.EventType.NOTIFICATION,
6179
+ source: import_codevibe_core6.EventSource.DESKTOP,
5573
6180
  content,
5574
6181
  metadata: { promptSafetyBlocked: true },
5575
- timestamp: (0, import_codevibe_core5.prepareEventTimestamp)({ orderingKey: session.sessionId })
6182
+ timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: session.sessionId })
5576
6183
  };
5577
- await this.flushToolActivityAtBoundary(import_codevibe_core5.EventType.NOTIFICATION);
6184
+ await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.NOTIFICATION);
5578
6185
  try {
5579
- await this.createEncryptedEvent(session, input);
6186
+ if (!this.started || this.sessionDisabled || this.session !== session) return;
6187
+ const result = await this.appSyncClient.publishCompanionNotice(
6188
+ input,
6189
+ async () => !!await this.createEncryptedEvent(session, input)
6190
+ );
6191
+ logger.debug("Prompt-safety notification publication outcome", { sessionId: session.sessionId, result });
5580
6192
  } catch (error) {
5581
6193
  logger.warn("Failed to emit prompt-safety notification", { error: String(error) });
5582
6194
  }
@@ -5586,23 +6198,20 @@ var McpServer = class _McpServer {
5586
6198
  * soon as the plugin receives + decrypts the event and BEFORE the
5587
6199
  * tmux send is attempted, mirroring codex/claude plugin semantics:
5588
6200
  * "delivered" means the desktop daemon has the message in hand, not
5589
- * that it has acted on it yet. Errors are swallowed (logged) since
5590
- * a status-write failure must not block the actual prompt routing.
6201
+ * that it has acted on it yet. Legacy checkmarks remain best-effort;
6202
+ * R2 requires exact cloud authority before terminal routing.
5591
6203
  */
5592
6204
  async markDelivered(evt) {
5593
6205
  try {
5594
- await this.appSyncClient.updateEventStatus({
5595
- eventId: evt.eventId,
5596
- sessionId: evt.sessionId,
5597
- timestamp: evt.timestamp,
5598
- deliveryStatus: import_codevibe_core5.DeliveryStatus.DELIVERED
5599
- });
6206
+ return await (0, import_codevibe_core6.acknowledgeMobileInputDelivery)(this.appSyncClient.updateEventStatus.bind(this.appSyncClient), evt);
5600
6207
  } catch (err) {
5601
6208
  logger.warn("updateEventStatus(DELIVERED) failed", {
5602
6209
  eventId: evt.eventId,
5603
6210
  sessionId: evt.sessionId,
5604
6211
  error: String(err)
5605
6212
  });
6213
+ if ((0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) throw err;
6214
+ return true;
5606
6215
  }
5607
6216
  }
5608
6217
  /**
@@ -5615,12 +6224,7 @@ var McpServer = class _McpServer {
5615
6224
  */
5616
6225
  async markExecuted(evt) {
5617
6226
  try {
5618
- await this.appSyncClient.updateEventStatus({
5619
- eventId: evt.eventId,
5620
- sessionId: evt.sessionId,
5621
- timestamp: evt.timestamp,
5622
- deliveryStatus: import_codevibe_core5.DeliveryStatus.EXECUTED
5623
- });
6227
+ await (0, import_codevibe_core6.acknowledgeMobileInputExecution)(this.appSyncClient.updateEventStatus.bind(this.appSyncClient), evt);
5624
6228
  } catch (err) {
5625
6229
  logger.warn("updateEventStatus(EXECUTED) failed", {
5626
6230
  eventId: evt.eventId,
@@ -5631,10 +6235,14 @@ var McpServer = class _McpServer {
5631
6235
  }
5632
6236
  // ─── HTTP API push hook (internal/programmatic) ────────────────────────
5633
6237
  async handleHttpPushEvent(payload) {
6238
+ if (!isHttpEventPayload(payload)) throw Object.assign(Error("invalid HTTP event payload"), { code: "INVALID_SYNC_INPUT" });
5634
6239
  const session = this.findSessionByBackendId(payload.sessionId);
5635
- if (!session) {
6240
+ if (!session || !this.started || this.sessionDisabled) {
5636
6241
  throw new SessionNotFoundError(payload.sessionId);
5637
6242
  }
6243
+ const assertOwner = () => {
6244
+ if (!this.started || this.sessionDisabled || this.session !== session) throw new SessionNotFoundError(payload.sessionId);
6245
+ };
5638
6246
  const normalizedMeta = parseMaybeJson(payload.metadata);
5639
6247
  const metadata = normalizedMeta && typeof normalizedMeta === "object" ? normalizedMeta : {};
5640
6248
  const input = {
@@ -5647,15 +6255,12 @@ var McpServer = class _McpServer {
5647
6255
  // orderingKey is session-scoped. Slightly coarser than the
5648
6256
  // per-conv key used by transcript emits, but acceptable because
5649
6257
  // hook-script pushes are off the hot transcript path.
5650
- timestamp: (0, import_codevibe_core5.prepareEventTimestamp)({ orderingKey: payload.sessionId })
6258
+ timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: payload.sessionId })
5651
6259
  };
5652
- const created = await this.createEncryptedEvent(session, input);
5653
- if (!created) {
5654
- throw new Error(
5655
- `fail-closed: encrypted session ${payload.sessionId} has no session key \u2014 event dropped (#638)`
5656
- );
5657
- }
5658
- return { eventId: created.eventId };
6260
+ return this.appSyncClient.publishCompanionEvent(input, async () => {
6261
+ assertOwner();
6262
+ return this.createEncryptedEvent(session, input);
6263
+ }, { assertOwner });
5659
6264
  }
5660
6265
  findSessionByBackendId(backendSessionId) {
5661
6266
  if (!this.session) return null;
@@ -6024,7 +6629,7 @@ async function main() {
6024
6629
  tmuxTarget: process.env.CODEVIBE_AGY_TMUX_TARGET ?? void 0,
6025
6630
  wrapperPid: process.env.CODEVIBE_AGY_WRAPPER_PID ? parseInt(process.env.CODEVIBE_AGY_WRAPPER_PID, 10) : void 0
6026
6631
  });
6027
- (0, import_codevibe_core5.installDaemonProcessGuards)(logger, {
6632
+ (0, import_codevibe_core6.installDaemonProcessGuards)(logger, {
6028
6633
  onFatal: () => {
6029
6634
  void server.stop().finally(() => process.exit(1));
6030
6635
  }