@quantiya/codevibe-antigravity-plugin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,3558 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/server.ts
31
+ var server_exports = {};
32
+ __export(server_exports, {
33
+ McpServer: () => McpServer,
34
+ SessionNotFoundError: () => SessionNotFoundError,
35
+ __testing: () => __testing,
36
+ generateLaunchSessionId: () => generateLaunchSessionId,
37
+ getActiveConversationFromCliLog: () => getActiveConversationFromCliLog,
38
+ parseMaybeJson: () => parseMaybeJson
39
+ });
40
+ module.exports = __toCommonJS(server_exports);
41
+ var crypto3 = __toESM(require("crypto"));
42
+ var path5 = __toESM(require("path"));
43
+ var fs5 = __toESM(require("fs"));
44
+ var os5 = __toESM(require("os"));
45
+ var import_codevibe_core4 = require("@quantiya/codevibe-core");
46
+
47
+ // src/logger.ts
48
+ var import_os = __toESM(require("os"));
49
+ var import_path = __toESM(require("path"));
50
+ var import_codevibe_core = require("@quantiya/codevibe-core");
51
+ var logger = (0, import_codevibe_core.createLogger)({
52
+ name: "codevibe-agy",
53
+ logFile: import_path.default.join(import_os.default.tmpdir(), "codevibe-agy-mcp.log"),
54
+ level: "info"
55
+ });
56
+
57
+ // src/http-api.ts
58
+ var fs = __toESM(require("fs"));
59
+ var crypto = __toESM(require("crypto"));
60
+ var http = __toESM(require("http"));
61
+ var import_codevibe_core2 = require("@quantiya/codevibe-core");
62
+ var HttpApi = class {
63
+ constructor(options) {
64
+ this.server = null;
65
+ this.handlers = null;
66
+ this.assignedPort = 0;
67
+ if (!options.bearerToken || options.bearerToken.length < 16) {
68
+ throw new Error("HttpApi requires a non-trivial bearerToken");
69
+ }
70
+ this.bearerToken = options.bearerToken;
71
+ this.host = options.host ?? "127.0.0.1";
72
+ if (this.host !== "127.0.0.1" && this.host !== "::1" && this.host !== "localhost") {
73
+ throw new Error(
74
+ `HttpApi refusing to bind ${this.host}: only loopback addresses are permitted`
75
+ );
76
+ }
77
+ this.preferredPort = options.preferredPort ?? 0;
78
+ this.portFilePath = options.portFilePath ?? null;
79
+ }
80
+ setHandlers(handlers) {
81
+ this.handlers = handlers;
82
+ }
83
+ /** Returns the bound port (0 if not started). */
84
+ getPort() {
85
+ return this.assignedPort;
86
+ }
87
+ async start() {
88
+ if (this.server) return this.assignedPort;
89
+ this.server = http.createServer((req, res) => this.dispatch(req, res));
90
+ try {
91
+ await new Promise((resolve3, reject) => {
92
+ const onError = (err) => {
93
+ this.server?.off("listening", onListening);
94
+ reject(err);
95
+ };
96
+ const onListening = () => {
97
+ this.server?.off("error", onError);
98
+ resolve3();
99
+ };
100
+ this.server.once("error", onError);
101
+ this.server.once("listening", onListening);
102
+ this.server.listen(this.preferredPort, this.host);
103
+ });
104
+ } catch (err) {
105
+ try {
106
+ this.server?.close();
107
+ } catch {
108
+ }
109
+ this.server = null;
110
+ throw err;
111
+ }
112
+ const address = this.server.address();
113
+ this.assignedPort = address.port;
114
+ logger.info("HTTP API listening", { host: this.host, port: this.assignedPort });
115
+ if (this.portFilePath) {
116
+ try {
117
+ await this.writePortFile();
118
+ } catch (err) {
119
+ logger.warn("Failed to write port file (continuing without)", {
120
+ path: this.portFilePath,
121
+ error: String(err)
122
+ });
123
+ }
124
+ }
125
+ return this.assignedPort;
126
+ }
127
+ async stop() {
128
+ const srv = this.server;
129
+ if (!srv) return;
130
+ this.server = null;
131
+ await new Promise((resolve3) => srv.close(() => resolve3()));
132
+ if (this.portFilePath) {
133
+ try {
134
+ await fs.promises.unlink(this.portFilePath);
135
+ } catch {
136
+ }
137
+ }
138
+ this.assignedPort = 0;
139
+ logger.info("HTTP API stopped");
140
+ }
141
+ // ─── Request handling ───────────────────────────────────────────────────
142
+ dispatch(req, res) {
143
+ const pathOnly = (req.url ?? "").split("?")[0];
144
+ if (req.method === "GET" && (pathOnly === "/health" || pathOnly === "/healthz")) {
145
+ this.handleHealth(req, res);
146
+ return;
147
+ }
148
+ if (!this.checkBearer(req)) {
149
+ this.sendJson(res, 401, { success: false, error: "unauthorized" });
150
+ return;
151
+ }
152
+ if (req.method === "POST" && pathOnly === "/event") {
153
+ void this.handlePostEvent(req, res);
154
+ return;
155
+ }
156
+ this.sendJson(res, 404, { success: false, error: "not found" });
157
+ }
158
+ checkBearer(req) {
159
+ const auth = req.headers["authorization"];
160
+ if (typeof auth !== "string") return false;
161
+ const m = auth.match(/^Bearer\s+(.+)$/i);
162
+ if (!m) return false;
163
+ const provided = m[1].trim();
164
+ try {
165
+ const providedHash = crypto.createHash("sha256").update(provided, "utf8").digest();
166
+ const expectedHash = crypto.createHash("sha256").update(this.bearerToken, "utf8").digest();
167
+ return crypto.timingSafeEqual(providedHash, expectedHash);
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+ handleHealth(_req, res) {
173
+ const session = this.handlers?.getActiveSession() ?? null;
174
+ this.sendJson(res, 200, {
175
+ success: true,
176
+ data: {
177
+ ok: true,
178
+ hasActiveSession: session !== null
179
+ }
180
+ });
181
+ }
182
+ async handlePostEvent(req, res) {
183
+ if (!this.handlers) {
184
+ this.sendJson(res, 503, { success: false, error: "server not ready" });
185
+ return;
186
+ }
187
+ let body;
188
+ try {
189
+ body = await readBody(req, 256 * 1024);
190
+ } catch (err) {
191
+ this.sendJson(res, 413, { success: false, error: "body too large or read failed" });
192
+ return;
193
+ }
194
+ let payload;
195
+ try {
196
+ payload = JSON.parse(body);
197
+ } catch {
198
+ this.sendJson(res, 400, { success: false, error: "invalid JSON" });
199
+ return;
200
+ }
201
+ if (!payload.sessionId || !payload.type || !payload.source || typeof payload.content !== "string") {
202
+ this.sendJson(res, 400, {
203
+ success: false,
204
+ error: "missing required fields: sessionId, type, source, content"
205
+ });
206
+ return;
207
+ }
208
+ if (!isValidEventType(payload.type) || !isValidEventSource(payload.source)) {
209
+ this.sendJson(res, 400, {
210
+ success: false,
211
+ error: "invalid type or source enum value"
212
+ });
213
+ return;
214
+ }
215
+ try {
216
+ const { eventId } = await this.handlers.pushEvent(payload);
217
+ this.sendJson(res, 200, { success: true, data: { eventId } });
218
+ } catch (err) {
219
+ logger.error("pushEvent failed in /event handler", { error: String(err) });
220
+ const code = err?.code;
221
+ if (code === "session-not-found") {
222
+ this.sendJson(res, 404, { success: false, error: "session not found" });
223
+ return;
224
+ }
225
+ this.sendJson(res, 500, { success: false, error: "event push failed" });
226
+ }
227
+ }
228
+ // ─── Helpers ────────────────────────────────────────────────────────────
229
+ async writePortFile() {
230
+ if (!this.portFilePath) return;
231
+ const content = JSON.stringify({
232
+ port: this.assignedPort,
233
+ token: this.bearerToken,
234
+ writtenAt: (/* @__PURE__ */ new Date()).toISOString()
235
+ });
236
+ const tmpPath = `${this.portFilePath}.tmp.${process.pid}`;
237
+ try {
238
+ await fs.promises.writeFile(tmpPath, content, { mode: 384 });
239
+ await fs.promises.chmod(tmpPath, 384);
240
+ await fs.promises.rename(tmpPath, this.portFilePath);
241
+ } catch (err) {
242
+ try {
243
+ await fs.promises.unlink(tmpPath);
244
+ } catch {
245
+ }
246
+ throw err;
247
+ }
248
+ }
249
+ sendJson(res, status, body) {
250
+ res.statusCode = status;
251
+ res.setHeader("Content-Type", "application/json");
252
+ res.end(JSON.stringify(body));
253
+ }
254
+ };
255
+ function isValidEventType(value) {
256
+ if (typeof value !== "string") return false;
257
+ return Object.values(import_codevibe_core2.EventType).includes(value);
258
+ }
259
+ function isValidEventSource(value) {
260
+ if (typeof value !== "string") return false;
261
+ return Object.values(import_codevibe_core2.EventSource).includes(value);
262
+ }
263
+ async function readBody(req, maxBytes) {
264
+ return new Promise((resolve3, reject) => {
265
+ let received = 0;
266
+ const chunks = [];
267
+ req.on("data", (chunk) => {
268
+ received += chunk.length;
269
+ if (received > maxBytes) {
270
+ req.destroy();
271
+ reject(new Error("body too large"));
272
+ return;
273
+ }
274
+ chunks.push(chunk);
275
+ });
276
+ req.on("end", () => resolve3(Buffer.concat(chunks).toString("utf8")));
277
+ req.on("error", reject);
278
+ });
279
+ }
280
+
281
+ // src/transcript-tailer.ts
282
+ var import_events = require("events");
283
+ var fs2 = __toESM(require("fs"));
284
+ var os2 = __toESM(require("os"));
285
+ var path2 = __toESM(require("path"));
286
+ var import_chokidar = require("chokidar");
287
+ var OFFSET_FLUSH_INTERVAL_MS = 3e4;
288
+ var LRU_CAP = 50;
289
+ var TRANSCRIPT_FILE_REGEX = /\/brain\/([0-9a-fA-F-]{36})\/\.system_generated\/logs\/transcript\.jsonl$/;
290
+ function defaultPaths() {
291
+ const home = os2.homedir();
292
+ return {
293
+ brainRoot: path2.join(home, ".gemini", "antigravity-cli", "brain"),
294
+ offsetFilePath: path2.join(home, ".codevibe", "codevibe-agy", "transcript-offsets.json")
295
+ };
296
+ }
297
+ var TranscriptTailer = class extends import_events.EventEmitter {
298
+ constructor(options = {}) {
299
+ super();
300
+ this.watcher = null;
301
+ this.isWatching = false;
302
+ this.isShuttingDown = false;
303
+ /** Per-file offset state (in-memory mirror of the persisted store). */
304
+ this.offsets = /* @__PURE__ */ new Map();
305
+ /** Conversation UUIDs we're actively tailing. v1.0 pins to single-active
306
+ * per design §3.5; if a second conversation UUID is observed, we WARN
307
+ * and skip (collision guard). */
308
+ this.activeConversations = /* @__PURE__ */ new Set();
309
+ /** Periodic offset flush timer. */
310
+ this.flushTimer = null;
311
+ /** Per-file read serialization (prevents concurrent reads of same file
312
+ * when `change` events fire in rapid succession). */
313
+ this.activeReads = /* @__PURE__ */ new Map();
314
+ this.pendingReads = /* @__PURE__ */ new Set();
315
+ /** Monotonic counter — used to short-circuit stale read loops after stop(). */
316
+ this.readGeneration = 0;
317
+ /** ms-epoch when start() was called. Files modified before this minus 5s
318
+ * are considered pre-existing and ignored unless they're being actively
319
+ * written to (mtime > startTime - 5s). */
320
+ this.startTime = 0;
321
+ const defaults = defaultPaths();
322
+ this.brainRoot = options.brainRoot ?? defaults.brainRoot;
323
+ this.offsetFilePath = options.offsetFilePath ?? defaults.offsetFilePath;
324
+ this.usePolling = options.usePolling ?? false;
325
+ }
326
+ /**
327
+ * Start watching the brain root. Loads persisted offsets, prunes orphans,
328
+ * then chokidar watches for new / changed transcript files.
329
+ */
330
+ start() {
331
+ if (this.isWatching) {
332
+ logger.warn("TranscriptTailer.start called while already watching");
333
+ return;
334
+ }
335
+ this.startTime = Date.now();
336
+ this.readGeneration++;
337
+ this.loadPersistedOffsets();
338
+ this.pruneOrphanedOffsets();
339
+ if (!fs2.existsSync(this.brainRoot)) {
340
+ try {
341
+ fs2.mkdirSync(this.brainRoot, { recursive: true });
342
+ logger.info("Created brain root", { brainRoot: this.brainRoot });
343
+ } catch (error) {
344
+ logger.warn("Could not create brain root (will retry implicitly)", {
345
+ brainRoot: this.brainRoot,
346
+ error
347
+ });
348
+ }
349
+ }
350
+ logger.info("Starting transcript tailer", {
351
+ brainRoot: this.brainRoot,
352
+ offsetFile: this.offsetFilePath,
353
+ loadedOffsets: this.offsets.size
354
+ });
355
+ this.watcher = (0, import_chokidar.watch)(this.brainRoot, {
356
+ persistent: true,
357
+ ignoreInitial: true,
358
+ depth: 4,
359
+ awaitWriteFinish: false,
360
+ // partial-line carryover handles partial writes
361
+ usePolling: this.usePolling,
362
+ interval: this.usePolling ? 50 : void 0,
363
+ ignored: (filePath) => {
364
+ const isDir = (() => {
365
+ try {
366
+ return fs2.statSync(filePath).isDirectory();
367
+ } catch {
368
+ return false;
369
+ }
370
+ })();
371
+ if (isDir) return false;
372
+ return !TRANSCRIPT_FILE_REGEX.test(filePath);
373
+ }
374
+ });
375
+ this.watcher.on("add", (filePath) => this.onFileAdded(filePath));
376
+ this.watcher.on("addDir", (dirPath) => this.onDirAdded(dirPath));
377
+ this.watcher.on("change", (filePath) => this.onFileChanged(filePath));
378
+ this.watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
379
+ this.watcher.on("error", (error) => {
380
+ logger.error("Chokidar error", { error });
381
+ this.emit("error", error instanceof Error ? error : new Error(String(error)));
382
+ });
383
+ this.watcher.on("ready", () => {
384
+ logger.info("Transcript tailer ready (chokidar initial scan complete)");
385
+ this.backfillActiveTranscripts();
386
+ });
387
+ this.flushTimer = setInterval(() => this.flushOffsets(), OFFSET_FLUSH_INTERVAL_MS);
388
+ if (this.flushTimer.unref) this.flushTimer.unref();
389
+ this.isWatching = true;
390
+ }
391
+ /**
392
+ * Stop watching. Flushes offsets one last time before closing chokidar.
393
+ * Idempotent.
394
+ */
395
+ async stop() {
396
+ if (!this.isWatching) return;
397
+ this.isShuttingDown = true;
398
+ this.readGeneration++;
399
+ if (this.flushTimer) {
400
+ clearInterval(this.flushTimer);
401
+ this.flushTimer = null;
402
+ }
403
+ try {
404
+ this.flushOffsets();
405
+ } catch (error) {
406
+ logger.warn("Final offset flush failed", { error });
407
+ }
408
+ if (this.watcher) {
409
+ try {
410
+ await this.watcher.close();
411
+ } catch (error) {
412
+ logger.warn("Error closing chokidar watcher", { error });
413
+ }
414
+ this.watcher = null;
415
+ }
416
+ this.activeReads.clear();
417
+ this.pendingReads.clear();
418
+ this.isWatching = false;
419
+ this.isShuttingDown = false;
420
+ logger.info("Transcript tailer stopped");
421
+ }
422
+ /**
423
+ * Best-effort sync flush. Safe to call from a process EXIT trap.
424
+ * (The async stop() above is preferred when a regular shutdown is feasible.)
425
+ */
426
+ syncFlush() {
427
+ try {
428
+ this.flushOffsets();
429
+ } catch (error) {
430
+ logger.warn("syncFlush failed", { error });
431
+ }
432
+ }
433
+ /** True if any conversation is currently being tailed. */
434
+ isActive() {
435
+ return this.activeConversations.size > 0;
436
+ }
437
+ /** Returns the set of currently-tailed conversation UUIDs (copy). */
438
+ getActiveConversations() {
439
+ return Array.from(this.activeConversations);
440
+ }
441
+ // ─── chokidar event handlers ─────────────────────────────────────────────
442
+ onFileAdded(filePath) {
443
+ if (this.isShuttingDown) return;
444
+ const conversationId = this.extractConversationId(filePath);
445
+ if (!conversationId) return;
446
+ if (this.isPreExisting(filePath)) {
447
+ logger.debug("Ignoring pre-existing transcript on `add`", { filePath });
448
+ return;
449
+ }
450
+ if (this.activeConversations.has(conversationId)) {
451
+ logger.debug("Conversation already active \u2014 ignoring duplicate add", {
452
+ conversationId,
453
+ filePath
454
+ });
455
+ return;
456
+ }
457
+ logger.info("New transcript file detected", { filePath, conversationId });
458
+ this.activeConversations.add(conversationId);
459
+ this.ensureOffset(filePath);
460
+ this.emit("transcript-add", filePath, conversationId);
461
+ void this.scheduleReadNewLines(filePath, conversationId);
462
+ }
463
+ onDirAdded(dirPath) {
464
+ if (this.isShuttingDown) return;
465
+ const base = path2.basename(dirPath);
466
+ if (!/^[0-9a-fA-F-]{36}$/.test(base)) return;
467
+ const conversationId = base;
468
+ const expectedParent = path2.dirname(dirPath);
469
+ if (path2.resolve(expectedParent) !== path2.resolve(this.brainRoot)) return;
470
+ logger.info("New brain directory discovered (pre-prompt)", { dirPath, conversationId });
471
+ this.emit("conversation-discovered", conversationId);
472
+ }
473
+ /**
474
+ * Fires when chokidar reports a transcript.jsonl was deleted or moved
475
+ * out from under us. Two scenarios (Codex HIGH-1 fix):
476
+ *
477
+ * (a) agy ended the conversation + cleaned up — the file is gone for
478
+ * good. We keep the offset entry in case the same conversation UUID
479
+ * gets recreated later (rare but possible), but log it.
480
+ *
481
+ * (b) Move-then-recreate (e.g. agy rotates the file) — chokidar will
482
+ * fire `add` shortly after for the new file at the same path. We
483
+ * proactively RESET the offset entry to byteOffset=0 + carryover=''
484
+ * so the next read picks up from the start of the new file. Without
485
+ * this, the next read would skip bytes 0..oldOffset of the new file.
486
+ */
487
+ onFileUnlinked(filePath) {
488
+ const conversationId = this.extractConversationId(filePath);
489
+ if (!conversationId) return;
490
+ const entry = this.offsets.get(filePath);
491
+ if (!entry) {
492
+ logger.debug("Unlink for unwatched path; ignoring", { filePath });
493
+ return;
494
+ }
495
+ logger.warn("Transcript file unlinked", {
496
+ filePath,
497
+ conversationId,
498
+ previousOffset: entry.byteOffset,
499
+ previousKnownSize: entry.lastKnownSize
500
+ });
501
+ entry.byteOffset = 0;
502
+ entry.carryover = "";
503
+ entry.lastKnownSize = 0;
504
+ entry.lastModifiedMs = Date.now();
505
+ this.activeConversations.delete(conversationId);
506
+ }
507
+ onFileChanged(filePath) {
508
+ if (this.isShuttingDown) return;
509
+ const conversationId = this.extractConversationId(filePath);
510
+ if (!conversationId) return;
511
+ if (!this.activeConversations.has(conversationId)) {
512
+ logger.info("Resuming transcript file via change event", {
513
+ filePath,
514
+ conversationId
515
+ });
516
+ this.activeConversations.add(conversationId);
517
+ this.ensureOffset(filePath);
518
+ this.emit("transcript-resume", filePath, conversationId);
519
+ }
520
+ void this.scheduleReadNewLines(filePath, conversationId);
521
+ }
522
+ // ─── Backfill at startup ────────────────────────────────────────────────
523
+ /**
524
+ * Walk the brain root once at startup and emit resume events for any
525
+ * transcript.jsonl that already exists and shows recent activity. This
526
+ * covers the case where agy is already running when codevibe-agy starts.
527
+ */
528
+ backfillActiveTranscripts() {
529
+ if (!fs2.existsSync(this.brainRoot)) return;
530
+ try {
531
+ const brainDirs = fs2.readdirSync(this.brainRoot, { withFileTypes: true });
532
+ for (const dir of brainDirs) {
533
+ if (!dir.isDirectory()) continue;
534
+ const conversationId = dir.name;
535
+ if (!/^[0-9a-fA-F-]{36}$/.test(conversationId)) continue;
536
+ const dirPath = path2.join(this.brainRoot, conversationId);
537
+ const transcriptPath = path2.join(
538
+ dirPath,
539
+ ".system_generated",
540
+ "logs",
541
+ "transcript.jsonl"
542
+ );
543
+ const hasTranscript = fs2.existsSync(transcriptPath);
544
+ try {
545
+ if (hasTranscript) {
546
+ const stat = fs2.statSync(transcriptPath);
547
+ if (stat.mtimeMs < this.startTime - 5 * 60 * 1e3) {
548
+ logger.debug("Skipping stale transcript at startup", {
549
+ transcriptPath,
550
+ ageMinutes: ((this.startTime - stat.mtimeMs) / 6e4).toFixed(1)
551
+ });
552
+ continue;
553
+ }
554
+ } else {
555
+ const stat = fs2.statSync(dirPath);
556
+ if (stat.mtimeMs < this.startTime - 5 * 60 * 1e3) {
557
+ logger.debug("Skipping stale transcriptless directory at startup", {
558
+ dirPath,
559
+ ageMinutes: ((this.startTime - stat.mtimeMs) / 6e4).toFixed(1)
560
+ });
561
+ continue;
562
+ }
563
+ }
564
+ } catch {
565
+ continue;
566
+ }
567
+ if (hasTranscript) {
568
+ if (this.activeConversations.has(conversationId)) continue;
569
+ logger.info("Backfilling resumed transcript at startup", {
570
+ transcriptPath,
571
+ conversationId
572
+ });
573
+ this.activeConversations.add(conversationId);
574
+ this.ensureOffset(transcriptPath);
575
+ this.emit("transcript-resume", transcriptPath, conversationId);
576
+ void this.scheduleReadNewLines(transcriptPath, conversationId);
577
+ } else {
578
+ logger.info("Backfilling recent transcriptless directory at startup", {
579
+ dirPath,
580
+ conversationId
581
+ });
582
+ this.emit("conversation-discovered", conversationId);
583
+ }
584
+ }
585
+ } catch (error) {
586
+ logger.warn("Backfill scan failed", { error });
587
+ }
588
+ }
589
+ // ─── Read serialization ─────────────────────────────────────────────────
590
+ scheduleReadNewLines(filePath, conversationId) {
591
+ const inflight = this.activeReads.get(filePath);
592
+ if (inflight) {
593
+ this.pendingReads.add(filePath);
594
+ return inflight;
595
+ }
596
+ const generation = this.readGeneration;
597
+ const read = this.drainReadQueue(filePath, conversationId, generation);
598
+ this.activeReads.set(filePath, read);
599
+ void read.finally(() => {
600
+ if (this.activeReads.get(filePath) === read) {
601
+ this.activeReads.delete(filePath);
602
+ this.pendingReads.delete(filePath);
603
+ }
604
+ });
605
+ return read;
606
+ }
607
+ async drainReadQueue(filePath, conversationId, generation) {
608
+ do {
609
+ this.pendingReads.delete(filePath);
610
+ await this.readNewLines(filePath, conversationId, generation);
611
+ } while (generation === this.readGeneration && this.pendingReads.has(filePath));
612
+ }
613
+ async readNewLines(filePath, conversationId, generation) {
614
+ if (generation !== this.readGeneration) return;
615
+ const entry = this.offsets.get(filePath);
616
+ if (!entry) {
617
+ logger.warn("No offset entry \u2014 skipping read", { filePath });
618
+ return;
619
+ }
620
+ let stat;
621
+ try {
622
+ stat = fs2.statSync(filePath);
623
+ } catch (error) {
624
+ logger.warn("stat failed; file may have been deleted", { filePath, error });
625
+ return;
626
+ }
627
+ if (stat.size < entry.lastKnownSize) {
628
+ logger.warn("Transcript file rotated (size shrunk); resetting offset", {
629
+ filePath,
630
+ previousOffset: entry.byteOffset,
631
+ previousKnownSize: entry.lastKnownSize,
632
+ currentSize: stat.size
633
+ });
634
+ entry.byteOffset = 0;
635
+ entry.carryover = "";
636
+ entry.lastKnownSize = stat.size;
637
+ this.emit("transcript-rotated", filePath, conversationId);
638
+ }
639
+ const carryoverBytes = Buffer.byteLength(entry.carryover, "utf-8");
640
+ const readStart = entry.byteOffset + carryoverBytes;
641
+ if (stat.size <= readStart) {
642
+ entry.lastKnownSize = stat.size;
643
+ entry.lastModifiedMs = stat.mtimeMs;
644
+ return;
645
+ }
646
+ let stream;
647
+ try {
648
+ stream = fs2.createReadStream(filePath, {
649
+ start: readStart,
650
+ encoding: "utf-8"
651
+ });
652
+ } catch (error) {
653
+ logger.error("createReadStream failed", { filePath, error });
654
+ this.emit("error", error instanceof Error ? error : new Error(String(error)));
655
+ return;
656
+ }
657
+ let buffered = entry.carryover;
658
+ let lineStartOffset = entry.byteOffset;
659
+ try {
660
+ for await (const chunk of stream) {
661
+ if (generation !== this.readGeneration) {
662
+ stream.destroy();
663
+ return;
664
+ }
665
+ buffered += chunk;
666
+ let newlineIndex = buffered.indexOf("\n");
667
+ while (newlineIndex !== -1) {
668
+ if (generation !== this.readGeneration) {
669
+ stream.destroy();
670
+ return;
671
+ }
672
+ const rawLine = buffered.slice(0, newlineIndex);
673
+ const completeRecord = buffered.slice(0, newlineIndex + 1);
674
+ const recordBytes = Buffer.byteLength(completeRecord, "utf-8");
675
+ const lineByteOffset = lineStartOffset;
676
+ buffered = buffered.slice(newlineIndex + 1);
677
+ lineStartOffset += recordBytes;
678
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
679
+ if (line.trim()) {
680
+ this.parseAndEmitLine(line, filePath, conversationId, lineByteOffset);
681
+ }
682
+ newlineIndex = buffered.indexOf("\n");
683
+ }
684
+ }
685
+ } catch (error) {
686
+ logger.error("Error reading transcript stream", { filePath, error });
687
+ this.emit("error", error instanceof Error ? error : new Error(String(error)));
688
+ return;
689
+ }
690
+ if (generation !== this.readGeneration) return;
691
+ entry.byteOffset = lineStartOffset;
692
+ entry.carryover = buffered;
693
+ entry.lastKnownSize = stat.size;
694
+ entry.lastModifiedMs = stat.mtimeMs;
695
+ }
696
+ parseAndEmitLine(line, filePath, conversationId, byteOffset) {
697
+ let event;
698
+ try {
699
+ event = JSON.parse(line);
700
+ } catch (error) {
701
+ logger.warn("Failed to parse transcript line as JSON", {
702
+ filePath,
703
+ linePreview: line.substring(0, 120),
704
+ error
705
+ });
706
+ return;
707
+ }
708
+ if (typeof event.step_index !== "number" || typeof event.type !== "string") {
709
+ logger.warn("Transcript line missing required fields", {
710
+ filePath,
711
+ linePreview: line.substring(0, 120),
712
+ hasStepIndex: typeof event.step_index,
713
+ hasType: typeof event.type
714
+ });
715
+ return;
716
+ }
717
+ const emit = { conversationId, filePath, event, byteOffset };
718
+ this.emit("event", emit);
719
+ }
720
+ // ─── Offset persistence ─────────────────────────────────────────────────
721
+ ensureOffset(filePath) {
722
+ if (this.offsets.has(filePath)) return;
723
+ if (this.offsets.size >= LRU_CAP) {
724
+ this.evictOldestOffset();
725
+ }
726
+ let stat = null;
727
+ try {
728
+ stat = fs2.statSync(filePath);
729
+ } catch {
730
+ }
731
+ this.offsets.set(filePath, {
732
+ byteOffset: 0,
733
+ carryover: "",
734
+ lastKnownSize: stat?.size ?? 0,
735
+ lastModifiedMs: stat?.mtimeMs ?? Date.now()
736
+ });
737
+ }
738
+ /**
739
+ * Pick the oldest-mtime offset entry that is NOT for a currently-active
740
+ * conversation, and evict it. Active conversations are PROTECTED — losing
741
+ * their offset would mean re-reading the whole file from byte 0 and
742
+ * duplicating events to AppSync. (Codex MED-4 fix.)
743
+ *
744
+ * If ALL entries are active (very unusual — would require 51 concurrent
745
+ * agy sessions in the same wrapper, which the §3.5 collision guard
746
+ * prevents), log a warning and skip eviction. The cap is then a soft
747
+ * cap for the duration of the wrapper.
748
+ */
749
+ evictOldestOffset() {
750
+ let oldestPath = null;
751
+ let oldestMtime = Number.POSITIVE_INFINITY;
752
+ for (const [p, entry] of this.offsets.entries()) {
753
+ const cid = this.extractConversationId(p);
754
+ if (cid && this.activeConversations.has(cid)) {
755
+ continue;
756
+ }
757
+ if (entry.lastModifiedMs < oldestMtime) {
758
+ oldestMtime = entry.lastModifiedMs;
759
+ oldestPath = p;
760
+ }
761
+ }
762
+ if (oldestPath) {
763
+ logger.info("LRU evicting transcript offset (inactive)", {
764
+ evictedPath: oldestPath,
765
+ cap: LRU_CAP
766
+ });
767
+ this.offsets.delete(oldestPath);
768
+ } else {
769
+ logger.warn("LRU cap reached but ALL entries are active; cap is soft", {
770
+ cap: LRU_CAP,
771
+ size: this.offsets.size,
772
+ activeConversations: this.activeConversations.size
773
+ });
774
+ }
775
+ }
776
+ loadPersistedOffsets() {
777
+ if (!fs2.existsSync(this.offsetFilePath)) return;
778
+ try {
779
+ const raw = fs2.readFileSync(this.offsetFilePath, "utf-8");
780
+ const store = JSON.parse(raw);
781
+ if (store.version !== 1 || !store.entries) {
782
+ logger.warn("Unrecognized offset file version; ignoring", {
783
+ version: store.version
784
+ });
785
+ return;
786
+ }
787
+ for (const [filePath, entry] of Object.entries(store.entries)) {
788
+ this.offsets.set(filePath, {
789
+ byteOffset: entry.byteOffset ?? 0,
790
+ carryover: "",
791
+ lastKnownSize: entry.lastKnownSize ?? 0,
792
+ lastModifiedMs: entry.lastModifiedMs ?? 0
793
+ });
794
+ }
795
+ logger.info("Loaded persisted offsets", { count: this.offsets.size });
796
+ } catch (error) {
797
+ logger.warn("Failed to load persisted offsets \u2014 starting fresh", { error });
798
+ }
799
+ }
800
+ pruneOrphanedOffsets() {
801
+ let pruned = 0;
802
+ for (const filePath of Array.from(this.offsets.keys())) {
803
+ if (!fs2.existsSync(filePath)) {
804
+ this.offsets.delete(filePath);
805
+ pruned++;
806
+ }
807
+ }
808
+ if (pruned > 0) {
809
+ logger.info("Pruned orphan offsets at startup", { pruned });
810
+ }
811
+ }
812
+ flushOffsets() {
813
+ if (this.offsets.size === 0) {
814
+ return;
815
+ }
816
+ try {
817
+ const offsetDir = path2.dirname(this.offsetFilePath);
818
+ if (!fs2.existsSync(offsetDir)) {
819
+ fs2.mkdirSync(offsetDir, { recursive: true, mode: 448 });
820
+ }
821
+ const store = {
822
+ version: 1,
823
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
824
+ entries: {}
825
+ };
826
+ for (const [filePath, entry] of this.offsets.entries()) {
827
+ store.entries[filePath] = {
828
+ byteOffset: entry.byteOffset,
829
+ carryover: "",
830
+ // never persist carryover; see loadPersistedOffsets
831
+ lastKnownSize: entry.lastKnownSize,
832
+ lastModifiedMs: entry.lastModifiedMs
833
+ };
834
+ }
835
+ const tmpPath = this.offsetFilePath + ".tmp";
836
+ fs2.writeFileSync(tmpPath, JSON.stringify(store, null, 2), { mode: 384 });
837
+ fs2.renameSync(tmpPath, this.offsetFilePath);
838
+ } catch (error) {
839
+ logger.warn("Failed to flush transcript offsets", { error });
840
+ }
841
+ }
842
+ // ─── Helpers ────────────────────────────────────────────────────────────
843
+ extractConversationId(filePath) {
844
+ const m = TRANSCRIPT_FILE_REGEX.exec(filePath);
845
+ return m ? m[1] : null;
846
+ }
847
+ isPreExisting(filePath) {
848
+ try {
849
+ const stat = fs2.statSync(filePath);
850
+ const ageMs = this.startTime - stat.mtimeMs;
851
+ return ageMs > 5e3 && stat.mtimeMs < this.startTime - 5e3 && (stat.birthtimeMs ?? stat.ctimeMs) < this.startTime - 5e3;
852
+ } catch {
853
+ return false;
854
+ }
855
+ }
856
+ };
857
+
858
+ // src/tmux-pane-observer.ts
859
+ var fs3 = __toESM(require("fs"));
860
+ var os3 = __toESM(require("os"));
861
+ var path3 = __toESM(require("path"));
862
+ var import_crypto = require("crypto");
863
+ var import_child_process = require("child_process");
864
+ var import_events2 = require("events");
865
+ var import_util = require("util");
866
+ var execAsync = (0, import_util.promisify)(import_child_process.exec);
867
+ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitter {
868
+ constructor(options = {}) {
869
+ super();
870
+ this.sessionName = null;
871
+ this.started = false;
872
+ this.filePosition = 0;
873
+ this.watcher = null;
874
+ this.processing = false;
875
+ this.pendingRead = false;
876
+ /** Monotonic counter — incremented on each start() so in-flight async
877
+ * operations from a prior session/start cycle can detect they're stale
878
+ * and abort before emitting. (Codex tmux-observer MED-2.) */
879
+ this.generation = 0;
880
+ /** sha256 of the last emitted prompt-candidate snapshot. Prevents
881
+ * re-emitting when pane content is unchanged (e.g. when noise
882
+ * somewhere else in the pane triggers fs.watch). */
883
+ this.lastPromptHash = null;
884
+ /** True if the previous tick saw an approval UI active in the pane.
885
+ * Used to fire 'prompt-cleared' when the UI vanishes between ticks. */
886
+ this.approvalUIActive = false;
887
+ /** Cached header text from the last detected approval (used by the
888
+ * prompt-responder's active-prompt-identity guard). */
889
+ this.currentApprovalHeader = null;
890
+ this.pendingEmitCandidate = null;
891
+ this.debounceTimer = null;
892
+ /** ms-epoch when the CURRENT debounce window opened (first prompt-bearing
893
+ * snapshot since the last flush). Used to enforce DEBOUNCE_MAX_WAIT_MS. */
894
+ this.debounceWindowOpenedAt = null;
895
+ /** Monotonic cycle counter — incremented on every call to
896
+ * `scheduleDebouncedEmit` (a new burst window) and on every
897
+ * `cancelDebounce`. Each `flushDebouncedEmit` captures this value
898
+ * at its scheduling moment; on every await boundary, it compares
899
+ * the captured value against the current cycle id and bails if
900
+ * they differ. Required to close the race where an OLDER flush
901
+ * is mid-await while a NEWER cycle has been scheduled AND already
902
+ * cleared `debounceTimer` (so a `debounceTimer !== null` check
903
+ * fails to detect the newer cycle). `generation` only tracks
904
+ * start()/stop() lifecycle and doesn't distinguish debounce
905
+ * cycles. (Stage 2 iter-4 R1 HIGH finding 2026-05-21.) */
906
+ this.debounceCycleId = 0;
907
+ this.pipeFilePath = options.pipeFilePath ?? path3.join(os3.tmpdir(), `codevibe-agy-pane-${process.pid}.log`);
908
+ this.skipTmuxCommands = options.skipTmuxCommands ?? false;
909
+ this.captureFn = options.captureFn ?? null;
910
+ }
911
+ static {
912
+ // ─── Debounce state (collapses repaint bursts into one emit) ────────────
913
+ //
914
+ // agy redraws the approval UI multiple times in rapid succession after
915
+ // the prompt first appears (spinner ticks, layout finalization, footer
916
+ // re-emit). Each redraw produces a different pane snapshot — different
917
+ // sha256 — and would otherwise fire a fresh `prompt-candidate`. We
918
+ // debounce here so that consecutive prompt-bearing snapshots within
919
+ // DEBOUNCE_QUIET_MS of each other collapse into a single emit of the
920
+ // LATEST snapshot. DEBOUNCE_MAX_WAIT_MS is a safety cap so that if
921
+ // agy never goes idle (e.g. an animated spinner inside the approval
922
+ // UI), we still emit eventually rather than starving the user.
923
+ // Correctness >> latency for approval prompts: agy users wait many
924
+ // seconds for the model to plan the action; an extra ~200ms before
925
+ // the prompt reaches mobile is invisible to the user.
926
+ this.DEBOUNCE_QUIET_MS = 200;
927
+ }
928
+ static {
929
+ this.DEBOUNCE_MAX_WAIT_MS = 600;
930
+ }
931
+ static {
932
+ /** Extra settle delay applied to Question UI snapshots inside
933
+ * flushDebouncedEmit. agy renders question option lists
934
+ * progressively (verified empirically when a 6-option prompt
935
+ * emitted to mobile with only 5 options). Re-capturing once after
936
+ * this delay catches options painted after the initial settle.
937
+ * Approvals don't need this — their option count is fixed at 4
938
+ * and they paint atomically. */
939
+ this.QUESTION_SETTLE_DELAY_MS = 100;
940
+ }
941
+ /**
942
+ * Start observing the tmux pane targeted by `sessionName`. If sessionName
943
+ * is omitted, read $CODEVIBE_AGY_TMUX_TARGET from env (set by the
944
+ * wrapper). Calling twice SEQUENTIALLY with the same session is a no-op;
945
+ * with a different session is a transparent restart.
946
+ *
947
+ * **Contract assumption** (Codex tmux-observer-r4 LOW): concurrent
948
+ * (overlapping in time) `start()` calls with the SAME session name are
949
+ * NOT supported. If start A is canceled by start B with the same target,
950
+ * A's generation-mismatch cleanup may disable B's just-enabled pipe.
951
+ * In our `codevibe-agy` wrapper, this is contract-impossible: the
952
+ * wrapper exec's agy ONCE per process with a unique PID-bound target,
953
+ * so observer.start() is called exactly once.
954
+ *
955
+ * Concurrent calls with DIFFERENT session names ARE supported via the
956
+ * existing stop()-then-start() restart path.
957
+ */
958
+ async start(sessionName) {
959
+ const targetSession = sessionName ?? process.env.CODEVIBE_AGY_TMUX_TARGET;
960
+ if (!targetSession) {
961
+ throw new Error(
962
+ "TmuxPaneObserver.start: no session name given and CODEVIBE_AGY_TMUX_TARGET not set"
963
+ );
964
+ }
965
+ if (this.started && this.sessionName === targetSession) {
966
+ logger.debug("Tmux pane observer already started", { sessionName: targetSession });
967
+ return;
968
+ }
969
+ if (this.started) {
970
+ await this.stop();
971
+ }
972
+ this.generation += 1;
973
+ const myGeneration = this.generation;
974
+ this.sessionName = targetSession;
975
+ this.filePosition = 0;
976
+ this.lastPromptHash = null;
977
+ this.approvalUIActive = false;
978
+ this.currentApprovalHeader = null;
979
+ try {
980
+ fs3.mkdirSync(path3.dirname(this.pipeFilePath), { recursive: true });
981
+ fs3.writeFileSync(this.pipeFilePath, "");
982
+ if (!this.skipTmuxCommands) {
983
+ await this.enablePipePane();
984
+ }
985
+ if (this.generation !== myGeneration) {
986
+ if (!this.skipTmuxCommands) {
987
+ try {
988
+ const escapedSession = escapeShellArg(targetSession);
989
+ await execAsync(`tmux pipe-pane -t '${escapedSession}'`);
990
+ } catch (cleanupError) {
991
+ logger.debug("Failed to disable tmux pipe-pane during canceled start (best-effort)", {
992
+ error: cleanupError
993
+ });
994
+ }
995
+ }
996
+ if (this.sessionName === targetSession) {
997
+ this.sessionName = null;
998
+ }
999
+ return;
1000
+ }
1001
+ this.startFileWatcher();
1002
+ this.started = true;
1003
+ } catch (error) {
1004
+ this.sessionName = null;
1005
+ this.started = false;
1006
+ if (this.watcher) {
1007
+ this.watcher.close();
1008
+ this.watcher = null;
1009
+ }
1010
+ throw error;
1011
+ }
1012
+ logger.info("Tmux pane observer started", {
1013
+ sessionName: targetSession,
1014
+ pipeFilePath: this.pipeFilePath
1015
+ });
1016
+ }
1017
+ async stop() {
1018
+ this.generation += 1;
1019
+ if (!this.started) return;
1020
+ this.started = false;
1021
+ this.cancelDebounce();
1022
+ if (!this.skipTmuxCommands) {
1023
+ try {
1024
+ await this.disablePipePane();
1025
+ } catch (error) {
1026
+ logger.debug("Failed to disable tmux pipe-pane cleanly", { error });
1027
+ }
1028
+ }
1029
+ if (this.watcher) {
1030
+ this.watcher.close();
1031
+ this.watcher = null;
1032
+ }
1033
+ try {
1034
+ fs3.unlinkSync(this.pipeFilePath);
1035
+ } catch {
1036
+ }
1037
+ logger.info("Tmux pane observer stopped", { sessionName: this.sessionName });
1038
+ this.sessionName = null;
1039
+ this.filePosition = 0;
1040
+ this.processing = false;
1041
+ this.pendingRead = false;
1042
+ this.lastPromptHash = null;
1043
+ this.approvalUIActive = false;
1044
+ this.currentApprovalHeader = null;
1045
+ }
1046
+ /**
1047
+ * Returns the current pane snapshot (best-effort). Used by approval-detector
1048
+ * for the active-prompt-identity guard right before prompt-responder sends
1049
+ * keys (DESIGN.md §3.4: "re-check active-prompt-identity immediately before
1050
+ * send; if no active prompt or different identity, drop").
1051
+ */
1052
+ async captureSnapshot(lines = 120) {
1053
+ if (!this.sessionName) {
1054
+ throw new Error("Tmux pane observer is not started");
1055
+ }
1056
+ if (this.captureFn) {
1057
+ return this.captureFn(this.sessionName, lines);
1058
+ }
1059
+ const safeLines = Math.max(1, Math.floor(lines));
1060
+ const escapedSession = escapeShellArg(this.sessionName);
1061
+ const cmd = `tmux capture-pane -p -e -S -${safeLines} -t '${escapedSession}'`;
1062
+ try {
1063
+ const { stdout } = await execAsync(cmd);
1064
+ return stdout;
1065
+ } catch (error) {
1066
+ logger.error("Failed to capture tmux pane snapshot", {
1067
+ sessionName: this.sessionName,
1068
+ error
1069
+ });
1070
+ this.emit("observer-error", error instanceof Error ? error : new Error(String(error)));
1071
+ throw error;
1072
+ }
1073
+ }
1074
+ /**
1075
+ * Snapshot of "is an approval UI currently visible in the pane?". Cheap —
1076
+ * returns cached state from the last fs.watch tick. For freshness-critical
1077
+ * uses (e.g. just-before-send-keys), call probeApprovalUIActive() instead
1078
+ * which does a fresh capture.
1079
+ */
1080
+ isApprovalUIActive() {
1081
+ return this.approvalUIActive;
1082
+ }
1083
+ /**
1084
+ * Fresh check via tmux capture-pane. More expensive than isApprovalUIActive()
1085
+ * but reflects state as of NOW. Returns { active, headerText } so the caller
1086
+ * can verify the active prompt's identity matches the expected one.
1087
+ */
1088
+ async probeApprovalUIActive() {
1089
+ try {
1090
+ const snapshot = await this.captureSnapshot();
1091
+ const active = looksLikePromptSnapshot(snapshot);
1092
+ let headerText = null;
1093
+ if (active) {
1094
+ const kind = detectActivePromptKind(snapshot);
1095
+ if (kind === "question") {
1096
+ const q = extractQuestionHeader(snapshot);
1097
+ headerText = q ? q.body : null;
1098
+ } else if (kind === "approval") {
1099
+ headerText = extractHeader(snapshot);
1100
+ }
1101
+ }
1102
+ return { active, headerText };
1103
+ } catch {
1104
+ return { active: false, headerText: null };
1105
+ }
1106
+ }
1107
+ // ─── tmux pipe-pane plumbing ─────────────────────────────────────────────
1108
+ async enablePipePane() {
1109
+ if (!this.sessionName) {
1110
+ throw new Error("Tmux pane observer is not initialized");
1111
+ }
1112
+ const escapedSession = escapeShellArg(this.sessionName);
1113
+ const escapedFile = escapeShellArg(this.pipeFilePath);
1114
+ const cmd = `tmux pipe-pane -O -t '${escapedSession}' "cat >> '${escapedFile}'"`;
1115
+ await execAsync(cmd);
1116
+ logger.debug("Enabled tmux pipe-pane mirroring", {
1117
+ sessionName: this.sessionName,
1118
+ pipeFilePath: this.pipeFilePath
1119
+ });
1120
+ }
1121
+ async disablePipePane() {
1122
+ if (!this.sessionName) return;
1123
+ const escapedSession = escapeShellArg(this.sessionName);
1124
+ const cmd = `tmux pipe-pane -t '${escapedSession}'`;
1125
+ await execAsync(cmd);
1126
+ }
1127
+ startFileWatcher() {
1128
+ this.watcher = fs3.watch(this.pipeFilePath, (eventType) => {
1129
+ if (eventType !== "change") return;
1130
+ void this.processFileChanges();
1131
+ });
1132
+ }
1133
+ /**
1134
+ * Coalesce a prompt-bearing snapshot into the pending debounce window.
1135
+ * Each new candidate within the quiet-window replaces the prior one
1136
+ * (we always emit the LATEST snapshot — that's the settled state agy
1137
+ * is showing). A MAX_WAIT cap ensures we still emit even if agy never
1138
+ * goes idle (e.g. an animated spinner inside the approval UI).
1139
+ */
1140
+ scheduleDebouncedEmit(candidate) {
1141
+ const now = Date.now();
1142
+ this.pendingEmitCandidate = candidate;
1143
+ if (this.debounceWindowOpenedAt === null) {
1144
+ this.debounceWindowOpenedAt = now;
1145
+ }
1146
+ const elapsed = now - this.debounceWindowOpenedAt;
1147
+ const cappedRemaining = Math.max(0, _TmuxPaneObserver.DEBOUNCE_MAX_WAIT_MS - elapsed);
1148
+ const waitMs = Math.min(_TmuxPaneObserver.DEBOUNCE_QUIET_MS, cappedRemaining);
1149
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
1150
+ this.debounceCycleId += 1;
1151
+ const myGeneration = this.generation;
1152
+ const myCycleId = this.debounceCycleId;
1153
+ this.debounceTimer = setTimeout(() => {
1154
+ void this.flushDebouncedEmit(myGeneration, myCycleId);
1155
+ }, waitMs);
1156
+ if (this.debounceTimer.unref) this.debounceTimer.unref();
1157
+ }
1158
+ /**
1159
+ * Fire the deferred `prompt-candidate` emit if (a) we're still in the
1160
+ * same generation as when the timer was scheduled and (b) the
1161
+ * observer is still started.
1162
+ *
1163
+ * Re-captures the pane snapshot here, BEFORE emitting. The staged
1164
+ * candidate's snapshot was taken inside the most recent
1165
+ * `processFileChanges` tick during the burst — which may have been
1166
+ * mid-paint if agy was still rendering options or footer content.
1167
+ * By flush time the pane has been quiet for ≥ DEBOUNCE_QUIET_MS, so
1168
+ * a fresh capture reflects the SETTLED state. (Empirical
1169
+ * 2026-05-21: a Question UI with 6 options on desktop was emitted
1170
+ * to mobile with only 5 because the staged snapshot was captured
1171
+ * before agy painted the last option.)
1172
+ *
1173
+ * Re-applies the lastPromptHash dedup against the FRESH hash so a
1174
+ * snapshot that ends up identical to the previous emission doesn't
1175
+ * fire a redundant candidate.
1176
+ */
1177
+ async flushDebouncedEmit(scheduledGeneration, scheduledCycleId) {
1178
+ if (this.generation !== scheduledGeneration || !this.started) return;
1179
+ if (this.debounceCycleId !== scheduledCycleId) return;
1180
+ const staged = this.pendingEmitCandidate;
1181
+ this.pendingEmitCandidate = null;
1182
+ this.debounceTimer = null;
1183
+ this.debounceWindowOpenedAt = null;
1184
+ if (!staged) return;
1185
+ let candidate = staged;
1186
+ try {
1187
+ const freshSnapshot = await this.captureSnapshot();
1188
+ if (freshSnapshot && looksLikePromptSnapshot(freshSnapshot)) {
1189
+ candidate = {
1190
+ rawDelta: staged.rawDelta,
1191
+ snapshot: freshSnapshot,
1192
+ detectedAt: Date.now(),
1193
+ snapshotHash: hashPromptSnapshot(freshSnapshot)
1194
+ };
1195
+ }
1196
+ if (isQuestionUISnapshot(candidate.snapshot)) {
1197
+ const firstHeader = extractQuestionHeader(candidate.snapshot);
1198
+ await sleep(_TmuxPaneObserver.QUESTION_SETTLE_DELAY_MS);
1199
+ if (this.generation !== scheduledGeneration || !this.started) return;
1200
+ if (this.debounceCycleId !== scheduledCycleId) return;
1201
+ try {
1202
+ const settled = await this.captureSnapshot();
1203
+ if (this.generation !== scheduledGeneration || !this.started) return;
1204
+ if (this.debounceCycleId !== scheduledCycleId) return;
1205
+ const settledHeader = settled ? extractQuestionHeader(settled) : null;
1206
+ const sameBody = !!firstHeader && !!settledHeader && firstHeader.body === settledHeader.body;
1207
+ if (settled && looksLikePromptSnapshot(settled) && detectActivePromptKind(settled) === "question" && sameBody) {
1208
+ candidate = {
1209
+ rawDelta: staged.rawDelta,
1210
+ snapshot: settled,
1211
+ detectedAt: Date.now(),
1212
+ snapshotHash: hashPromptSnapshot(settled)
1213
+ };
1214
+ }
1215
+ } catch {
1216
+ }
1217
+ }
1218
+ } catch {
1219
+ }
1220
+ if (this.generation !== scheduledGeneration || !this.started) return;
1221
+ if (this.debounceCycleId !== scheduledCycleId) return;
1222
+ if (candidate.snapshotHash === this.lastPromptHash) return;
1223
+ this.lastPromptHash = candidate.snapshotHash;
1224
+ this.approvalUIActive = true;
1225
+ this.currentApprovalHeader = extractHeader(candidate.snapshot);
1226
+ this.emit("prompt-candidate", candidate);
1227
+ }
1228
+ /** Cancel any in-flight debounce timer + drop the pending candidate.
1229
+ * Called from stop() so a wrapper exit / restart doesn't fire a
1230
+ * prompt for a torn-down observer. Also bumps debounceCycleId so
1231
+ * any in-flight flushDebouncedEmit currently awaiting a capture
1232
+ * sees a cycle-id mismatch at its next guard and bails — without
1233
+ * this bump, a flush mid-sleep at stop() time could resume and
1234
+ * emit because the generation guard alone is delayed by a
1235
+ * separate start()/stop() cycle (which is what `generation`
1236
+ * tracks). */
1237
+ cancelDebounce() {
1238
+ if (this.debounceTimer) {
1239
+ clearTimeout(this.debounceTimer);
1240
+ this.debounceTimer = null;
1241
+ }
1242
+ this.pendingEmitCandidate = null;
1243
+ this.debounceWindowOpenedAt = null;
1244
+ this.debounceCycleId += 1;
1245
+ }
1246
+ /**
1247
+ * Public hook for tests / drivers: trigger a read+process cycle as if
1248
+ * fs.watch had fired. Not invoked in production except via the
1249
+ * watcher.
1250
+ *
1251
+ * In production, processFileChanges schedules a debounced emit via
1252
+ * setTimeout(DEBOUNCE_QUIET_MS); the timer fires after the pane
1253
+ * goes quiet, at which point flushDebouncedEmit re-captures and
1254
+ * emits. Tests using tick() expect the emit to be observable
1255
+ * synchronously after await — so we drain any pending debounce
1256
+ * here, end-to-end, so tests don't have to coordinate with the
1257
+ * real timeout.
1258
+ */
1259
+ async tick() {
1260
+ await this.processFileChanges();
1261
+ if (this.debounceTimer) {
1262
+ clearTimeout(this.debounceTimer);
1263
+ this.debounceTimer = null;
1264
+ await this.flushDebouncedEmit(this.generation, this.debounceCycleId);
1265
+ }
1266
+ }
1267
+ async processFileChanges() {
1268
+ if (this.processing) {
1269
+ this.pendingRead = true;
1270
+ return;
1271
+ }
1272
+ if (!this.started) return;
1273
+ this.processing = true;
1274
+ const myGeneration = this.generation;
1275
+ try {
1276
+ do {
1277
+ if (this.generation !== myGeneration || !this.started) return;
1278
+ this.pendingRead = false;
1279
+ const chunk = this.readAppendedChunk();
1280
+ if (!chunk) continue;
1281
+ if (!looksLikePromptDelta(chunk)) {
1282
+ if (this.approvalUIActive) {
1283
+ await this.checkPromptCleared(myGeneration);
1284
+ }
1285
+ continue;
1286
+ }
1287
+ const snapshot = await this.captureSnapshot();
1288
+ if (this.generation !== myGeneration || !this.started) return;
1289
+ if (!snapshot || !looksLikePromptSnapshot(snapshot)) {
1290
+ if (this.approvalUIActive) {
1291
+ await this.checkPromptCleared(myGeneration);
1292
+ }
1293
+ continue;
1294
+ }
1295
+ const snapshotHash = hashPromptSnapshot(snapshot);
1296
+ if (snapshotHash === this.lastPromptHash) {
1297
+ continue;
1298
+ }
1299
+ if (this.generation !== myGeneration || !this.started) return;
1300
+ this.scheduleDebouncedEmit({
1301
+ rawDelta: chunk,
1302
+ snapshot,
1303
+ detectedAt: Date.now(),
1304
+ snapshotHash
1305
+ });
1306
+ } while (this.pendingRead);
1307
+ } catch (error) {
1308
+ if (this.generation !== myGeneration || !this.started) return;
1309
+ logger.error("Failed to process tmux pane changes", { error });
1310
+ this.emit("observer-error", error instanceof Error ? error : new Error(String(error)));
1311
+ } finally {
1312
+ if (this.generation === myGeneration) {
1313
+ this.processing = false;
1314
+ }
1315
+ }
1316
+ }
1317
+ /** Called when we suspect the approval UI may have vanished. Does a
1318
+ * fresh capture; if the pane no longer shows approval UI, fires
1319
+ * 'prompt-cleared'. Generation-checked to suppress emit after stop(). */
1320
+ async checkPromptCleared(myGeneration) {
1321
+ try {
1322
+ const snapshot = await this.captureSnapshot();
1323
+ if (this.generation !== myGeneration || !this.started) return;
1324
+ if (!looksLikePromptSnapshot(snapshot)) {
1325
+ this.approvalUIActive = false;
1326
+ this.lastPromptHash = null;
1327
+ this.currentApprovalHeader = null;
1328
+ this.emit("prompt-cleared");
1329
+ }
1330
+ } catch {
1331
+ }
1332
+ }
1333
+ readAppendedChunk() {
1334
+ const stat = fs3.statSync(this.pipeFilePath);
1335
+ if (stat.size <= this.filePosition) return "";
1336
+ const fd = fs3.openSync(this.pipeFilePath, "r");
1337
+ try {
1338
+ const length = stat.size - this.filePosition;
1339
+ const buffer = Buffer.alloc(length);
1340
+ fs3.readSync(fd, buffer, 0, length, this.filePosition);
1341
+ this.filePosition = stat.size;
1342
+ return buffer.toString("utf-8");
1343
+ } finally {
1344
+ fs3.closeSync(fd);
1345
+ }
1346
+ }
1347
+ };
1348
+ function looksLikePromptDelta(chunk) {
1349
+ return (
1350
+ // Approval UI signatures
1351
+ /Requesting permission for:/i.test(chunk) || /Do you want to proceed\?/i.test(chunk) || /\besc to cancel\b/i.test(chunk) || /^\s*[1-9]\. (?:Yes|No)\b/im.test(chunk) || /\[(?:y\/n|Y\/n|y\/N)\]/.test(chunk) || /Question \d+\/\d+:/i.test(chunk) || /\benter Select\b/i.test(chunk)
1352
+ );
1353
+ }
1354
+ function looksLikePromptSnapshot(snapshot) {
1355
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1356
+ const recent = stripped.split("\n").slice(-30).join("\n");
1357
+ const hasApprovalHeader = /Requesting permission for:/i.test(recent) || /Do you want to proceed\?/i.test(recent);
1358
+ const hasApprovalOptions = /^\s*[1-9]\. (?:Yes|No)\b/im.test(recent);
1359
+ const hasApprovalFooter = /\besc to cancel\b/i.test(recent);
1360
+ const isApproval = hasApprovalHeader && hasApprovalOptions && hasApprovalFooter;
1361
+ const hasQuestionHeader = /Question \d+\/\d+:/i.test(recent);
1362
+ const hasQuestionOptions = /^[>\s]+[1-9]\. \S/im.test(recent);
1363
+ const hasQuestionFooter = /\benter Select\b/i.test(recent);
1364
+ const isQuestion = hasQuestionHeader && hasQuestionOptions && hasQuestionFooter;
1365
+ const legacyYN = /\[(?:y\/n|Y\/n|y\/N)\]/.test(recent) && /\b(?:apply|approve|allow|continue|proceed|run|execute|confirm)\b/i.test(recent);
1366
+ return legacyYN || isApproval || isQuestion;
1367
+ }
1368
+ function extractHeader(snapshot) {
1369
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1370
+ const matches = Array.from(stripped.matchAll(/Requesting permission for:\s*([^\n]+)/gi));
1371
+ if (matches.length === 0) return null;
1372
+ return matches[matches.length - 1][1].trim();
1373
+ }
1374
+ function extractFullHeaderLine(snapshot) {
1375
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1376
+ const matches = Array.from(stripped.matchAll(/Requesting permission for:\s*([^\n]+)/gi));
1377
+ if (matches.length === 0) return null;
1378
+ return matches[matches.length - 1][0].trim();
1379
+ }
1380
+ function detectActivePromptKind(snapshot) {
1381
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1382
+ const lines = stripped.split("\n");
1383
+ const approvalRe = /Requesting permission for:/i;
1384
+ const questionRe = /Question \d+\/\d+:/i;
1385
+ let lastApprovalLineIdx = -1;
1386
+ let lastQuestionLineIdx = -1;
1387
+ for (let i = lines.length - 1; i >= 0; i--) {
1388
+ if (lastApprovalLineIdx < 0 && approvalRe.test(lines[i])) {
1389
+ lastApprovalLineIdx = i;
1390
+ }
1391
+ if (lastQuestionLineIdx < 0 && questionRe.test(lines[i])) {
1392
+ lastQuestionLineIdx = i;
1393
+ }
1394
+ if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0) break;
1395
+ }
1396
+ if (lastApprovalLineIdx < 0 && lastQuestionLineIdx < 0) return null;
1397
+ if (lastQuestionLineIdx > lastApprovalLineIdx) return "question";
1398
+ return "approval";
1399
+ }
1400
+ function extractQuestionHeader(snapshot) {
1401
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1402
+ const matches = Array.from(stripped.matchAll(/Question \d+\/\d+:\s*([^\n]+)/gi));
1403
+ if (matches.length === 0) return null;
1404
+ const last = matches[matches.length - 1];
1405
+ return { fullLine: last[0].trim(), body: last[1].trim() };
1406
+ }
1407
+ function hashPromptSnapshot(snapshot) {
1408
+ const normalized = snapshot.replace(ANSI_ESCAPE_REGEX, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/[ \t]+\n/g, "\n").trim();
1409
+ return (0, import_crypto.createHash)("sha256").update(normalized).digest("hex");
1410
+ }
1411
+ var ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
1412
+ function escapeShellArg(value) {
1413
+ return value.replace(/'/g, `'\\''`);
1414
+ }
1415
+ function sleep(ms) {
1416
+ return new Promise((resolve3) => {
1417
+ const t = setTimeout(resolve3, ms);
1418
+ if (t.unref) t.unref();
1419
+ });
1420
+ }
1421
+ function isQuestionUISnapshot(snapshot) {
1422
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1423
+ return /Question \d+\/\d+:/i.test(stripped);
1424
+ }
1425
+
1426
+ // src/approval-detector.ts
1427
+ var import_events3 = require("events");
1428
+ var import_uuid = require("uuid");
1429
+
1430
+ // src/prompt-parser.ts
1431
+ function parseApprovalSnapshot(snapshot) {
1432
+ if (!snapshot) return null;
1433
+ const stripped = stripAnsi(snapshot);
1434
+ const kind = detectActivePromptKind(stripped);
1435
+ if (kind === "question") {
1436
+ const questionHeader = extractQuestionHeader(stripped);
1437
+ if (questionHeader) {
1438
+ return parseQuestionSnapshot(stripped, snapshot, questionHeader);
1439
+ }
1440
+ }
1441
+ return parseApprovalUISnapshot(stripped, snapshot);
1442
+ }
1443
+ function parseApprovalUISnapshot(stripped, originalSnapshot) {
1444
+ const headerText = extractHeader(stripped);
1445
+ if (!headerText) return null;
1446
+ const fullHeaderLine = extractFullHeaderLine(stripped) ?? headerText;
1447
+ const lines = stripped.split("\n");
1448
+ let headerIdx = -1;
1449
+ for (let i = lines.length - 1; i >= 0; i--) {
1450
+ if (lines[i].includes("Requesting permission for:")) {
1451
+ headerIdx = i;
1452
+ break;
1453
+ }
1454
+ }
1455
+ const belowHeader = headerIdx >= 0 ? lines.slice(headerIdx + 1).join("\n") : stripped;
1456
+ const options = parseOptions(belowHeader);
1457
+ if (options.length < 2) return null;
1458
+ const submitMap = buildApprovalSubmitMap(options);
1459
+ const { command, filePath } = extractIdentity(headerText);
1460
+ return {
1461
+ kind: "approval",
1462
+ headerText,
1463
+ fullHeaderLine,
1464
+ command,
1465
+ filePath,
1466
+ options,
1467
+ submitMap,
1468
+ paneHash: hashPromptSnapshot(originalSnapshot)
1469
+ };
1470
+ }
1471
+ function parseQuestionSnapshot(stripped, originalSnapshot, header) {
1472
+ const lines = stripped.split("\n");
1473
+ let headerIdx = -1;
1474
+ for (let i = lines.length - 1; i >= 0; i--) {
1475
+ if (/Question \d+\/\d+:/i.test(lines[i])) {
1476
+ headerIdx = i;
1477
+ break;
1478
+ }
1479
+ }
1480
+ const belowHeader = headerIdx >= 0 ? lines.slice(headerIdx + 1).join("\n") : stripped;
1481
+ const options = parseOptions(belowHeader);
1482
+ if (options.length < 2) return null;
1483
+ const submitMap = buildQuestionSubmitMap(options);
1484
+ return {
1485
+ kind: "question",
1486
+ headerText: header.body,
1487
+ fullHeaderLine: header.fullLine,
1488
+ command: void 0,
1489
+ filePath: void 0,
1490
+ options,
1491
+ submitMap,
1492
+ paneHash: hashPromptSnapshot(originalSnapshot)
1493
+ };
1494
+ }
1495
+ function parseOptions(snapshot) {
1496
+ const recent = snapshot.split("\n").slice(-30);
1497
+ const optionRegex = /^[>\s]*([1-9])\. (.+?)\s*$/;
1498
+ const found = /* @__PURE__ */ new Map();
1499
+ for (const line of recent) {
1500
+ const m = line.match(optionRegex);
1501
+ if (!m) continue;
1502
+ const num = m[1];
1503
+ found.set(num, m[2].trim());
1504
+ }
1505
+ return Array.from(found.entries()).sort(([a], [b]) => Number(a) - Number(b)).map(([number, text]) => ({ number, text }));
1506
+ }
1507
+ function buildApprovalSubmitMap(options) {
1508
+ const map = {};
1509
+ for (const opt of options) {
1510
+ map[opt.number] = [opt.number];
1511
+ }
1512
+ return map;
1513
+ }
1514
+ function buildQuestionSubmitMap(options) {
1515
+ const map = {};
1516
+ for (const opt of options) {
1517
+ map[opt.number] = [opt.number, "Enter"];
1518
+ }
1519
+ return map;
1520
+ }
1521
+ function extractIdentity(headerText) {
1522
+ if (!headerText) return {};
1523
+ const trimmed = headerText.trim();
1524
+ if (trimmed.startsWith("/") || trimmed.startsWith("~/") || trimmed.startsWith("./") || /^[A-Za-z]:[\\/]/.test(trimmed)) {
1525
+ if (!/\s/.test(trimmed)) {
1526
+ return { filePath: trimmed };
1527
+ }
1528
+ }
1529
+ const firstWord = trimmed.split(/\s+/, 1)[0].toLowerCase();
1530
+ const KNOWN_COMMANDS = /* @__PURE__ */ new Set([
1531
+ // Shell builtins / coreutils
1532
+ "rm",
1533
+ "mv",
1534
+ "cp",
1535
+ "cat",
1536
+ "ls",
1537
+ "mkdir",
1538
+ "rmdir",
1539
+ "touch",
1540
+ "echo",
1541
+ "pwd",
1542
+ "chmod",
1543
+ "chown",
1544
+ "find",
1545
+ "grep",
1546
+ "sed",
1547
+ "awk",
1548
+ "tar",
1549
+ "curl",
1550
+ "wget",
1551
+ "tee",
1552
+ "head",
1553
+ "tail",
1554
+ "cut",
1555
+ "sort",
1556
+ "uniq",
1557
+ "wc",
1558
+ "diff",
1559
+ "patch",
1560
+ // Common dev tools
1561
+ "git",
1562
+ "npm",
1563
+ "pnpm",
1564
+ "yarn",
1565
+ "bun",
1566
+ "node",
1567
+ "python",
1568
+ "python3",
1569
+ "pip",
1570
+ "cargo",
1571
+ "rustc",
1572
+ "go",
1573
+ "gradle",
1574
+ "mvn",
1575
+ "bash",
1576
+ "sh",
1577
+ "zsh",
1578
+ "make",
1579
+ "docker",
1580
+ "podman",
1581
+ "kubectl",
1582
+ "helm",
1583
+ "terraform",
1584
+ "aws",
1585
+ "gcloud",
1586
+ // Editors (rarely intercepted but possible)
1587
+ "vim",
1588
+ "nvim",
1589
+ "nano",
1590
+ "code"
1591
+ ]);
1592
+ if (KNOWN_COMMANDS.has(firstWord)) {
1593
+ return { command: trimmed };
1594
+ }
1595
+ if (/\./.test(firstWord) && !/\s/.test(trimmed)) {
1596
+ return { filePath: trimmed };
1597
+ }
1598
+ return { command: trimmed };
1599
+ }
1600
+ function guessToolTypeFromIdentity(c) {
1601
+ if (c.command) return "RUN_COMMAND";
1602
+ if (c.filePath) return "CODE_ACTION";
1603
+ return "GENERIC";
1604
+ }
1605
+ var ANSI_ESCAPE_REGEX2 = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
1606
+ function stripAnsi(s) {
1607
+ return s.replace(ANSI_ESCAPE_REGEX2, "");
1608
+ }
1609
+
1610
+ // src/approval-detector.ts
1611
+ var DEFAULT_PROMPT_TTL_MS = 6e4;
1612
+ var PENDING_CALL_TTL_MS = 5 * 6e4;
1613
+ var SWEEP_INTERVAL_MS = 3e4;
1614
+ var PASS3_MIN_IDENTITY_LEN = 4;
1615
+ var PENDING_CANDIDATE_BUFFER_MS = 5e3;
1616
+ var ApprovalDetector = class extends import_events3.EventEmitter {
1617
+ constructor(options = {}) {
1618
+ super();
1619
+ this.pendingCalls = /* @__PURE__ */ new Map();
1620
+ this.pendingPrompts = /* @__PURE__ */ new Map();
1621
+ /** Track recently-resolved promptIds so a duplicate candidate-emit
1622
+ * doesn't re-fire INTERACTIVE_PROMPT. Maps promptId → ms-epoch when
1623
+ * resolved. Pruned by sweep. */
1624
+ this.resolvedPrompts = /* @__PURE__ */ new Map();
1625
+ /** Pre-transcript candidate buffer keyed by `${conversationId}|${paneHash}`
1626
+ * (composite key prevents collision when two conversations on the same
1627
+ * tmux pane produce identical paneHashes — agy R2 Stage 2 round-3 MED).
1628
+ * When a candidate arrives with no matching pending call, we park it
1629
+ * here for pendingCandidateBufferMs and re-try the match each time
1630
+ * registerPendingCall fires. */
1631
+ this.pendingCandidates = /* @__PURE__ */ new Map();
1632
+ this.sweepTimer = null;
1633
+ // ─── Pane-only prompt emission (v9.1 — bypasses pending-call match) ─────
1634
+ /** Set of pane-snapshot hashes we've already emitted INTERACTIVE_PROMPT
1635
+ * for. Belt-and-suspenders dedupe against the case where paneObserver
1636
+ * somehow re-fires `prompt-candidate` with the same snapshot bytes
1637
+ * for the same prompt. The primary dedupe for cosmetic redraws of a
1638
+ * single approval (spinner ticks, cursor blink, partial repaints)
1639
+ * lives in tmux-pane-observer's debounce window — by the time a
1640
+ * candidate reaches this layer, the pane has settled. */
1641
+ this.paneOnlyEmittedHashes = /* @__PURE__ */ new Set();
1642
+ this.promptTtlMs = options.promptTtlMs ?? DEFAULT_PROMPT_TTL_MS;
1643
+ this.pendingCallTtlMs = options.pendingCallTtlMs ?? PENDING_CALL_TTL_MS;
1644
+ this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
1645
+ this.pendingCandidateBufferMs = options.pendingCandidateBufferMs ?? PENDING_CANDIDATE_BUFFER_MS;
1646
+ }
1647
+ start() {
1648
+ if (this.sweepTimer) return;
1649
+ this.sweepTimer = setInterval(() => this.sweep(), this.sweepIntervalMs);
1650
+ if (this.sweepTimer.unref) this.sweepTimer.unref();
1651
+ logger.info("Approval detector started", {
1652
+ promptTtlMs: this.promptTtlMs,
1653
+ pendingCallTtlMs: this.pendingCallTtlMs
1654
+ });
1655
+ }
1656
+ async stop() {
1657
+ if (this.sweepTimer) {
1658
+ clearInterval(this.sweepTimer);
1659
+ this.sweepTimer = null;
1660
+ }
1661
+ this.pendingCalls.clear();
1662
+ this.pendingPrompts.clear();
1663
+ this.resolvedPrompts.clear();
1664
+ this.pendingCandidates.clear();
1665
+ this.paneOnlyEmittedHashes.clear();
1666
+ logger.info("Approval detector stopped");
1667
+ }
1668
+ /**
1669
+ * Emit INTERACTIVE_PROMPT directly from a pane snapshot, WITHOUT
1670
+ * requiring a matching PendingToolCall from a transcript PLANNER_RESPONSE.
1671
+ *
1672
+ * Background (v9.1, 2026-05-21): agy does NOT write tool_call entries
1673
+ * to the transcript BEFORE rendering the approval UI for shell
1674
+ * commands. It only writes them AFTER the user approves. So the
1675
+ * transcript-based pending-call matching path deadlocks for shell
1676
+ * commands: paneObserver sees the UI, but no pending call exists →
1677
+ * candidate buffered → user can't approve from mobile (no prompt
1678
+ * shown) → no transcript write → buffer expires.
1679
+ *
1680
+ * Solution: pane is authoritative for "approval UI is showing right
1681
+ * now." Skip the pending-call requirement and emit directly. The
1682
+ * parsed candidate has everything needed (command, options, submitMap).
1683
+ *
1684
+ * Synthesizes a minimal PendingToolCall from the candidate so the
1685
+ * existing handlePendingPrompt code path doesn't need to branch on
1686
+ * `pendingCall: null`.
1687
+ *
1688
+ * Returns the emitted state, or null if dedupe (already emitted for
1689
+ * this paneHash) or disabled.
1690
+ */
1691
+ emitPaneOnlyPrompt(candidate, conversationId) {
1692
+ if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
1693
+ logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
1694
+ paneHash: candidate.paneHash.substring(0, 16)
1695
+ });
1696
+ return null;
1697
+ }
1698
+ const promptId = (0, import_uuid.v4)();
1699
+ const syntheticCall = {
1700
+ conversationId,
1701
+ intentStepIndex: -1,
1702
+ // sentinel: "no transcript origin"
1703
+ toolCallIndex: 0,
1704
+ intentByteOffset: 0,
1705
+ // Best-effort tool-type inference from the candidate. For shell
1706
+ // commands → RUN_COMMAND. For edit-style headers with a file
1707
+ // path → CODE_ACTION. Default: GENERIC.
1708
+ toolType: guessToolTypeFromIdentity(candidate),
1709
+ command: candidate.command,
1710
+ filePath: candidate.filePath,
1711
+ rawToolName: candidate.command ?? candidate.filePath ?? "unknown",
1712
+ promptId,
1713
+ startedAt: Date.now(),
1714
+ intentCreatedAt: (/* @__PURE__ */ new Date()).toISOString()
1715
+ };
1716
+ const state = {
1717
+ promptId,
1718
+ conversationId,
1719
+ pendingCall: syntheticCall,
1720
+ submitMap: candidate.submitMap,
1721
+ matchedPaneHeader: candidate.headerText,
1722
+ paneDisplayHeader: candidate.fullHeaderLine,
1723
+ emittedAt: Date.now(),
1724
+ ttlMs: this.promptTtlMs,
1725
+ paneOptions: candidate.options
1726
+ };
1727
+ this.pendingPrompts.set(promptId, state);
1728
+ this.paneOnlyEmittedHashes.add(candidate.paneHash);
1729
+ logger.info("Emitting pane-only INTERACTIVE_PROMPT", {
1730
+ promptId,
1731
+ command: candidate.command,
1732
+ filePath: candidate.filePath,
1733
+ optionsCount: candidate.options.length
1734
+ });
1735
+ this.emit("pending-prompt", state);
1736
+ return state;
1737
+ }
1738
+ // ─── Pending-call registration (called from event-mapper hook) ──────────
1739
+ /**
1740
+ * Register a tool-call intent from a PLANNER_RESPONSE.tool_calls[].
1741
+ * Indexed by composite key so agy rollback (which can replay
1742
+ * step_index values) doesn't cause collisions.
1743
+ */
1744
+ registerPendingCall(call) {
1745
+ const key = this.callKey(call);
1746
+ if (this.pendingCalls.has(key)) {
1747
+ logger.debug("Pending call already registered, skipping", {
1748
+ key,
1749
+ intentStepIndex: call.intentStepIndex,
1750
+ toolType: call.toolType
1751
+ });
1752
+ return;
1753
+ }
1754
+ this.pendingCalls.set(key, call);
1755
+ logger.debug("Pending tool call registered", {
1756
+ key,
1757
+ toolType: call.toolType,
1758
+ command: call.command,
1759
+ filePath: call.filePath
1760
+ });
1761
+ this.drainBufferedCandidates(call.conversationId);
1762
+ }
1763
+ drainBufferedCandidates(conversationId) {
1764
+ if (this.pendingCandidates.size === 0) return;
1765
+ const now = Date.now();
1766
+ for (const [key, buffered] of this.pendingCandidates.entries()) {
1767
+ if (buffered.conversationId !== conversationId) continue;
1768
+ if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
1769
+ this.pendingCandidates.delete(key);
1770
+ continue;
1771
+ }
1772
+ const state = this.matchCandidateInternal(buffered.candidate, buffered.conversationId);
1773
+ if (state) {
1774
+ this.pendingCandidates.delete(key);
1775
+ logger.info("Drained buffered candidate after pending-call arrived", {
1776
+ key,
1777
+ ageMs: now - buffered.queuedAt
1778
+ });
1779
+ }
1780
+ }
1781
+ }
1782
+ /** Composite key for the pre-transcript buffer. Scoping by
1783
+ * conversationId prevents the collision case where two simultaneous
1784
+ * conversations on the same tmux pane (contract-discouraged but not
1785
+ * contract-impossible) produce identical paneHashes — without
1786
+ * conversation scoping, conv-B's candidate would be silently dropped
1787
+ * at the same-key dedupe check. (agy R2 Stage 2 round-3 MED finding
1788
+ * 2026-05-20.) */
1789
+ candidateKey(conversationId, paneHash) {
1790
+ return `${conversationId}|${paneHash}`;
1791
+ }
1792
+ /**
1793
+ * Clear a pending call when its matching RESULT event arrives in the
1794
+ * transcript. event-mapper.ts calls this from its tool-result handlers.
1795
+ *
1796
+ * The result event's step_index = intentStepIndex + toolCallIndex + 1
1797
+ * per agy R2 v2 (the deterministic correlation formula). We try to
1798
+ * match by (conversationId, expected step_index, toolType) — when one
1799
+ * matches, clear it. If multiple pending calls match (rollback edge),
1800
+ * clear the earliest by intentStepIndex.
1801
+ */
1802
+ clearOnResultEvent(args) {
1803
+ let matched = null;
1804
+ let matchedKey = null;
1805
+ for (const [key, call] of this.pendingCalls.entries()) {
1806
+ if (call.conversationId !== args.conversationId) continue;
1807
+ if (call.toolType !== args.toolType) continue;
1808
+ const expected = call.intentStepIndex + call.toolCallIndex + 1;
1809
+ if (expected !== args.resultStepIndex) continue;
1810
+ if (typeof args.resultByteOffset === "number") {
1811
+ if (call.intentByteOffset >= args.resultByteOffset) continue;
1812
+ if (!matched || call.intentByteOffset > matched.intentByteOffset) {
1813
+ matched = call;
1814
+ matchedKey = key;
1815
+ }
1816
+ } else if (!matched || call.intentStepIndex < matched.intentStepIndex) {
1817
+ matched = call;
1818
+ matchedKey = key;
1819
+ }
1820
+ }
1821
+ if (matched && matchedKey) {
1822
+ matched.resultStepIndex = args.resultStepIndex;
1823
+ this.pendingCalls.delete(matchedKey);
1824
+ if (matched.promptId) {
1825
+ this.resolvedPrompts.set(matched.promptId, Date.now());
1826
+ this.pendingPrompts.delete(matched.promptId);
1827
+ }
1828
+ logger.debug("Pending tool call cleared on result event", {
1829
+ intentStepIndex: matched.intentStepIndex,
1830
+ toolCallIndex: matched.toolCallIndex,
1831
+ resultStepIndex: args.resultStepIndex,
1832
+ toolType: args.toolType
1833
+ });
1834
+ }
1835
+ return matched;
1836
+ }
1837
+ // ─── Candidate matching (called from tmux-pane-observer's prompt-candidate) ─
1838
+ /**
1839
+ * Cross-reference a parsed ApprovalCandidate against the pending-call
1840
+ * list. Returns the matched PendingPromptState (which the MCP server
1841
+ * uses to create the INTERACTIVE_PROMPT event), or null if no match
1842
+ * (stale scrollback).
1843
+ *
1844
+ * Match algorithm:
1845
+ * 1. Filter pendingCalls by candidate.command (if set) — exact match
1846
+ * against PendingToolCall.command.
1847
+ * 2. Fallback: filter by candidate.filePath (if set) — exact match
1848
+ * against PendingToolCall.filePath.
1849
+ * 3. If multiple candidates match (rare), prefer the earliest
1850
+ * intentStepIndex.
1851
+ * 4. If a match exists AND it already has a promptId AND that
1852
+ * promptId is in resolvedPrompts → null (don't re-emit).
1853
+ * 5. If a match exists AND has a promptId BUT NOT resolved → return
1854
+ * the existing PendingPromptState (idempotent re-emit on the same
1855
+ * promptId for a duplicate candidate hash).
1856
+ * 6. Otherwise: create a fresh promptId + PendingPromptState, attach
1857
+ * to the matched call, store in pendingPrompts, return it.
1858
+ */
1859
+ matchCandidate(candidate, conversationId) {
1860
+ const state = this.matchCandidateInternal(candidate, conversationId);
1861
+ if (state) return state;
1862
+ const existing = this.pendingCandidates.get(this.candidateKey(conversationId, candidate.paneHash));
1863
+ if (existing) {
1864
+ logger.debug("Candidate already buffered (same paneHash)", { paneHash: this.candidateKey(conversationId, candidate.paneHash) });
1865
+ } else {
1866
+ this.pendingCandidates.set(this.candidateKey(conversationId, candidate.paneHash), {
1867
+ candidate,
1868
+ conversationId,
1869
+ queuedAt: Date.now()
1870
+ });
1871
+ logger.debug("No pending tool-call matched \u2014 buffering candidate for pre-transcript window", {
1872
+ candidateCommand: candidate.command,
1873
+ candidateFilePath: candidate.filePath,
1874
+ pendingCount: this.pendingCalls.size,
1875
+ bufferSize: this.pendingCandidates.size
1876
+ });
1877
+ }
1878
+ return null;
1879
+ }
1880
+ /** Internal match logic — does NOT buffer on miss. Used both by the
1881
+ * public matchCandidate (which buffers on miss) and by
1882
+ * drainBufferedCandidates (which doesn't re-buffer; misses just stay
1883
+ * in the buffer until their TTL expires). */
1884
+ matchCandidateInternal(candidate, conversationId) {
1885
+ const matched = this.findMatchingPendingCall(candidate, conversationId);
1886
+ if (!matched) {
1887
+ return null;
1888
+ }
1889
+ if (matched.promptId && this.resolvedPrompts.has(matched.promptId)) {
1890
+ logger.debug("Skipping re-emit for already-resolved prompt", {
1891
+ promptId: matched.promptId
1892
+ });
1893
+ return null;
1894
+ }
1895
+ if (matched.promptId && this.pendingPrompts.has(matched.promptId)) {
1896
+ return this.pendingPrompts.get(matched.promptId) ?? null;
1897
+ }
1898
+ const promptId = (0, import_uuid.v4)();
1899
+ matched.promptId = promptId;
1900
+ const state = {
1901
+ promptId,
1902
+ conversationId,
1903
+ pendingCall: matched,
1904
+ submitMap: candidate.submitMap,
1905
+ matchedPaneHeader: candidate.headerText,
1906
+ paneDisplayHeader: candidate.fullHeaderLine,
1907
+ emittedAt: Date.now(),
1908
+ ttlMs: this.promptTtlMs
1909
+ };
1910
+ this.pendingPrompts.set(promptId, state);
1911
+ logger.info("Emitting INTERACTIVE_PROMPT for matched candidate", {
1912
+ promptId,
1913
+ command: matched.command,
1914
+ filePath: matched.filePath,
1915
+ intentStepIndex: matched.intentStepIndex,
1916
+ toolCallIndex: matched.toolCallIndex
1917
+ });
1918
+ this.emit("pending-prompt", state);
1919
+ return state;
1920
+ }
1921
+ /** Resolve a pending prompt — called from MCP server when mobile sends
1922
+ * the option-number reply with metadata.promptId. */
1923
+ /**
1924
+ * Called from the MCP server when paneObserver emits 'prompt-cleared'
1925
+ * — i.e. the approval UI disappeared from the pane (desktop user
1926
+ * picked an option, agy timed out the prompt, or the dispatcher
1927
+ * answered). Resolves ALL outstanding pane-only pending prompts and
1928
+ * releases the paneOnly dedup hashes so a subsequent approval can
1929
+ * fresh-emit. Without this, mobile prompt state dangles until TTL
1930
+ * AND the next identical approval is forever-suppressed by stale
1931
+ * dedup. (Stage 2 R1 MED finding 2026-05-21.)
1932
+ */
1933
+ onPanePromptCleared() {
1934
+ if (this.pendingPrompts.size === 0 && this.paneOnlyEmittedHashes.size === 0) {
1935
+ return;
1936
+ }
1937
+ const now = Date.now();
1938
+ const cleared = [];
1939
+ for (const [promptId] of this.pendingPrompts.entries()) {
1940
+ this.resolvedPrompts.set(promptId, now);
1941
+ cleared.push(promptId);
1942
+ }
1943
+ this.pendingPrompts.clear();
1944
+ this.paneOnlyEmittedHashes.clear();
1945
+ if (cleared.length > 0) {
1946
+ logger.info("Pane prompt cleared \u2014 resolved pending prompts", {
1947
+ count: cleared.length
1948
+ });
1949
+ }
1950
+ }
1951
+ resolvePrompt(promptId) {
1952
+ const state = this.pendingPrompts.get(promptId);
1953
+ if (!state) return null;
1954
+ this.resolvedPrompts.set(promptId, Date.now());
1955
+ this.pendingPrompts.delete(promptId);
1956
+ this.paneOnlyEmittedHashes.clear();
1957
+ return state;
1958
+ }
1959
+ /**
1960
+ * Roll back a pending prompt WITHOUT marking it as resolved. Used by
1961
+ * the MCP server when the INTERACTIVE_PROMPT createEvent fails — the
1962
+ * prompt was never delivered to mobile, so a future tmux pane
1963
+ * candidate for the same pending tool-call should be eligible to
1964
+ * re-emit. `resolvePrompt` is the WRONG call there because it adds
1965
+ * the promptId to `resolvedPrompts`, which the stale-scrollback guard
1966
+ * uses to suppress re-emit forever. This method instead detaches the
1967
+ * promptId from the pending call + removes from pendingPrompts +
1968
+ * leaves resolvedPrompts UNTOUCHED so the fresh-emit path runs on
1969
+ * the next match. (agy R2 Stage 2 round-2 MED finding 2026-05-20.)
1970
+ */
1971
+ rollbackPrompt(promptId) {
1972
+ const state = this.pendingPrompts.get(promptId);
1973
+ if (!state) return null;
1974
+ this.pendingPrompts.delete(promptId);
1975
+ if (state.pendingCall.promptId === promptId) {
1976
+ state.pendingCall.promptId = void 0;
1977
+ }
1978
+ this.paneOnlyEmittedHashes.clear();
1979
+ return state;
1980
+ }
1981
+ /** Returns the current pending-prompt state by id, or null if expired /
1982
+ * unknown. Used by prompt-responder for the active-prompt-identity
1983
+ * guard right before send-keys. */
1984
+ getPendingPrompt(promptId) {
1985
+ return this.pendingPrompts.get(promptId) ?? null;
1986
+ }
1987
+ // ─── Read-only accessors ────────────────────────────────────────────────
1988
+ getPendingCalls() {
1989
+ return Array.from(this.pendingCalls.values());
1990
+ }
1991
+ getActivePromptCount() {
1992
+ return this.pendingPrompts.size;
1993
+ }
1994
+ // ─── Internals ──────────────────────────────────────────────────────────
1995
+ callKey(call) {
1996
+ return `${call.conversationId}|${call.intentStepIndex}|${call.toolCallIndex}|${call.intentByteOffset}`;
1997
+ }
1998
+ findMatchingPendingCall(candidate, conversationId) {
1999
+ if (candidate.command) {
2000
+ const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.command && c.command === candidate.command).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
2001
+ if (matches.length > 0) return matches[0];
2002
+ }
2003
+ if (candidate.filePath) {
2004
+ const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.filePath && c.filePath === candidate.filePath).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
2005
+ if (matches.length > 0) return matches[0];
2006
+ }
2007
+ const allFields = (candidate.command ?? "") + " " + (candidate.filePath ?? "");
2008
+ if (allFields.trim()) {
2009
+ const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => {
2010
+ const cmd = (c.command ?? "").trim();
2011
+ const fp = (c.filePath ?? "").trim();
2012
+ if (!cmd && !fp) return false;
2013
+ const cmdOk = cmd.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, cmd);
2014
+ const fpOk = fp.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, fp);
2015
+ return cmdOk || fpOk;
2016
+ }).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
2017
+ if (matches.length > 0) return matches[0];
2018
+ }
2019
+ return null;
2020
+ }
2021
+ sweep() {
2022
+ const now = Date.now();
2023
+ for (const [key, call] of this.pendingCalls.entries()) {
2024
+ if (now - call.startedAt > this.pendingCallTtlMs) {
2025
+ this.pendingCalls.delete(key);
2026
+ if (call.promptId) {
2027
+ this.pendingPrompts.delete(call.promptId);
2028
+ this.resolvedPrompts.set(call.promptId, now);
2029
+ }
2030
+ logger.warn("Pending tool call expired (no matching result event)", {
2031
+ intentStepIndex: call.intentStepIndex,
2032
+ toolCallIndex: call.toolCallIndex,
2033
+ command: call.command,
2034
+ filePath: call.filePath,
2035
+ ageMs: now - call.startedAt
2036
+ });
2037
+ this.emit("pending-call-expired", call);
2038
+ }
2039
+ }
2040
+ let anyExpired = false;
2041
+ for (const [promptId, state] of this.pendingPrompts.entries()) {
2042
+ if (now - state.emittedAt > state.ttlMs) {
2043
+ this.pendingPrompts.delete(promptId);
2044
+ this.resolvedPrompts.set(promptId, now);
2045
+ anyExpired = true;
2046
+ logger.debug("Pending prompt expired (TTL)", {
2047
+ promptId,
2048
+ ageMs: now - state.emittedAt
2049
+ });
2050
+ }
2051
+ }
2052
+ if (anyExpired) {
2053
+ this.paneOnlyEmittedHashes.clear();
2054
+ }
2055
+ for (const [promptId, resolvedAt] of this.resolvedPrompts.entries()) {
2056
+ if (now - resolvedAt > 5 * 6e4) {
2057
+ this.resolvedPrompts.delete(promptId);
2058
+ }
2059
+ }
2060
+ for (const [key, buffered] of this.pendingCandidates.entries()) {
2061
+ if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
2062
+ this.pendingCandidates.delete(key);
2063
+ logger.debug("Buffered candidate expired without ever matching a pending call", {
2064
+ key,
2065
+ candidateCommand: buffered.candidate.command,
2066
+ ageMs: now - buffered.queuedAt
2067
+ });
2068
+ }
2069
+ }
2070
+ }
2071
+ };
2072
+ function containsAsWord(haystack, needle) {
2073
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2074
+ return new RegExp(`(^|[\\s"'\`])${escaped}([\\s"'\`]|$)`).test(haystack);
2075
+ }
2076
+
2077
+ // src/prompt-responder.ts
2078
+ var import_child_process2 = require("child_process");
2079
+ var import_util2 = require("util");
2080
+ var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
2081
+ var ENTER_DELAY_MS = 100;
2082
+ var CODEVIBE_AGY_ESCAPE_INPUT = "__CODEVIBE_AGY_KEY_ESCAPE__";
2083
+ var PromptResponder = class {
2084
+ constructor(options = {}) {
2085
+ this.tmuxTargetOverride = options.tmuxTarget;
2086
+ this.execFn = options.execFn ?? (async (cmd) => execAsync2(cmd));
2087
+ this.paneObserver = options.paneObserver ?? null;
2088
+ this.detector = options.detector ?? null;
2089
+ if (this.detector && !this.paneObserver) {
2090
+ throw new Error(
2091
+ "PromptResponder misconfigured: detector wired without paneObserver. Approval-reply identity guard requires the observer to re-probe the pane before send. Either wire both, or wire neither."
2092
+ );
2093
+ }
2094
+ }
2095
+ /**
2096
+ * Send free-form text to the agy session. The text is typed (via
2097
+ * `send-keys -l`) then Enter is sent after a short delay so agy's
2098
+ * input handler has time to process the buffer.
2099
+ */
2100
+ async sendFreeForm(text) {
2101
+ const target = this.resolveTmuxTarget();
2102
+ if (!target) {
2103
+ return { ok: false, reason: "no-tmux-target" };
2104
+ }
2105
+ try {
2106
+ if (text === CODEVIBE_AGY_ESCAPE_INPUT) {
2107
+ await this.sendKey(target, "Escape");
2108
+ } else {
2109
+ await this.typeLiteral(target, text);
2110
+ await delay(ENTER_DELAY_MS);
2111
+ await this.sendKey(target, "Enter");
2112
+ }
2113
+ logger.info("Sent free-form input to agy", {
2114
+ target,
2115
+ textLength: text.length
2116
+ });
2117
+ return { ok: true };
2118
+ } catch (error) {
2119
+ logger.error("tmux send-keys failed for free-form input", { target, error });
2120
+ return {
2121
+ ok: false,
2122
+ reason: "tmux-failed",
2123
+ details: error instanceof Error ? error.message : String(error)
2124
+ };
2125
+ }
2126
+ }
2127
+ /**
2128
+ * Resolve a pending approval prompt with the mobile-user's chosen
2129
+ * option number. Uses the submitMap from the PendingPromptState to
2130
+ * translate option-number → terminal keystrokes (digit alone for agy's
2131
+ * 4-numbered UI; future Y/N prompts would use 'y'/'n'/'Escape').
2132
+ *
2133
+ * Active-prompt-identity guard (DESIGN.md §3.4):
2134
+ * 1. Detector check: is `promptId` still in pendingPrompts? If not
2135
+ * (expired or already-resolved), drop with `prompt-expired`.
2136
+ * 2. Pane-observer check: probeApprovalUIActive() — does the pane
2137
+ * STILL show an approval UI whose header matches our pending
2138
+ * call's identity? If active-UI is gone OR header differs,
2139
+ * drop with `prompt-superseded`.
2140
+ * 3. submitMap lookup: is `optionNumber` a valid key? If not (e.g.
2141
+ * mobile sent "5" for a 4-option prompt), drop with
2142
+ * `invalid-option`.
2143
+ * 4. Send the keystrokes via tmux send-keys.
2144
+ */
2145
+ async sendApprovalReply(promptId, optionNumber) {
2146
+ const target = this.resolveTmuxTarget();
2147
+ if (!target) {
2148
+ return { ok: false, reason: "no-tmux-target" };
2149
+ }
2150
+ if (this.detector) {
2151
+ const state = this.detector.getPendingPrompt(promptId);
2152
+ if (!state) {
2153
+ logger.warn("Mobile reply for unknown / expired promptId", { promptId, optionNumber });
2154
+ return {
2155
+ ok: false,
2156
+ reason: "prompt-expired",
2157
+ details: `promptId ${promptId} is not in pendingPrompts`
2158
+ };
2159
+ }
2160
+ if (this.paneObserver) {
2161
+ const probe = await this.paneObserver.probeApprovalUIActive();
2162
+ if (!probe.active) {
2163
+ this.detector.resolvePrompt(promptId);
2164
+ return {
2165
+ ok: false,
2166
+ reason: "prompt-superseded",
2167
+ details: "approval UI vanished from pane (likely user approved on desktop)"
2168
+ };
2169
+ }
2170
+ const expectedHeader = state.matchedPaneHeader;
2171
+ if (expectedHeader && probe.headerText && !sameIdentity(probe.headerText, expectedHeader)) {
2172
+ this.detector.resolvePrompt(promptId);
2173
+ return {
2174
+ ok: false,
2175
+ reason: "prompt-superseded",
2176
+ details: `pane shows '${probe.headerText}' but matched-against header was '${expectedHeader}'`
2177
+ };
2178
+ }
2179
+ }
2180
+ const keys = state.submitMap[optionNumber];
2181
+ if (!keys || keys.length === 0) {
2182
+ return {
2183
+ ok: false,
2184
+ reason: "invalid-option",
2185
+ details: `option '${optionNumber}' not in submitMap (valid: ${Object.keys(state.submitMap).join(",")})`
2186
+ };
2187
+ }
2188
+ try {
2189
+ for (const key of keys) {
2190
+ if (isNamedKey(key)) {
2191
+ await this.sendKey(target, key);
2192
+ } else {
2193
+ await this.typeLiteral(target, key);
2194
+ }
2195
+ }
2196
+ this.detector.resolvePrompt(promptId);
2197
+ logger.info("Sent approval reply via tmux", {
2198
+ promptId,
2199
+ optionNumber,
2200
+ keysSent: keys
2201
+ });
2202
+ return { ok: true };
2203
+ } catch (error) {
2204
+ return {
2205
+ ok: false,
2206
+ reason: "tmux-failed",
2207
+ details: error instanceof Error ? error.message : String(error)
2208
+ };
2209
+ }
2210
+ }
2211
+ try {
2212
+ await this.typeLiteral(target, optionNumber);
2213
+ return { ok: true };
2214
+ } catch (error) {
2215
+ return {
2216
+ ok: false,
2217
+ reason: "tmux-failed",
2218
+ details: error instanceof Error ? error.message : String(error)
2219
+ };
2220
+ }
2221
+ }
2222
+ // ─── Tmux primitives ────────────────────────────────────────────────────
2223
+ async typeLiteral(target, text) {
2224
+ const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
2225
+ const safeTarget = escapeShellArg2(target);
2226
+ const cmd = `tmux send-keys -t '${safeTarget}' -l "${escaped}"`;
2227
+ await this.execFn(cmd);
2228
+ }
2229
+ async sendKey(target, key) {
2230
+ const safeTarget = escapeShellArg2(target);
2231
+ const cmd = `tmux send-keys -t '${safeTarget}' ${key}`;
2232
+ await this.execFn(cmd);
2233
+ }
2234
+ resolveTmuxTarget() {
2235
+ return this.tmuxTargetOverride ?? process.env.CODEVIBE_AGY_TMUX_TARGET ?? null;
2236
+ }
2237
+ };
2238
+ function escapeShellArg2(value) {
2239
+ return value.replace(/'/g, `'\\''`);
2240
+ }
2241
+ function isNamedKey(key) {
2242
+ return /^[A-Z][A-Za-z0-9]+$/.test(key);
2243
+ }
2244
+ function delay(ms) {
2245
+ return new Promise((r) => setTimeout(r, ms));
2246
+ }
2247
+ function sameIdentity(headerFromPane, expected) {
2248
+ return headerFromPane.trim() === expected.trim();
2249
+ }
2250
+
2251
+ // src/mobile-prompt-dedupe.ts
2252
+ var DEFAULT_EXPIRY_MS = 15e3;
2253
+ var DEFAULT_MIN_FUZZY_ECHO_LENGTH = 16;
2254
+ var DEFAULT_MIN_FUZZY_ECHO_RATIO = 0.35;
2255
+ var MobilePromptDeduper = class {
2256
+ constructor(options = {}) {
2257
+ this.pendingBySession = /* @__PURE__ */ new Map();
2258
+ this.expiryMs = options.expiryMs ?? DEFAULT_EXPIRY_MS;
2259
+ this.minFuzzyEchoLength = options.minFuzzyEchoLength ?? DEFAULT_MIN_FUZZY_ECHO_LENGTH;
2260
+ this.minFuzzyEchoRatio = options.minFuzzyEchoRatio ?? DEFAULT_MIN_FUZZY_ECHO_RATIO;
2261
+ this.now = options.now ?? Date.now;
2262
+ }
2263
+ /** Called by the MCP server immediately BEFORE prompt-responder
2264
+ * performs the tmux send-keys. Records the normalized text + timestamp
2265
+ * for future echo-suppression. */
2266
+ track(sessionId, prompt) {
2267
+ this.sweep();
2268
+ const normalized = this.normalize(prompt);
2269
+ if (!normalized) return;
2270
+ const entries = this.validEntries(sessionId);
2271
+ entries.push({ normalized, timestamp: this.now() });
2272
+ this.pendingBySession.set(sessionId, entries);
2273
+ }
2274
+ /** Symmetric helper for callers that want to retract a tracked entry
2275
+ * WITHOUT consuming it via dedupe (e.g. responder bailed before send). */
2276
+ forget(sessionId, prompt) {
2277
+ this.sweep();
2278
+ const normalized = this.normalize(prompt);
2279
+ if (!normalized) return;
2280
+ let removed = false;
2281
+ const entries = this.validEntries(sessionId).filter((entry) => {
2282
+ if (!removed && entry.normalized === normalized) {
2283
+ removed = true;
2284
+ return false;
2285
+ }
2286
+ return true;
2287
+ });
2288
+ this.replaceEntries(sessionId, entries);
2289
+ }
2290
+ /**
2291
+ * Called by event-mapper / MCP server immediately BEFORE forwarding a
2292
+ * USER_PROMPT to AppSync. If the echo matches a recently-tracked mobile
2293
+ * prompt, returns the match metadata + CONSUMES the tracked entry (so a
2294
+ * second similar echo doesn't suppress an actual second mobile prompt).
2295
+ * Returns null when no match → caller proceeds with the AppSync emit.
2296
+ */
2297
+ consumeIfDuplicate(sessionId, echo) {
2298
+ this.sweep();
2299
+ const normalizedEcho = this.normalize(echo);
2300
+ if (!normalizedEcho) return null;
2301
+ let match = null;
2302
+ const entries = this.validEntries(sessionId).filter((entry) => {
2303
+ if (!match) {
2304
+ const matchType = this.matchType(entry.normalized, normalizedEcho);
2305
+ if (matchType) {
2306
+ match = {
2307
+ matchType,
2308
+ originalLength: entry.normalized.length,
2309
+ echoLength: normalizedEcho.length
2310
+ };
2311
+ return false;
2312
+ }
2313
+ }
2314
+ return true;
2315
+ });
2316
+ this.replaceEntries(sessionId, entries);
2317
+ return match;
2318
+ }
2319
+ // ─── Internals ──────────────────────────────────────────────────────────
2320
+ sweep() {
2321
+ const cutoff = this.now() - this.expiryMs;
2322
+ for (const [sessId, entries] of this.pendingBySession.entries()) {
2323
+ const valid = entries.filter((entry) => entry.timestamp >= cutoff);
2324
+ if (valid.length > 0) {
2325
+ this.pendingBySession.set(sessId, valid);
2326
+ } else {
2327
+ this.pendingBySession.delete(sessId);
2328
+ }
2329
+ }
2330
+ }
2331
+ validEntries(sessionId) {
2332
+ const cutoff = this.now() - this.expiryMs;
2333
+ return (this.pendingBySession.get(sessionId) || []).filter((entry) => entry.timestamp >= cutoff);
2334
+ }
2335
+ replaceEntries(sessionId, entries) {
2336
+ if (entries.length > 0) {
2337
+ this.pendingBySession.set(sessionId, entries);
2338
+ } else {
2339
+ this.pendingBySession.delete(sessionId);
2340
+ }
2341
+ }
2342
+ matchType(original, echo) {
2343
+ if (original === echo) {
2344
+ return "exact";
2345
+ }
2346
+ const longEnough = echo.length >= this.minFuzzyEchoLength;
2347
+ const enoughOfOriginal = echo.length / original.length >= this.minFuzzyEchoRatio;
2348
+ if (longEnough && enoughOfOriginal && original.endsWith(echo)) {
2349
+ return "suffix";
2350
+ }
2351
+ return null;
2352
+ }
2353
+ normalize(text) {
2354
+ return text.replace(/\s+/g, " ").trim();
2355
+ }
2356
+ };
2357
+
2358
+ // src/daemon-telemetry.ts
2359
+ var crypto2 = __toESM(require("crypto"));
2360
+ var fs4 = __toESM(require("fs"));
2361
+ var https = __toESM(require("https"));
2362
+ var os4 = __toESM(require("os"));
2363
+ var path4 = __toESM(require("path"));
2364
+ var MEASUREMENT_ID = "G-GS74YEQTB8";
2365
+ var API_SECRET = "lAfOF6OxRzSQ-NsLBRjhAg";
2366
+ var GA4_HOSTNAME = "www.google-analytics.com";
2367
+ var GA4_PATH = `/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;
2368
+ var BEACON_TOTAL_TIMEOUT_MS = 800;
2369
+ function readPluginVersion() {
2370
+ try {
2371
+ const pkgPath = path4.resolve(__dirname, "..", "package.json");
2372
+ const raw = fs4.readFileSync(pkgPath, "utf-8");
2373
+ const pkg = JSON.parse(raw);
2374
+ if (typeof pkg.version === "string" && pkg.version.length > 0 && pkg.version.length < 30) {
2375
+ return pkg.version;
2376
+ }
2377
+ } catch {
2378
+ }
2379
+ return "unknown";
2380
+ }
2381
+ var PLUGIN_VERSION = readPluginVersion();
2382
+ function getClientId() {
2383
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
2384
+ return crypto2.createHash("sha256").update(`${os4.hostname()}-${uid}`).digest("hex").substring(0, 36);
2385
+ }
2386
+ function sanitizeErrorMessage(msg) {
2387
+ if (!msg) return "";
2388
+ const homedir4 = os4.homedir();
2389
+ let out = msg.replace(/[\n\r\t]/g, " ");
2390
+ if (homedir4 && homedir4.length > 0) {
2391
+ const safeHome = homedir4.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2392
+ out = out.replace(new RegExp(safeHome, "g"), "~");
2393
+ }
2394
+ out = out.replace(/\/Users\/[^/\s"'`]+/g, "/Users/<user>").replace(/\/home\/[^/\s"'`]+/g, "/home/<user>").replace(/[A-Za-z]:\\Users\\[^\\\s"'`]+/gi, "C:\\Users\\<user>").replace(/[^\x20-\x7E]/g, "");
2395
+ return out.trim().substring(0, 100);
2396
+ }
2397
+ function fireDaemonBeacon(name, params) {
2398
+ return new Promise((resolve3) => {
2399
+ let req;
2400
+ let settled = false;
2401
+ const settle = () => {
2402
+ if (settled) return;
2403
+ settled = true;
2404
+ clearTimeout(overall);
2405
+ resolve3();
2406
+ };
2407
+ const overall = setTimeout(() => {
2408
+ try {
2409
+ req?.destroy();
2410
+ } catch {
2411
+ }
2412
+ settle();
2413
+ }, BEACON_TOTAL_TIMEOUT_MS);
2414
+ if (typeof overall.unref === "function") {
2415
+ overall.unref();
2416
+ }
2417
+ try {
2418
+ const sanitizedParams = {};
2419
+ for (const [key, value] of Object.entries(params)) {
2420
+ if (typeof value === "string") {
2421
+ sanitizedParams[key] = sanitizeErrorMessage(value);
2422
+ } else {
2423
+ sanitizedParams[key] = value;
2424
+ }
2425
+ }
2426
+ const payload = JSON.stringify({
2427
+ client_id: getClientId(),
2428
+ events: [
2429
+ {
2430
+ name,
2431
+ params: {
2432
+ agent: "antigravity",
2433
+ plugin_version: PLUGIN_VERSION,
2434
+ platform: process.platform,
2435
+ source: process.env.CODEVIBE_TELEMETRY_SOURCE || "production",
2436
+ ...sanitizedParams
2437
+ }
2438
+ }
2439
+ ]
2440
+ });
2441
+ req = https.request(
2442
+ {
2443
+ hostname: GA4_HOSTNAME,
2444
+ path: GA4_PATH,
2445
+ method: "POST",
2446
+ headers: { "Content-Type": "application/json" },
2447
+ timeout: BEACON_TOTAL_TIMEOUT_MS
2448
+ },
2449
+ (res) => {
2450
+ res.resume();
2451
+ res.on("end", settle);
2452
+ res.on("close", settle);
2453
+ res.on("error", settle);
2454
+ }
2455
+ );
2456
+ req.on("error", settle);
2457
+ req.on("timeout", () => {
2458
+ try {
2459
+ req?.destroy();
2460
+ } catch {
2461
+ }
2462
+ settle();
2463
+ });
2464
+ req.on("close", settle);
2465
+ req.write(payload);
2466
+ req.end();
2467
+ } catch {
2468
+ settle();
2469
+ }
2470
+ });
2471
+ }
2472
+
2473
+ // src/event-mapper.ts
2474
+ var import_uuid2 = require("uuid");
2475
+ var import_codevibe_core3 = require("@quantiya/codevibe-core");
2476
+ var CONTENT_MAX = 150 * 1024;
2477
+ var TOOL_OUTPUT_MAX = 50 * 1024;
2478
+ var TOOL_DIFF_MAX = 100 * 1024;
2479
+ function mapTranscriptEvent(emit, sessionId) {
2480
+ const { event } = emit;
2481
+ const base = {
2482
+ sessionId,
2483
+ source: import_codevibe_core3.EventSource.DESKTOP
2484
+ };
2485
+ const nextTimestamp = () => bumpTimestampForConv(event.created_at, emit.conversationId);
2486
+ const baseMetadata = {
2487
+ step_index: event.step_index,
2488
+ agy_source: event.source,
2489
+ agy_status: event.status,
2490
+ agy_created_at: event.created_at,
2491
+ agy_type: event.type,
2492
+ agy_conversation_id: emit.conversationId
2493
+ };
2494
+ if (event.source === "SYSTEM") {
2495
+ if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
2496
+ logger.debug("Dropping internal transcript event", {
2497
+ type: event.type,
2498
+ stepIndex: event.step_index
2499
+ });
2500
+ return { events: [], pendingCallRegistrations: [] };
2501
+ }
2502
+ if (event.type !== "ERROR_MESSAGE") {
2503
+ logger.warn("Dropping unknown SYSTEM-source transcript event (safety)", {
2504
+ type: event.type,
2505
+ stepIndex: event.step_index
2506
+ });
2507
+ return { events: [], pendingCallRegistrations: [] };
2508
+ }
2509
+ }
2510
+ if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
2511
+ logger.debug("Dropping internal transcript event (non-SYSTEM source)", {
2512
+ type: event.type,
2513
+ source: event.source,
2514
+ stepIndex: event.step_index
2515
+ });
2516
+ return { events: [], pendingCallRegistrations: [] };
2517
+ }
2518
+ if (event.type === "USER_INPUT") {
2519
+ const cleaned = stripUserRequestWrapper(event.content || "");
2520
+ return {
2521
+ events: [
2522
+ {
2523
+ ...base,
2524
+ timestamp: nextTimestamp(),
2525
+ type: import_codevibe_core3.EventType.USER_PROMPT,
2526
+ content: truncate(cleaned, CONTENT_MAX),
2527
+ metadata: { ...baseMetadata }
2528
+ }
2529
+ ],
2530
+ pendingCallRegistrations: []
2531
+ };
2532
+ }
2533
+ if (event.type === "PLANNER_RESPONSE") {
2534
+ const events = [];
2535
+ const pendingCallRegistrations = [];
2536
+ if (typeof event.thinking === "string" && event.thinking.trim().length > 0) {
2537
+ events.push({
2538
+ ...base,
2539
+ timestamp: nextTimestamp(),
2540
+ type: import_codevibe_core3.EventType.REASONING,
2541
+ content: truncate(event.thinking, CONTENT_MAX),
2542
+ metadata: { ...baseMetadata }
2543
+ });
2544
+ }
2545
+ if (typeof event.content === "string" && event.content.trim().length > 0) {
2546
+ events.push({
2547
+ ...base,
2548
+ timestamp: nextTimestamp(),
2549
+ type: import_codevibe_core3.EventType.ASSISTANT_RESPONSE,
2550
+ content: truncate(event.content, CONTENT_MAX),
2551
+ metadata: { ...baseMetadata }
2552
+ });
2553
+ }
2554
+ const toolCalls = event.tool_calls;
2555
+ if (Array.isArray(toolCalls)) {
2556
+ for (let i = 0; i < toolCalls.length; i++) {
2557
+ const tc = toolCalls[i];
2558
+ if (!tc || typeof tc !== "object") continue;
2559
+ const name = tc.name;
2560
+ if (typeof name !== "string") continue;
2561
+ const args = tc.args;
2562
+ pendingCallRegistrations.push({
2563
+ intentStepIndex: event.step_index,
2564
+ toolCallIndex: i,
2565
+ intentByteOffset: emit.byteOffset,
2566
+ toolType: hookToolNameToTranscriptType(name),
2567
+ conversationId: emit.conversationId,
2568
+ command: extractCommandFromArgs(name, args),
2569
+ filePath: extractFilePathFromArgs(name, args),
2570
+ cwd: extractCwdFromArgs(args),
2571
+ rawToolName: name,
2572
+ startedAt: Date.now(),
2573
+ intentCreatedAt: event.created_at
2574
+ });
2575
+ }
2576
+ }
2577
+ return { events, pendingCallRegistrations };
2578
+ }
2579
+ if (event.type === "ERROR_MESSAGE") {
2580
+ const content = stripTimestampPrefix(event.content || "Agy error");
2581
+ return {
2582
+ events: [
2583
+ {
2584
+ ...base,
2585
+ timestamp: nextTimestamp(),
2586
+ type: import_codevibe_core3.EventType.NOTIFICATION,
2587
+ content: truncate(content, CONTENT_MAX),
2588
+ metadata: {
2589
+ ...baseMetadata,
2590
+ severity: "error"
2591
+ }
2592
+ }
2593
+ ],
2594
+ pendingCallRegistrations: []
2595
+ };
2596
+ }
2597
+ const toolName = transcriptTypeToFriendlyToolName(event.type);
2598
+ const isErrorStatus = event.status === "ERROR";
2599
+ const rawContent = stripTimestampPrefix(event.content || "");
2600
+ const truncatedContent = truncate(rawContent, TOOL_OUTPUT_MAX);
2601
+ return {
2602
+ events: [
2603
+ {
2604
+ ...base,
2605
+ timestamp: nextTimestamp(),
2606
+ type: import_codevibe_core3.EventType.TOOL_USE,
2607
+ content: formatToolResultContent(event.type, toolName, truncatedContent, isErrorStatus),
2608
+ metadata: {
2609
+ ...baseMetadata,
2610
+ tool_name: toolName,
2611
+ tool_output: truncatedContent,
2612
+ success: !isErrorStatus
2613
+ }
2614
+ }
2615
+ ],
2616
+ pendingCallRegistrations: []
2617
+ };
2618
+ }
2619
+ var lastEmittedSecondPerConv = /* @__PURE__ */ new Map();
2620
+ function bumpTimestampForConv(createdAt, conversationId) {
2621
+ const parsed = new Date(createdAt);
2622
+ const agySec = Math.floor(parsed.getTime() / 1e3);
2623
+ const lastSec = lastEmittedSecondPerConv.get(conversationId) ?? 0;
2624
+ const effectiveSec = Math.max(agySec, lastSec + 1);
2625
+ lastEmittedSecondPerConv.set(conversationId, effectiveSec);
2626
+ return new Date(effectiveSec * 1e3).toISOString();
2627
+ }
2628
+ function stripUserRequestWrapper(content) {
2629
+ const match = content.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
2630
+ if (match && match[1] !== void 0) {
2631
+ return match[1].trim();
2632
+ }
2633
+ return content.trim();
2634
+ }
2635
+ function stripTimestampPrefix(content) {
2636
+ return content.replace(
2637
+ /^Created At:[^\n]*\nCompleted At:[^\n]*\n+(?:\s*\n)?/,
2638
+ ""
2639
+ ).trim();
2640
+ }
2641
+ function transcriptTypeToFriendlyToolName(type) {
2642
+ switch (type) {
2643
+ case "RUN_COMMAND":
2644
+ return "Bash";
2645
+ case "LIST_DIRECTORY":
2646
+ return "LS";
2647
+ case "GREP_SEARCH":
2648
+ return "Grep";
2649
+ case "VIEW_FILE":
2650
+ return "Read";
2651
+ case "CODE_ACTION":
2652
+ return "Edit";
2653
+ case "SEARCH_WEB":
2654
+ return "WebSearch";
2655
+ case "GENERIC":
2656
+ return "Other";
2657
+ default:
2658
+ return "Other";
2659
+ }
2660
+ }
2661
+ function hookToolNameToTranscriptType(hookName) {
2662
+ const upper = hookName.toUpperCase();
2663
+ switch (upper) {
2664
+ case "LIST_DIR":
2665
+ return "LIST_DIRECTORY";
2666
+ case "REPLACE_FILE_CONTENT":
2667
+ case "MULTI_REPLACE_FILE_CONTENT":
2668
+ case "WRITE_TO_FILE":
2669
+ return "CODE_ACTION";
2670
+ case "GENERATE_IMAGE":
2671
+ case "READ_URL_CONTENT":
2672
+ case "INVOKE_SUBAGENT":
2673
+ case "DEFINE_SUBAGENT":
2674
+ case "MANAGE_SUBAGENTS":
2675
+ case "SEND_MESSAGE":
2676
+ case "LIST_PERMISSIONS":
2677
+ case "MANAGE_TASK":
2678
+ case "SCHEDULE":
2679
+ case "ASK_PERMISSION":
2680
+ case "ASK_QUESTION":
2681
+ return "GENERIC";
2682
+ default:
2683
+ return upper;
2684
+ }
2685
+ }
2686
+ function extractCommandFromArgs(toolName, args) {
2687
+ if (!args || typeof args !== "object") return void 0;
2688
+ const a = args;
2689
+ for (const key of ["CommandLine", "Command", "command", "cmd", "exec"]) {
2690
+ const v = a[key];
2691
+ if (typeof v === "string" && v.trim().length > 0) {
2692
+ return stripWrappingQuotes(v.trim());
2693
+ }
2694
+ }
2695
+ return void 0;
2696
+ }
2697
+ function extractFilePathFromArgs(toolName, args) {
2698
+ if (!args || typeof args !== "object") return void 0;
2699
+ const a = args;
2700
+ for (const key of [
2701
+ "TargetFile",
2702
+ "FilePath",
2703
+ "file_path",
2704
+ "filePath",
2705
+ "path",
2706
+ "DirectoryPath",
2707
+ "directory",
2708
+ "Directory"
2709
+ ]) {
2710
+ const v = a[key];
2711
+ if (typeof v === "string" && v.trim().length > 0) {
2712
+ return stripWrappingQuotes(v.trim());
2713
+ }
2714
+ }
2715
+ return void 0;
2716
+ }
2717
+ function extractCwdFromArgs(args) {
2718
+ if (!args || typeof args !== "object") return void 0;
2719
+ const a = args;
2720
+ for (const key of ["Cwd", "cwd", "workingDirectory", "WorkingDirectory"]) {
2721
+ const v = a[key];
2722
+ if (typeof v === "string" && v.trim().length > 0) {
2723
+ return stripWrappingQuotes(v.trim());
2724
+ }
2725
+ }
2726
+ return void 0;
2727
+ }
2728
+ function stripWrappingQuotes(s) {
2729
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
2730
+ return s.slice(1, -1);
2731
+ }
2732
+ return s;
2733
+ }
2734
+ function formatToolResultContent(agyType, toolName, output, isError) {
2735
+ const prefix = isError ? "\u26A0 " : "";
2736
+ const firstLine = output.split("\n", 1)[0]?.trim();
2737
+ switch (agyType) {
2738
+ case "RUN_COMMAND":
2739
+ return `${prefix}Bash: ${firstLine || "command executed"}`;
2740
+ case "LIST_DIRECTORY":
2741
+ return `${prefix}LS: ${firstLine || "directory listed"}`;
2742
+ case "GREP_SEARCH":
2743
+ return `${prefix}Grep: ${firstLine || "search executed"}`;
2744
+ case "VIEW_FILE":
2745
+ return `${prefix}Read: ${firstLine || "file read"}`;
2746
+ case "CODE_ACTION":
2747
+ return `${prefix}Edit: ${firstLine || "file edited"}`;
2748
+ case "SEARCH_WEB":
2749
+ return `${prefix}WebSearch: ${firstLine || "search executed"}`;
2750
+ case "GENERIC":
2751
+ return `${prefix}${toolName}: ${firstLine || "tool executed"}`;
2752
+ default:
2753
+ return `${prefix}${toolName} (${agyType}): ${firstLine || "unknown event"}`;
2754
+ }
2755
+ }
2756
+ function truncate(s, maxBytes) {
2757
+ if (!s) return "";
2758
+ if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
2759
+ let lo = 0;
2760
+ let hi = s.length;
2761
+ while (lo < hi) {
2762
+ const mid = lo + hi + 1 >>> 1;
2763
+ if (Buffer.byteLength(s.slice(0, mid), "utf-8") <= maxBytes - 32) {
2764
+ lo = mid;
2765
+ } else {
2766
+ hi = mid - 1;
2767
+ }
2768
+ }
2769
+ return s.slice(0, lo) + ` [...truncated ${s.length - lo} chars]`;
2770
+ }
2771
+
2772
+ // src/server.ts
2773
+ var McpServer = class {
2774
+ constructor(options) {
2775
+ /** v9 launch-session: ONE backend session per wrapper lifetime.
2776
+ * Created at start() before any tailer event can fire. All agy
2777
+ * conv UUIDs observed during this wrapper's lifetime emit events
2778
+ * under this single sessionId. /resume does NOT create a new
2779
+ * session — same row. */
2780
+ this.session = null;
2781
+ /** Single subscription for the launch session (v9 — was per-conv Map). */
2782
+ this.subscription = null;
2783
+ /** Wrapper-level disable flag (free-tier limit / collision). v9
2784
+ * collapses v8's per-conv disabledConversations Set because there's
2785
+ * only ONE backend session per wrapper. */
2786
+ this.sessionDisabled = false;
2787
+ /** Registry of agy conv UUIDs the wrapper has observed this lifecycle.
2788
+ * Populated from handleConversationDiscovered + handleTranscriptEmit
2789
+ * (after isMainConversation passes). Used by:
2790
+ * (a) handlePromptCandidate — approvalDetector keys candidates by
2791
+ * agy conv UUID, not backend session ID;
2792
+ * (b) getSessionByConversation — defensive membership check before
2793
+ * returning the launch session.
2794
+ * Stale UUIDs from /resume cycles remain until lifecycle stop;
2795
+ * causes harmless extra candidate-buffer entries in approvalDetector
2796
+ * bounded by TTL. (Codex Stage 2 R1 v12 HIGH.) */
2797
+ this.observedMainConversationIds = /* @__PURE__ */ new Set();
2798
+ this.started = false;
2799
+ /** Monotonically-increasing counter incremented on every start(). Captured
2800
+ * by async callbacks at entry; mismatch after an await means stop()→start()
2801
+ * ran during the await, so the captured work is from a previous lifecycle
2802
+ * and must bail before touching this lifecycle's state. NOT reset on
2803
+ * stop() — preserving the monotonic invariant lets stale callbacks
2804
+ * reliably detect they're stale. (Codex Stage 2 R1 v5 MED 2026-05-20.) */
2805
+ this.lifecycleGen = 0;
2806
+ /** In-flight stop() promise so a re-entrant call (e.g. double SIGINT)
2807
+ * awaits the same shutdown instead of bypassing it and calling
2808
+ * process.exit before the original stop's cleanup completes.
2809
+ * (Stage 1 MED finding 2026-05-20.) */
2810
+ this.stopPromise = null;
2811
+ /** Listener references kept so doStop() can detach them. Without this,
2812
+ * a server restart (test mode or future hot-reload) accumulates
2813
+ * listeners on the shared module instances. (Stage 2 HIGH finding
2814
+ * 2026-05-20.) */
2815
+ this.listenerHandles = [];
2816
+ this.signalsRegistered = false;
2817
+ this.boundSigintHandler = null;
2818
+ this.boundSigtermHandler = null;
2819
+ if (!options.bearerToken || options.bearerToken.length < 16) {
2820
+ throw new Error("McpServer requires a non-trivial bearerToken");
2821
+ }
2822
+ this.bearerToken = options.bearerToken;
2823
+ this.preferredPort = options.preferredPort ?? 0;
2824
+ this.runtimeDir = options.runtimeDir ?? null;
2825
+ this.tmuxTarget = options.tmuxTarget ?? process.env.CODEVIBE_AGY_TMUX_TARGET ?? null;
2826
+ this.wrapperPid = options.wrapperPid ?? null;
2827
+ this.cliLogPath = options.cliLogPath ?? null;
2828
+ this.appSyncClient = options.appSyncClient ?? new import_codevibe_core4.AppSyncClient();
2829
+ this.approvalDetector = options.approvalDetector ?? new ApprovalDetector(options.detectorOptions);
2830
+ this.paneObserver = options.paneObserver ?? new TmuxPaneObserver();
2831
+ this.transcriptTailer = options.transcriptTailer ?? new TranscriptTailer();
2832
+ this.mobileDeduper = options.mobileDeduper ?? new MobilePromptDeduper();
2833
+ this.promptResponder = options.promptResponder ?? new PromptResponder({
2834
+ tmuxTarget: this.tmuxTarget ?? void 0,
2835
+ paneObserver: this.paneObserver,
2836
+ detector: this.approvalDetector
2837
+ });
2838
+ const portFilePath = this.runtimeDir ? path5.join(this.runtimeDir, "conn.json") : void 0;
2839
+ this.httpApi = options.httpApi ?? new HttpApi({
2840
+ bearerToken: this.bearerToken,
2841
+ preferredPort: this.preferredPort,
2842
+ portFilePath
2843
+ });
2844
+ this.httpApi.setHandlers({
2845
+ pushEvent: (p) => this.handleHttpPushEvent(p),
2846
+ getActiveSession: () => this.getAnySession()
2847
+ });
2848
+ }
2849
+ // ─── Lifecycle ──────────────────────────────────────────────────────────
2850
+ async start() {
2851
+ if (this.started) throw new Error("McpServer.start() called twice");
2852
+ this.stopPromise = null;
2853
+ await fireDaemonBeacon("daemon_init_start", {
2854
+ step: "init",
2855
+ outcome: "ok",
2856
+ has_tmux_target: this.tmuxTarget ? "yes" : "no"
2857
+ });
2858
+ const authed = await this.appSyncClient.authenticateWithStoredTokens();
2859
+ if (!authed) {
2860
+ await fireDaemonBeacon("daemon_init_step", {
2861
+ step: "auth",
2862
+ outcome: "fail",
2863
+ error_message: "no stored OAuth tokens"
2864
+ });
2865
+ throw new Error(
2866
+ "codevibe-antigravity-plugin: no OAuth tokens \u2014 run `codevibe-agy login` first"
2867
+ );
2868
+ }
2869
+ this.started = true;
2870
+ this.lifecycleGen++;
2871
+ this.approvalDetector.start();
2872
+ this.addListener(this.approvalDetector, "pending-prompt", (state) => {
2873
+ void this.handlePendingPrompt(state).catch((err) => {
2874
+ logger.error("handlePendingPrompt failed", { error: String(err) });
2875
+ });
2876
+ });
2877
+ await this.createLaunchSession();
2878
+ this.addListener(this.transcriptTailer, "event", (emit) => {
2879
+ void this.handleTranscriptEmit(emit).catch((err) => {
2880
+ logger.error("handleTranscriptEmit failed", { error: String(err) });
2881
+ });
2882
+ });
2883
+ this.addListener(this.transcriptTailer, "conversation-discovered", (conversationId) => {
2884
+ void this.handleConversationDiscovered(conversationId).catch((err) => {
2885
+ logger.error("handleConversationDiscovered failed", { error: String(err) });
2886
+ });
2887
+ });
2888
+ await this.transcriptTailer.start();
2889
+ if (this.tmuxTarget) {
2890
+ this.addListener(this.paneObserver, "prompt-candidate", (cand) => {
2891
+ void this.handlePromptCandidate(cand).catch((err) => {
2892
+ logger.error("handlePromptCandidate failed", { error: String(err) });
2893
+ });
2894
+ });
2895
+ this.addListener(this.paneObserver, "prompt-cleared", () => {
2896
+ this.approvalDetector.onPanePromptCleared();
2897
+ });
2898
+ await this.paneObserver.start(this.tmuxTarget);
2899
+ } else {
2900
+ logger.warn("No tmux target \u2014 pane observer disabled (mobile-only mode)");
2901
+ }
2902
+ const httpPort = await this.httpApi.start();
2903
+ this.registerSignalHandlers();
2904
+ await fireDaemonBeacon("daemon_init_step", {
2905
+ step: "ready",
2906
+ outcome: "ok",
2907
+ http_port: httpPort
2908
+ });
2909
+ return { httpPort };
2910
+ }
2911
+ async stop() {
2912
+ if (this.stopPromise) return this.stopPromise;
2913
+ if (!this.started) return;
2914
+ this.stopPromise = this.doStop();
2915
+ try {
2916
+ await this.stopPromise;
2917
+ } finally {
2918
+ }
2919
+ }
2920
+ addListener(emitter, event, handler) {
2921
+ emitter.on(event, handler);
2922
+ this.listenerHandles.push({ emitter, event, handler });
2923
+ }
2924
+ async doStop() {
2925
+ this.started = false;
2926
+ try {
2927
+ await this.transcriptTailer.stop();
2928
+ } catch (err) {
2929
+ logger.warn("transcriptTailer.stop failed", { error: String(err) });
2930
+ }
2931
+ try {
2932
+ await this.paneObserver.stop();
2933
+ } catch (err) {
2934
+ logger.warn("paneObserver.stop failed", { error: String(err) });
2935
+ }
2936
+ if (this.subscription) {
2937
+ try {
2938
+ this.subscription();
2939
+ } catch {
2940
+ }
2941
+ this.subscription = null;
2942
+ }
2943
+ for (const { emitter, event, handler } of this.listenerHandles) {
2944
+ try {
2945
+ emitter.off(event, handler);
2946
+ } catch {
2947
+ }
2948
+ }
2949
+ this.listenerHandles = [];
2950
+ this.unregisterSignalHandlers();
2951
+ if (this.session) {
2952
+ try {
2953
+ await this.appSyncClient.updateSession({
2954
+ sessionId: this.session.sessionId,
2955
+ status: "INACTIVE"
2956
+ });
2957
+ } catch (err) {
2958
+ logger.warn("updateSession INACTIVE failed during shutdown", {
2959
+ sessionId: this.session.sessionId,
2960
+ error: String(err)
2961
+ });
2962
+ }
2963
+ try {
2964
+ this.appSyncClient.stopHeartbeat(this.session.sessionId);
2965
+ } catch {
2966
+ }
2967
+ }
2968
+ try {
2969
+ await this.approvalDetector.stop();
2970
+ } catch (err) {
2971
+ logger.warn("approvalDetector.stop failed", { error: String(err) });
2972
+ }
2973
+ try {
2974
+ await this.httpApi.stop();
2975
+ } catch (err) {
2976
+ logger.warn("httpApi.stop failed", { error: String(err) });
2977
+ }
2978
+ try {
2979
+ this.appSyncClient.cleanupSubscriptions();
2980
+ } catch (err) {
2981
+ logger.warn("appSyncClient.cleanupSubscriptions failed", { error: String(err) });
2982
+ }
2983
+ this.session = null;
2984
+ this.sessionDisabled = false;
2985
+ this.observedMainConversationIds.clear();
2986
+ await fireDaemonBeacon("daemon_init_step", {
2987
+ step: "shutdown",
2988
+ outcome: "ok"
2989
+ });
2990
+ }
2991
+ async handleConversationDiscovered(conversationId) {
2992
+ if (!this.started) return;
2993
+ if (this.sessionDisabled) return;
2994
+ if (!this.session) return;
2995
+ if (!this.isMainConversation(conversationId)) {
2996
+ logger.debug("Ignoring discovery for non-main conversation (subagent)", { conversationId });
2997
+ return;
2998
+ }
2999
+ this.observedMainConversationIds.add(conversationId);
3000
+ logger.info("agy conversation observed under launch session", {
3001
+ conversationId,
3002
+ launchSessionId: this.session.sessionId
3003
+ });
3004
+ }
3005
+ /**
3006
+ * v9 launch-session: create the wrapper-lifetime backend session at
3007
+ * start(). All agy conv UUIDs observed during this lifetime emit
3008
+ * events under this single sessionId. /resume does NOT create a new
3009
+ * row. (Codex Stage 2 R1 v5/v6/v7 switch machinery is now dead code,
3010
+ * removed.)
3011
+ */
3012
+ async createLaunchSession() {
3013
+ const gen = this.lifecycleGen;
3014
+ const sessionId = generateLaunchSessionId(this.wrapperPid ?? process.pid);
3015
+ const userId = this.appSyncClient.getCurrentUserId();
3016
+ const projectPath = process.cwd();
3017
+ let sessionKey = null;
3018
+ try {
3019
+ const result = await (0, import_codevibe_core4.resumeOrCreateSession)(
3020
+ {
3021
+ sessionId,
3022
+ userId,
3023
+ agentType: import_codevibe_core4.AgentType.ANTIGRAVITY,
3024
+ projectPath,
3025
+ metadata: { wrapperPid: this.wrapperPid ?? void 0, launch: true }
3026
+ },
3027
+ this.appSyncClient,
3028
+ logger
3029
+ );
3030
+ sessionKey = result.sessionKey ?? null;
3031
+ } catch (err) {
3032
+ const msg = String(err);
3033
+ if (msg.includes("session-limit-exceeded")) {
3034
+ logger.warn("Free-tier session limit reached \u2014 mobile sync disabled for this wrapper", { sessionId });
3035
+ if (gen !== this.lifecycleGen || !this.started) return;
3036
+ this.sessionDisabled = true;
3037
+ return;
3038
+ }
3039
+ logger.error("createLaunchSession failed (non-fatal)", { sessionId, error: msg });
3040
+ return;
3041
+ }
3042
+ if (gen !== this.lifecycleGen || !this.started) return;
3043
+ this.session = {
3044
+ sessionId,
3045
+ conversationId: "",
3046
+ // No agy conv UUID yet at launch
3047
+ userId,
3048
+ projectPath,
3049
+ cwd: projectPath,
3050
+ createdAt: /* @__PURE__ */ new Date(),
3051
+ subscriptionActive: false,
3052
+ metadata: { wrapperPid: this.wrapperPid ?? void 0, launch: true },
3053
+ sessionKey
3054
+ };
3055
+ try {
3056
+ const stop = this.appSyncClient.subscribeToEvents(
3057
+ sessionId,
3058
+ (evt) => {
3059
+ void this.handleMobileEvent(evt).catch((err) => {
3060
+ logger.error("handleMobileEvent failed", { error: String(err) });
3061
+ });
3062
+ },
3063
+ (err) => logger.warn("AppSync subscription error", { error: String(err) })
3064
+ );
3065
+ if (gen !== this.lifecycleGen || !this.started) {
3066
+ try {
3067
+ stop();
3068
+ } catch {
3069
+ }
3070
+ return;
3071
+ }
3072
+ this.subscription = stop;
3073
+ this.session.subscriptionActive = true;
3074
+ } catch (err) {
3075
+ logger.error("subscribeToEvents failed (non-fatal)", { sessionId, error: String(err) });
3076
+ }
3077
+ try {
3078
+ this.appSyncClient.startHeartbeat(sessionId);
3079
+ } catch (err) {
3080
+ logger.warn("startHeartbeat failed", { sessionId, error: String(err) });
3081
+ }
3082
+ void fireDaemonBeacon("daemon_init_step", {
3083
+ step: "session_ready",
3084
+ outcome: "ok",
3085
+ path: "launch_session"
3086
+ });
3087
+ logger.info("Launch session created", { sessionId, userId });
3088
+ }
3089
+ isMainConversation(conversationId) {
3090
+ if (process.env.JEST_WORKER_ID && !this.cliLogPath) {
3091
+ return true;
3092
+ }
3093
+ const cliLogPath = this.cliLogPath ?? path5.join(os5.homedir(), ".gemini", "antigravity-cli", "cli.log");
3094
+ const activeId = getActiveConversationFromCliLog(cliLogPath);
3095
+ if (!activeId) {
3096
+ return true;
3097
+ }
3098
+ return activeId === conversationId;
3099
+ }
3100
+ // ─── Transcript flow ────────────────────────────────────────────────────
3101
+ async handleTranscriptEmit(emit) {
3102
+ if (!this.started) return;
3103
+ const { conversationId, byteOffset, event } = emit;
3104
+ if (this.sessionDisabled) {
3105
+ return;
3106
+ }
3107
+ if (!this.session) {
3108
+ return;
3109
+ }
3110
+ if (!this.isMainConversation(conversationId)) {
3111
+ logger.debug("Ignoring transcript emit for non-main conversation (subagent)", { conversationId });
3112
+ return;
3113
+ }
3114
+ this.observedMainConversationIds.add(conversationId);
3115
+ const session = this.session;
3116
+ const mapped = mapTranscriptEvent(emit, session.sessionId);
3117
+ for (const reg of mapped.pendingCallRegistrations) {
3118
+ this.approvalDetector.registerPendingCall(reg);
3119
+ }
3120
+ for (const input of mapped.events) {
3121
+ if (input.type === import_codevibe_core4.EventType.TOOL_USE && typeof input.metadata === "object" && input.metadata) {
3122
+ const meta = input.metadata;
3123
+ const resultStep = typeof meta.step_index === "number" ? meta.step_index : void 0;
3124
+ const agyType = typeof meta.agy_type === "string" ? meta.agy_type : void 0;
3125
+ if (resultStep !== void 0 && agyType) {
3126
+ this.approvalDetector.clearOnResultEvent({
3127
+ conversationId,
3128
+ resultStepIndex: resultStep,
3129
+ toolType: agyType,
3130
+ resultByteOffset: byteOffset
3131
+ });
3132
+ }
3133
+ }
3134
+ if (input.type === import_codevibe_core4.EventType.USER_PROMPT && input.source === import_codevibe_core4.EventSource.DESKTOP) {
3135
+ const dedupe = this.mobileDeduper.consumeIfDuplicate(session.sessionId, input.content);
3136
+ if (dedupe) {
3137
+ logger.info("Suppressed desktop echo of mobile prompt", {
3138
+ sessionId: session.sessionId,
3139
+ matchType: dedupe.matchType
3140
+ });
3141
+ continue;
3142
+ }
3143
+ }
3144
+ try {
3145
+ await this.appSyncClient.createEvent(this.encryptOutbound(session, input));
3146
+ } catch (err) {
3147
+ logger.error("createEvent failed", {
3148
+ sessionId: session.sessionId,
3149
+ type: input.type,
3150
+ error: String(err)
3151
+ });
3152
+ }
3153
+ }
3154
+ }
3155
+ /**
3156
+ * If the session has an E2E sessionKey, encrypt the event's content +
3157
+ * metadata and stamp isEncrypted=true. Returns a new CreateEventInput;
3158
+ * never mutates the input. When no key is set, returns input unchanged.
3159
+ * (Codex Stage 2 HIGH finding 2026-05-20.)
3160
+ */
3161
+ encryptOutbound(session, input) {
3162
+ if (!session.sessionKey) return input;
3163
+ try {
3164
+ const encryptedContent = import_codevibe_core4.cryptoService.encryptContent(input.content, session.sessionKey);
3165
+ let encryptedMetadata = input.metadata;
3166
+ if (input.metadata) {
3167
+ const encryptedMetaStr = import_codevibe_core4.cryptoService.encryptMetadata(
3168
+ input.metadata,
3169
+ session.sessionKey
3170
+ );
3171
+ encryptedMetadata = { encrypted: encryptedMetaStr };
3172
+ }
3173
+ return {
3174
+ ...input,
3175
+ content: encryptedContent,
3176
+ metadata: encryptedMetadata,
3177
+ isEncrypted: true
3178
+ };
3179
+ } catch (err) {
3180
+ logger.error("Outbound encryption failed \u2014 DROPPING event to avoid plaintext leak", {
3181
+ sessionId: session.sessionId,
3182
+ type: input.type,
3183
+ error: String(err)
3184
+ });
3185
+ throw err;
3186
+ }
3187
+ }
3188
+ /**
3189
+ * If the inbound mobile event is marked isEncrypted, decrypt content +
3190
+ * metadata in place. Returns a new shallow-copied Event; never mutates
3191
+ * the input. When the session has no key OR the event isn't encrypted,
3192
+ * returns input unchanged. (Codex Stage 2 HIGH finding 2026-05-20.)
3193
+ */
3194
+ decryptInbound(session, evt) {
3195
+ const isEnc = evt.isEncrypted;
3196
+ if (!isEnc) return evt;
3197
+ if (!session.sessionKey) {
3198
+ logger.warn("Encrypted mobile event arrived but no sessionKey \u2014 dropping", {
3199
+ sessionId: session.sessionId,
3200
+ eventId: evt.eventId
3201
+ });
3202
+ throw new Error("encrypted-event-no-session-key");
3203
+ }
3204
+ const result = { ...evt };
3205
+ try {
3206
+ result.content = import_codevibe_core4.cryptoService.decryptContent(evt.content, session.sessionKey);
3207
+ const rawMeta = parseMaybeJson(evt.metadata);
3208
+ if (rawMeta && typeof rawMeta === "object" && typeof rawMeta.encrypted === "string") {
3209
+ const decryptedMeta = import_codevibe_core4.cryptoService.decryptMetadata(
3210
+ rawMeta.encrypted,
3211
+ session.sessionKey
3212
+ );
3213
+ result.metadata = decryptedMeta;
3214
+ } else if (rawMeta && typeof rawMeta === "object") {
3215
+ result.metadata = rawMeta;
3216
+ }
3217
+ } catch (err) {
3218
+ logger.error("Decryption failed \u2014 dropping event", {
3219
+ sessionId: session.sessionId,
3220
+ eventId: evt.eventId,
3221
+ error: String(err)
3222
+ });
3223
+ throw err;
3224
+ }
3225
+ return result;
3226
+ }
3227
+ // ─── Tmux pane flow ─────────────────────────────────────────────────────
3228
+ async handlePromptCandidate(cand) {
3229
+ if (!this.started) return;
3230
+ if (this.sessionDisabled) return;
3231
+ if (!this.session) return;
3232
+ const parsed = parseApprovalSnapshot(cand.snapshot);
3233
+ if (!parsed) return;
3234
+ const activeConvId = this.pickActiveConversationForPrompt();
3235
+ if (!activeConvId) {
3236
+ logger.warn("Prompt candidate fired but no active conversation known", {
3237
+ observedConvCount: this.observedMainConversationIds.size
3238
+ });
3239
+ return;
3240
+ }
3241
+ this.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId);
3242
+ }
3243
+ /**
3244
+ * Pick the agy conversation UUID that owns the current approval UI.
3245
+ * Priority:
3246
+ * 1. cli.log says it's a specific conv → return that one if observed.
3247
+ * 2. Otherwise, return the most recently observed main conv UUID.
3248
+ * 3. Otherwise null (no active conv).
3249
+ */
3250
+ pickActiveConversationForPrompt() {
3251
+ if (this.observedMainConversationIds.size === 0) return null;
3252
+ const cliLogPath = this.cliLogPath ?? path5.join(os5.homedir(), ".gemini", "antigravity-cli", "cli.log");
3253
+ const cliActive = getActiveConversationFromCliLog(cliLogPath);
3254
+ if (cliActive && this.observedMainConversationIds.has(cliActive)) {
3255
+ return cliActive;
3256
+ }
3257
+ let last = null;
3258
+ for (const id of this.observedMainConversationIds) last = id;
3259
+ return last;
3260
+ }
3261
+ async handlePendingPrompt(state) {
3262
+ if (!this.started) return;
3263
+ const session = this.getSessionByConversation(state.conversationId);
3264
+ if (!session) {
3265
+ logger.warn("pending-prompt fired but no session known", {
3266
+ promptId: state.promptId,
3267
+ conversationId: state.conversationId
3268
+ });
3269
+ return;
3270
+ }
3271
+ const optionsArr = state.paneOptions ? state.paneOptions.map((o) => ({ number: o.number, text: o.text })) : Object.keys(state.submitMap).map((n) => ({ number: n }));
3272
+ const optionsForMobile = state.pendingCall ? {
3273
+ promptId: state.promptId,
3274
+ tool_name: state.pendingCall.toolType,
3275
+ command: state.pendingCall.command,
3276
+ file_path: state.pendingCall.filePath,
3277
+ options: optionsArr
3278
+ } : { promptId: state.promptId };
3279
+ const content = state.paneDisplayHeader || state.matchedPaneHeader || state.pendingCall?.command || state.pendingCall?.filePath || "approval requested";
3280
+ const input = {
3281
+ sessionId: session.sessionId,
3282
+ type: import_codevibe_core4.EventType.INTERACTIVE_PROMPT,
3283
+ source: import_codevibe_core4.EventSource.DESKTOP,
3284
+ content,
3285
+ metadata: optionsForMobile,
3286
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3287
+ };
3288
+ try {
3289
+ await this.appSyncClient.createEvent(this.encryptOutbound(session, input));
3290
+ } catch (err) {
3291
+ logger.error("createEvent INTERACTIVE_PROMPT failed", {
3292
+ promptId: state.promptId,
3293
+ error: String(err)
3294
+ });
3295
+ this.approvalDetector.rollbackPrompt(state.promptId);
3296
+ }
3297
+ }
3298
+ // ─── Mobile → desktop flow ──────────────────────────────────────────────
3299
+ async handleMobileEvent(evt) {
3300
+ if (!this.started) return;
3301
+ if (this.sessionDisabled) return;
3302
+ if (!this.session) return;
3303
+ const session = this.session;
3304
+ if (evt.source !== import_codevibe_core4.EventSource.MOBILE) return;
3305
+ if (evt.sessionId !== session.sessionId) {
3306
+ logger.warn("Dropping mobile event with mismatched sessionId", {
3307
+ expected: session.sessionId,
3308
+ got: evt.sessionId
3309
+ });
3310
+ return;
3311
+ }
3312
+ let decrypted;
3313
+ try {
3314
+ decrypted = this.decryptInbound(session, evt);
3315
+ } catch {
3316
+ return;
3317
+ }
3318
+ evt = decrypted;
3319
+ await this.markDelivered(evt);
3320
+ if (evt.type === import_codevibe_core4.EventType.USER_PROMPT) {
3321
+ this.mobileDeduper.track(session.sessionId, evt.content);
3322
+ const result = await this.promptResponder.sendFreeForm(evt.content);
3323
+ if (!result.ok) {
3324
+ this.mobileDeduper.forget(session.sessionId, evt.content);
3325
+ logger.warn("sendFreeForm failed", { reason: result.reason, details: result.details });
3326
+ return;
3327
+ }
3328
+ await this.markExecuted(evt);
3329
+ return;
3330
+ }
3331
+ if (evt.type === import_codevibe_core4.EventType.PROMPT_RESPONSE) {
3332
+ const rawMeta = parseMaybeJson(evt.metadata);
3333
+ const meta = rawMeta && typeof rawMeta === "object" ? rawMeta : {};
3334
+ const evtAny = evt;
3335
+ const promptId = 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;
3336
+ if (!promptId) {
3337
+ logger.warn("PROMPT_RESPONSE missing promptId metadata; dropping", {
3338
+ sessionId: session.sessionId
3339
+ });
3340
+ return;
3341
+ }
3342
+ const optionNumber = (evt.content || "").trim();
3343
+ this.mobileDeduper.track(session.sessionId, optionNumber);
3344
+ const result = await this.promptResponder.sendApprovalReply(promptId, optionNumber);
3345
+ if (!result.ok) {
3346
+ this.mobileDeduper.forget(session.sessionId, optionNumber);
3347
+ logger.warn("sendApprovalReply failed", {
3348
+ promptId,
3349
+ reason: result.reason,
3350
+ details: result.details
3351
+ });
3352
+ return;
3353
+ }
3354
+ await this.markExecuted(evt);
3355
+ }
3356
+ }
3357
+ /**
3358
+ * Transition a mobile event's deliveryStatus to DELIVERED. Called as
3359
+ * soon as the plugin receives + decrypts the event and BEFORE the
3360
+ * tmux send is attempted, mirroring codex/claude plugin semantics:
3361
+ * "delivered" means the desktop daemon has the message in hand, not
3362
+ * that it has acted on it yet. Errors are swallowed (logged) since
3363
+ * a status-write failure must not block the actual prompt routing.
3364
+ */
3365
+ async markDelivered(evt) {
3366
+ try {
3367
+ await this.appSyncClient.updateEventStatus({
3368
+ eventId: evt.eventId,
3369
+ sessionId: evt.sessionId,
3370
+ timestamp: evt.timestamp,
3371
+ deliveryStatus: import_codevibe_core4.DeliveryStatus.DELIVERED
3372
+ });
3373
+ } catch (err) {
3374
+ logger.warn("updateEventStatus(DELIVERED) failed", {
3375
+ eventId: evt.eventId,
3376
+ sessionId: evt.sessionId,
3377
+ error: String(err)
3378
+ });
3379
+ }
3380
+ }
3381
+ /**
3382
+ * Transition a mobile event's deliveryStatus to EXECUTED. Called
3383
+ * after the tmux send-keys for this mobile event succeeded — at
3384
+ * which point the desktop pane has actually consumed the input
3385
+ * (free-form text or approval digit). Errors are swallowed so a
3386
+ * transient AppSync failure doesn't surface as a user-visible
3387
+ * error after the desktop has already acted on the message.
3388
+ */
3389
+ async markExecuted(evt) {
3390
+ try {
3391
+ await this.appSyncClient.updateEventStatus({
3392
+ eventId: evt.eventId,
3393
+ sessionId: evt.sessionId,
3394
+ timestamp: evt.timestamp,
3395
+ deliveryStatus: import_codevibe_core4.DeliveryStatus.EXECUTED
3396
+ });
3397
+ } catch (err) {
3398
+ logger.warn("updateEventStatus(EXECUTED) failed", {
3399
+ eventId: evt.eventId,
3400
+ sessionId: evt.sessionId,
3401
+ error: String(err)
3402
+ });
3403
+ }
3404
+ }
3405
+ // ─── HTTP API push hook (internal/programmatic) ────────────────────────
3406
+ async handleHttpPushEvent(payload) {
3407
+ const session = this.findSessionByBackendId(payload.sessionId);
3408
+ if (!session) {
3409
+ throw new SessionNotFoundError(payload.sessionId);
3410
+ }
3411
+ const normalizedMeta = parseMaybeJson(payload.metadata);
3412
+ const metadata = normalizedMeta && typeof normalizedMeta === "object" ? normalizedMeta : {};
3413
+ const input = {
3414
+ sessionId: payload.sessionId,
3415
+ type: payload.type,
3416
+ source: payload.source,
3417
+ content: payload.content,
3418
+ metadata,
3419
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3420
+ };
3421
+ const finalInput = this.encryptOutbound(session, input);
3422
+ const created = await this.appSyncClient.createEvent(finalInput);
3423
+ return { eventId: created.eventId };
3424
+ }
3425
+ findSessionByBackendId(backendSessionId) {
3426
+ if (!this.session) return null;
3427
+ return this.session.sessionId === backendSessionId ? this.session : null;
3428
+ }
3429
+ // ─── Signal handling ────────────────────────────────────────────────────
3430
+ registerSignalHandlers() {
3431
+ if (this.signalsRegistered) return;
3432
+ this.signalsRegistered = true;
3433
+ const onSignal = (sig) => {
3434
+ logger.info(`${sig} received \u2014 shutting down`);
3435
+ void this.stop().then(() => process.exit(0)).catch((err) => {
3436
+ logger.error("shutdown error", { error: String(err) });
3437
+ process.exit(1);
3438
+ });
3439
+ };
3440
+ this.boundSigintHandler = () => onSignal("SIGINT");
3441
+ this.boundSigtermHandler = () => onSignal("SIGTERM");
3442
+ process.on("SIGINT", this.boundSigintHandler);
3443
+ process.on("SIGTERM", this.boundSigtermHandler);
3444
+ }
3445
+ unregisterSignalHandlers() {
3446
+ if (!this.signalsRegistered) return;
3447
+ if (this.boundSigintHandler) process.off("SIGINT", this.boundSigintHandler);
3448
+ if (this.boundSigtermHandler) process.off("SIGTERM", this.boundSigtermHandler);
3449
+ this.boundSigintHandler = null;
3450
+ this.boundSigtermHandler = null;
3451
+ this.signalsRegistered = false;
3452
+ }
3453
+ // ─── Lookups (test surface) ────────────────────────────────────────────
3454
+ getSessionByConversation(conversationId) {
3455
+ if (!this.session) return null;
3456
+ if (!this.observedMainConversationIds.has(conversationId)) return null;
3457
+ return this.session;
3458
+ }
3459
+ getAnySession() {
3460
+ return this.session;
3461
+ }
3462
+ isDisabled() {
3463
+ return this.sessionDisabled;
3464
+ }
3465
+ };
3466
+ function parseMaybeJson(value) {
3467
+ if (value === null || value === void 0) return null;
3468
+ if (typeof value === "object") return value;
3469
+ if (typeof value === "string") {
3470
+ if (value.length === 0) return null;
3471
+ try {
3472
+ return JSON.parse(value);
3473
+ } catch {
3474
+ return null;
3475
+ }
3476
+ }
3477
+ return null;
3478
+ }
3479
+ var SessionNotFoundError = class extends Error {
3480
+ constructor(sessionId) {
3481
+ super(`session not found: ${sessionId}`);
3482
+ this.sessionId = sessionId;
3483
+ this.code = "session-not-found";
3484
+ this.name = "SessionNotFoundError";
3485
+ }
3486
+ };
3487
+ function generateLaunchSessionId(wrapperPid) {
3488
+ const nonce = crypto3.randomBytes(8).toString("hex");
3489
+ return `agy-${wrapperPid}-${nonce}`;
3490
+ }
3491
+ function getActiveConversationFromCliLog(cliLogPath) {
3492
+ try {
3493
+ if (!fs5.existsSync(cliLogPath)) return null;
3494
+ const content = fs5.readFileSync(cliLogPath, "utf8");
3495
+ const regex = /(?:Created conversation|Streaming conversation|Starting conversation update stream for)\s+([0-9a-fA-F-]{36})/g;
3496
+ const matches = Array.from(content.matchAll(regex));
3497
+ if (matches.length === 0) return null;
3498
+ return matches[matches.length - 1][1];
3499
+ } catch (err) {
3500
+ return null;
3501
+ }
3502
+ }
3503
+ async function main() {
3504
+ const args = parseArgs(process.argv.slice(2));
3505
+ const bearerToken = process.env.CODEVIBE_AGY_MCP_TOKEN || args.token;
3506
+ if (!bearerToken) {
3507
+ console.error("codevibe-antigravity-plugin: bearer token required via $CODEVIBE_AGY_MCP_TOKEN or --token");
3508
+ process.exit(1);
3509
+ }
3510
+ const server = new McpServer({
3511
+ bearerToken,
3512
+ preferredPort: args.port,
3513
+ runtimeDir: args.runtimeDir,
3514
+ tmuxTarget: process.env.CODEVIBE_AGY_TMUX_TARGET ?? void 0,
3515
+ wrapperPid: process.env.CODEVIBE_AGY_WRAPPER_PID ? parseInt(process.env.CODEVIBE_AGY_WRAPPER_PID, 10) : void 0
3516
+ });
3517
+ try {
3518
+ const { httpPort } = await server.start();
3519
+ logger.info("codevibe-antigravity-plugin ready", { httpPort });
3520
+ } catch (err) {
3521
+ console.error("startup failed:", err);
3522
+ process.exit(1);
3523
+ }
3524
+ }
3525
+ function parseArgs(argv) {
3526
+ let token = "";
3527
+ let port = 0;
3528
+ let runtimeDir;
3529
+ for (let i = 0; i < argv.length; i++) {
3530
+ const flag = argv[i];
3531
+ if (flag === "--token") {
3532
+ token = argv[++i] ?? "";
3533
+ } else if (flag === "--port") {
3534
+ port = parseInt(argv[++i] ?? "0", 10);
3535
+ } else if (flag === "--runtime-dir") {
3536
+ runtimeDir = argv[++i];
3537
+ }
3538
+ }
3539
+ return { token, port, runtimeDir };
3540
+ }
3541
+ if (require.main === module) {
3542
+ void main();
3543
+ }
3544
+ var __testing = {
3545
+ generateLaunchSessionId,
3546
+ parseArgs,
3547
+ parseMaybeJson,
3548
+ getActiveConversationFromCliLog
3549
+ };
3550
+ // Annotate the CommonJS export names for ESM import in node:
3551
+ 0 && (module.exports = {
3552
+ McpServer,
3553
+ SessionNotFoundError,
3554
+ __testing,
3555
+ generateLaunchSessionId,
3556
+ getActiveConversationFromCliLog,
3557
+ parseMaybeJson
3558
+ });