@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/.env.example +28 -0
- package/README.md +112 -0
- package/antigravity-plugin.json +11 -0
- package/bin/codevibe-agy +518 -0
- package/dist/installer-cli.js +478 -0
- package/dist/server.js +3558 -0
- package/package.json +71 -0
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
|
+
});
|