@quantiya/codevibe-antigravity-plugin 2.0.63 → 2.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -37,16 +37,18 @@ __export(server_exports, {
37
37
  __testing: () => __testing,
38
38
  classifyEntrypoint: () => classifyEntrypoint,
39
39
  classifyTmuxHasSessionError: () => classifyTmuxHasSessionError,
40
+ commandDigest: () => commandDigest,
40
41
  generateLaunchSessionId: () => generateLaunchSessionId,
41
42
  getActiveConversationFromCliLog: () => getActiveConversationFromCliLog,
42
43
  parseMaybeJson: () => parseMaybeJson,
44
+ readTranscriptFirstType: () => readTranscriptFirstType,
43
45
  runForeignSpawnIdle: () => runForeignSpawnIdle
44
46
  });
45
47
  module.exports = __toCommonJS(server_exports);
46
48
  var crypto3 = __toESM(require("crypto"));
47
- var path5 = __toESM(require("path"));
48
- var fs5 = __toESM(require("fs"));
49
- var os5 = __toESM(require("os"));
49
+ var path8 = __toESM(require("path"));
50
+ var fs7 = __toESM(require("fs"));
51
+ var os7 = __toESM(require("os"));
50
52
  var import_child_process3 = require("child_process");
51
53
  var import_util3 = require("util");
52
54
  var import_codevibe_core6 = require("@quantiya/codevibe-core");
@@ -66,6 +68,60 @@ var fs = __toESM(require("fs"));
66
68
  var crypto = __toESM(require("crypto"));
67
69
  var http = __toESM(require("http"));
68
70
  var import_codevibe_core2 = require("@quantiya/codevibe-core");
71
+
72
+ // src/agy-hook-paths.ts
73
+ var os2 = __toESM(require("os"));
74
+ var path2 = __toESM(require("path"));
75
+ var HOOK_ENTRY_NAME = "codevibe-antigravity";
76
+ var HOOK_ROUTE_PREFIX = "/agy-hook/v1/";
77
+ var HOOK_EVENTS = ["PreInvocation", "PostToolUse"];
78
+ var HOOK_SHELL_BASENAME = "agy-hook.sh";
79
+ var HOOK_SCRIPT_BASENAME = "agy-hook.js";
80
+ function isHookEvent(value) {
81
+ return typeof value === "string" && HOOK_EVENTS.includes(value);
82
+ }
83
+ function codevibeAgyDir(home = os2.homedir()) {
84
+ return path2.join(home, ".codevibe", "codevibe-agy");
85
+ }
86
+ function hooksDir(home = os2.homedir()) {
87
+ return path2.join(codevibeAgyDir(home), "hooks");
88
+ }
89
+ function globalHooksConfigPath(home = os2.homedir()) {
90
+ return path2.join(home, ".gemini", "config", "hooks.json");
91
+ }
92
+
93
+ // src/http-api.ts
94
+ var HOOK_BODY_MAX_BYTES = 64 * 1024;
95
+ var UUID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
96
+ function parseAgyHookDelivery(event, raw) {
97
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
98
+ const body = raw;
99
+ if (typeof body.conversationId !== "string" || !UUID_PATTERN.test(body.conversationId)) return null;
100
+ const delivery = { event, conversationId: body.conversationId };
101
+ for (const key of ["invocationNum", "initialNumSteps", "stepIdx"]) {
102
+ const value = body[key];
103
+ if (value === void 0) continue;
104
+ if (!Number.isInteger(value) || value < 0) return null;
105
+ delivery[key] = value;
106
+ }
107
+ if (body.toolName !== void 0) {
108
+ if (typeof body.toolName !== "string") return null;
109
+ delivery.toolName = body.toolName;
110
+ }
111
+ if (body.toolArgs !== void 0) {
112
+ if (!body.toolArgs || typeof body.toolArgs !== "object" || Array.isArray(body.toolArgs)) return null;
113
+ const args = {};
114
+ for (const [key, value] of Object.entries(body.toolArgs)) {
115
+ if (typeof value === "string") args[key] = value;
116
+ }
117
+ delivery.toolArgs = args;
118
+ }
119
+ if (body.hasError !== void 0) {
120
+ if (typeof body.hasError !== "boolean") return null;
121
+ delivery.hasError = body.hasError;
122
+ }
123
+ return delivery;
124
+ }
69
125
  var HttpApi = class {
70
126
  constructor(options) {
71
127
  this.server = null;
@@ -160,8 +216,50 @@ var HttpApi = class {
160
216
  void this.handlePostEvent(req, res);
161
217
  return;
162
218
  }
219
+ if (req.method === "POST" && pathOnly.startsWith(HOOK_ROUTE_PREFIX)) {
220
+ void this.handleAgyHook(req, res, pathOnly.slice(HOOK_ROUTE_PREFIX.length));
221
+ return;
222
+ }
163
223
  this.sendJson(res, 404, { success: false, error: "not found" });
164
224
  }
225
+ async handleAgyHook(req, res, eventName) {
226
+ if (!isHookEvent(eventName)) {
227
+ this.sendJson(res, 404, { success: false, error: "unknown hook event" });
228
+ return;
229
+ }
230
+ const onAgyHook = this.handlers?.onAgyHook;
231
+ if (!onAgyHook) {
232
+ this.sendJson(res, 503, { success: false, error: "server not ready" });
233
+ return;
234
+ }
235
+ let body;
236
+ try {
237
+ body = await readBody(req, HOOK_BODY_MAX_BYTES);
238
+ } catch {
239
+ this.sendJson(res, 413, { success: false, error: "body too large or read failed" });
240
+ return;
241
+ }
242
+ let parsed;
243
+ try {
244
+ parsed = JSON.parse(body);
245
+ } catch {
246
+ this.sendJson(res, 400, { success: false, error: "invalid JSON" });
247
+ return;
248
+ }
249
+ const delivery = parseAgyHookDelivery(eventName, parsed);
250
+ if (!delivery) {
251
+ this.sendJson(res, 400, { success: false, error: "invalid hook delivery" });
252
+ return;
253
+ }
254
+ try {
255
+ onAgyHook(delivery);
256
+ } catch (err) {
257
+ logger.error("agy hook handler failed", { event: eventName, error: String(err) });
258
+ this.sendJson(res, 500, { success: false, error: "hook handling failed" });
259
+ return;
260
+ }
261
+ this.sendJson(res, 200, { success: true });
262
+ }
165
263
  checkBearer(req) {
166
264
  const auth = req.headers["authorization"];
167
265
  if (typeof auth !== "string") return false;
@@ -239,6 +337,7 @@ var HttpApi = class {
239
337
  const content = JSON.stringify({
240
338
  port: this.assignedPort,
241
339
  token: this.bearerToken,
340
+ pid: process.pid,
242
341
  writtenAt: (/* @__PURE__ */ new Date()).toISOString()
243
342
  });
244
343
  const tmpPath = `${this.portFilePath}.tmp.${process.pid}`;
@@ -290,8 +389,8 @@ async function readBody(req, maxBytes) {
290
389
  // src/transcript-tailer.ts
291
390
  var import_events = require("events");
292
391
  var fs2 = __toESM(require("fs"));
293
- var os2 = __toESM(require("os"));
294
- var path2 = __toESM(require("path"));
392
+ var os3 = __toESM(require("os"));
393
+ var path3 = __toESM(require("path"));
295
394
  var import_chokidar = require("chokidar");
296
395
 
297
396
  // src/shutdown-bounds.ts
@@ -405,10 +504,10 @@ function watchOneFilePolled(target, options) {
405
504
  return watcher;
406
505
  }
407
506
  function defaultPaths() {
408
- const home = os2.homedir();
507
+ const home = os3.homedir();
409
508
  return {
410
- brainRoot: path2.join(home, ".gemini", "antigravity-cli", "brain"),
411
- offsetFilePath: path2.join(home, ".codevibe", "codevibe-agy", "transcript-offsets.json")
509
+ brainRoot: path3.join(home, ".gemini", "antigravity-cli", "brain"),
510
+ offsetFilePath: path3.join(home, ".codevibe", "codevibe-agy", "transcript-offsets.json")
412
511
  };
413
512
  }
414
513
  var TranscriptTailer = class extends import_events.EventEmitter {
@@ -722,7 +821,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
722
821
  onConversationDirAdded(conversationId) {
723
822
  if (this.isShuttingDown) return;
724
823
  logger.info("New brain directory discovered (pre-prompt)", {
725
- dirPath: path2.join(this.brainRoot, conversationId),
824
+ dirPath: path3.join(this.brainRoot, conversationId),
726
825
  conversationId
727
826
  });
728
827
  this.emit("conversation-discovered", conversationId);
@@ -914,7 +1013,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
914
1013
  this.cursorResets.set(filePath, (this.cursorResets.get(filePath) ?? 0) + 1);
915
1014
  }
916
1015
  transcriptPathFor(conversationId) {
917
- return path2.join(this.brainRoot, conversationId, SYSTEM_GENERATED_DIR, LOGS_DIR, TRANSCRIPT_BASENAME);
1016
+ return path3.join(this.brainRoot, conversationId, SYSTEM_GENERATED_DIR, LOGS_DIR, TRANSCRIPT_BASENAME);
918
1017
  }
919
1018
  /** UUID-named directories directly under the brain root; null on failure. */
920
1019
  listConversationDirs() {
@@ -1022,8 +1121,8 @@ var TranscriptTailer = class extends import_events.EventEmitter {
1022
1121
  this.snapshotTranscript(this.transcriptPathFor(conversationId))
1023
1122
  );
1024
1123
  }
1025
- const dirPath = path2.join(this.brainRoot, conversationId);
1026
- const transcriptPath = path2.join(
1124
+ const dirPath = path3.join(this.brainRoot, conversationId);
1125
+ const transcriptPath = path3.join(
1027
1126
  dirPath,
1028
1127
  ".system_generated",
1029
1128
  "logs",
@@ -1349,7 +1448,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
1349
1448
  return;
1350
1449
  }
1351
1450
  try {
1352
- const offsetDir = path2.dirname(this.offsetFilePath);
1451
+ const offsetDir = path3.dirname(this.offsetFilePath);
1353
1452
  if (!fs2.existsSync(offsetDir)) {
1354
1453
  fs2.mkdirSync(offsetDir, { recursive: true, mode: 448 });
1355
1454
  }
@@ -1390,10 +1489,815 @@ var TranscriptTailer = class extends import_events.EventEmitter {
1390
1489
  }
1391
1490
  };
1392
1491
 
1393
- // src/tmux-pane-observer.ts
1492
+ // src/conversation-router.ts
1493
+ var DEFAULT_HOLD_MS = 4e3;
1494
+ var DEFAULT_MAX_HELD_PER_CONVERSATION = 2e3;
1495
+ var DEFAULT_MAX_HELD_CONVERSATIONS = 16;
1496
+ var DEFAULT_MAX_PARKED_PER_CONVERSATION = 64;
1497
+ var VOUCHER_QUEUE_MS = 1e4;
1498
+ var PENDING_TURN_MS = 6e4;
1499
+ var ConversationRouter = class {
1500
+ constructor(options) {
1501
+ this.roles = /* @__PURE__ */ new Map();
1502
+ this.lastInvoked = /* @__PURE__ */ new Map();
1503
+ /** Reported by PostToolUse only, with an inconclusive transcript. Ours, not routed. */
1504
+ this.unclassified = /* @__PURE__ */ new Set();
1505
+ /** Held events of these were dropped as another session's (log dedupe + late-claim warning). */
1506
+ this.foreign = /* @__PURE__ */ new Set();
1507
+ this.legacy = /* @__PURE__ */ new Set();
1508
+ /** Model output arrived without our PreInvocation: not kept at expiry until our next PreInvocation. */
1509
+ this.unparkable = /* @__PURE__ */ new Set();
1510
+ /** Our turn-start PreInvocation that came before its turn line: that line's step. */
1511
+ this.turnTokens = /* @__PURE__ */ new Map();
1512
+ /** A turn line processed straight away (line first, unshared) and not yet seen by a turn start: that start leaves no voucher for it. */
1513
+ this.processedTurnSteps = /* @__PURE__ */ new Map();
1514
+ this.held = /* @__PURE__ */ new Map();
1515
+ /** `main` conversations, most recently invoked last; `bound` is the last. */
1516
+ this.mainOrder = [];
1517
+ this.hookReceived = false;
1518
+ this.options = options;
1519
+ this.holdMs = options.holdMs ?? DEFAULT_HOLD_MS;
1520
+ this.maxHeldPerConversation = options.maxHeldPerConversation ?? DEFAULT_MAX_HELD_PER_CONVERSATION;
1521
+ this.maxHeldConversations = options.maxHeldConversations ?? DEFAULT_MAX_HELD_CONVERSATIONS;
1522
+ this.maxParkedPerConversation = options.maxParkedPerConversation ?? DEFAULT_MAX_PARKED_PER_CONVERSATION;
1523
+ this.now = options.now ?? Date.now;
1524
+ }
1525
+ hasReceivedHook() {
1526
+ return this.hookReceived;
1527
+ }
1528
+ /** The `main` conversation our PreInvocation hook reported most recently. */
1529
+ getBound() {
1530
+ return this.mainOrder.length > 0 ? this.mainOrder[this.mainOrder.length - 1] : null;
1531
+ }
1532
+ roleOf(conversationId) {
1533
+ return this.roles.get(conversationId);
1534
+ }
1535
+ /** Reported by our own hooks, whatever its role. */
1536
+ isOwn(conversationId) {
1537
+ return this.roles.has(conversationId) || this.unclassified.has(conversationId);
1538
+ }
1539
+ route(conversationId, item) {
1540
+ const role = this.roles.get(conversationId);
1541
+ if (role === "main") {
1542
+ const step = item === void 0 ? null : this.turnStepOf(item);
1543
+ if (this.held.has(conversationId)) return "hold";
1544
+ const token = this.turnTokens.get(conversationId);
1545
+ if (item !== void 0 && token && this.now() - token.atMs <= VOUCHER_QUEUE_MS && (step === null || !this.tokenNames(token, step))) {
1546
+ return "hold";
1547
+ }
1548
+ if (this.movedElsewhere(conversationId)) return "hold";
1549
+ if (conversationId !== this.getBound()) return "hold";
1550
+ if (step !== null) {
1551
+ if (this.takeTurnToken(conversationId, step)) return "process";
1552
+ if (this.options.isClaimedElsewhere(conversationId)) return "hold";
1553
+ this.processedTurnSteps.set(conversationId, step);
1554
+ }
1555
+ return "process";
1556
+ }
1557
+ if (role === "subagent") return "drop";
1558
+ if (this.legacy.has(conversationId)) {
1559
+ if (this.options.isClaimedElsewhere(conversationId)) {
1560
+ this.markForeign(conversationId, "claimed by another CodeVibe session");
1561
+ return "drop";
1562
+ }
1563
+ return "legacy";
1564
+ }
1565
+ if (this.options.hooksArmed()) return "hold";
1566
+ if (this.options.isClaimedElsewhere(conversationId)) {
1567
+ this.markForeign(conversationId, "claimed by another CodeVibe session");
1568
+ return "drop";
1569
+ }
1570
+ return "legacy";
1571
+ }
1572
+ /** Buffer one event for a conversation route() answered `hold` for. */
1573
+ hold(conversationId, item) {
1574
+ let entry = this.held.get(conversationId);
1575
+ const itemStep = this.turnStepOf(item);
1576
+ if (itemStep !== null) {
1577
+ if (entry?.parked && !this.hasTurnLine(entry)) {
1578
+ this.dropParked(conversationId, "a new turn started");
1579
+ entry = void 0;
1580
+ }
1581
+ this.unparkable.delete(conversationId);
1582
+ }
1583
+ if (entry?.parked) {
1584
+ const reason = this.parkedDropReason(conversationId, entry, item);
1585
+ if (reason) {
1586
+ this.dropParked(conversationId, reason);
1587
+ entry = void 0;
1588
+ }
1589
+ }
1590
+ if (!entry) {
1591
+ if (this.held.size >= this.maxHeldConversations) {
1592
+ this.evict(this.evictionCandidate());
1593
+ }
1594
+ entry = { items: [], timer: this.startTimer(conversationId), parked: false, overflowed: 0, behindVoucher: false, shared: false, moved: false };
1595
+ this.held.set(conversationId, entry);
1596
+ if (!this.looksForeign(conversationId)) {
1597
+ this.options.onLog?.("Holding agy events until a hook claims the conversation", {
1598
+ conversationId,
1599
+ holdMs: this.holdMs
1600
+ });
1601
+ }
1602
+ }
1603
+ if (entry.items.length >= this.maxHeldPerConversation) {
1604
+ entry.items.shift();
1605
+ entry.overflowed += 1;
1606
+ }
1607
+ entry.items.push(item);
1608
+ if (!entry.shared && this.isShared(conversationId)) entry.shared = true;
1609
+ if (!entry.moved && this.movedElsewhere(conversationId)) entry.moved = true;
1610
+ if (itemStep !== null && this.roles.get(conversationId) === "main") {
1611
+ const token2 = this.takeTurnToken(conversationId, itemStep);
1612
+ if (token2) {
1613
+ this.releaseFromTurnLine(conversationId, itemStep, token2.earlierOwned);
1614
+ return;
1615
+ }
1616
+ }
1617
+ if (itemStep !== null && entry.parked) this.armPendingTurnDeadline(conversationId, entry);
1618
+ const token = this.turnTokens.get(conversationId);
1619
+ if (token && this.now() - token.atMs <= VOUCHER_QUEUE_MS) entry.behindVoucher = true;
1620
+ }
1621
+ heldCount(conversationId) {
1622
+ return this.held.get(conversationId)?.items.length ?? 0;
1623
+ }
1624
+ /** Our PreInvocation hook reported this conversation. */
1625
+ onPreInvocation(report) {
1626
+ this.markHookReceived();
1627
+ const id = report.conversationId;
1628
+ const previous = this.roles.get(id);
1629
+ const role = this.classify(report, previous);
1630
+ const earlierOwned = previous === "main" && !this.movedElsewhere(id) && !this.held.get(id)?.moved;
1631
+ this.lastInvoked.set(id, this.now());
1632
+ this.unparkable.delete(id);
1633
+ const parked = this.held.get(id)?.parked ? this.heldCount(id) : 0;
1634
+ const turnStart = report.invocationNum === void 0 || report.invocationNum === 0;
1635
+ const turnLineStep = report.initialNumSteps === void 0 ? null : report.initialNumSteps - 1;
1636
+ const heldTurnSteps = this.heldTurnSteps(id);
1637
+ const ownLineHeld = turnStart && heldTurnSteps.some((step) => turnLineStep === null || step === turnLineStep);
1638
+ const releases = role !== "main" || heldTurnSteps.length === 0 || ownLineHeld;
1639
+ if (releases && parked > 0) {
1640
+ this.options.onLog?.("This session's hook arrived late; releasing the events kept for it", {
1641
+ conversationId: id,
1642
+ kept: parked
1643
+ });
1644
+ }
1645
+ if (this.foreign.delete(id) && parked === 0) {
1646
+ this.options.onLog?.("This session now owns a conversation whose earlier events were dropped as another session's", {
1647
+ conversationId: id
1648
+ });
1649
+ }
1650
+ this.setRole(id, role, previous, report);
1651
+ this.mainOrder = this.mainOrder.filter((other) => other !== id);
1652
+ if (role === "main") this.mainOrder.push(id);
1653
+ this.publishClaims();
1654
+ const lineAlreadyProcessed = turnStart && turnLineStep !== null && this.processedTurnSteps.get(id) === turnLineStep;
1655
+ if (turnStart) this.processedTurnSteps.delete(id);
1656
+ const vouches = role === "main" && turnStart && !ownLineHeld && !lineAlreadyProcessed;
1657
+ if (vouches) {
1658
+ this.turnTokens.set(id, { step: turnLineStep ?? Number.NaN, earlierOwned, atMs: this.now() });
1659
+ } else if (role !== "main" || ownLineHeld || lineAlreadyProcessed) {
1660
+ this.turnTokens.delete(id);
1661
+ }
1662
+ if (releases) {
1663
+ if (role !== "main") {
1664
+ this.releaseHeld(id, null);
1665
+ } else if (ownLineHeld) {
1666
+ this.releaseFromTurnLine(id, turnLineStep, earlierOwned);
1667
+ } else {
1668
+ const token = this.turnTokens.get(id);
1669
+ const owned = token && this.held.get(id)?.behindVoucher ? token.earlierOwned : earlierOwned || !turnStart;
1670
+ this.releaseHeld(id, owned ? "process" : null);
1671
+ }
1672
+ }
1673
+ const remaining = this.held.get(id);
1674
+ if (vouches && remaining) remaining.behindVoucher = true;
1675
+ return { role, bound: this.getBound() };
1676
+ }
1677
+ /** Our PostToolUse hook reported this conversation. */
1678
+ onPostToolUse(conversationId) {
1679
+ this.markHookReceived();
1680
+ if (this.roles.has(conversationId)) return;
1681
+ const firstType = this.options.readTranscriptFirstType(conversationId);
1682
+ const role = firstType === "USER_INPUT" ? "main" : firstType === "SYSTEM_MESSAGE" ? "subagent" : null;
1683
+ if (role) {
1684
+ this.setRole(conversationId, role, void 0, null);
1685
+ this.foreign.delete(conversationId);
1686
+ this.unparkable.delete(conversationId);
1687
+ this.publishClaims();
1688
+ this.releaseHeld(conversationId, role === "main" ? "process" : null);
1689
+ return;
1690
+ }
1691
+ if (this.unclassified.has(conversationId)) return;
1692
+ this.unclassified.add(conversationId);
1693
+ this.publishClaims();
1694
+ }
1695
+ /** Clear all state (daemon stop). */
1696
+ reset() {
1697
+ for (const entry of this.held.values()) {
1698
+ if (entry.timer) clearTimeout(entry.timer);
1699
+ }
1700
+ this.held.clear();
1701
+ this.roles.clear();
1702
+ this.lastInvoked.clear();
1703
+ this.unclassified.clear();
1704
+ this.foreign.clear();
1705
+ this.legacy.clear();
1706
+ this.unparkable.clear();
1707
+ this.turnTokens.clear();
1708
+ this.processedTurnSteps.clear();
1709
+ this.mainOrder = [];
1710
+ this.hookReceived = false;
1711
+ }
1712
+ setRole(id, role, previous, report) {
1713
+ this.roles.set(id, role);
1714
+ this.unclassified.delete(id);
1715
+ this.legacy.delete(id);
1716
+ if (previous !== role) {
1717
+ this.options.onLog?.("agy conversation classified by hook", {
1718
+ conversationId: id,
1719
+ role,
1720
+ previousRole: previous ?? null,
1721
+ via: report ? "PreInvocation" : "PostToolUse",
1722
+ invocationNum: report?.invocationNum ?? null,
1723
+ initialNumSteps: report?.initialNumSteps ?? null
1724
+ });
1725
+ }
1726
+ }
1727
+ /** Another session's (as far as we know): claimed elsewhere or already dropped as foreign. */
1728
+ looksForeign(conversationId) {
1729
+ if (this.roles.get(conversationId) === "main" && !this.movedElsewhere(conversationId)) return false;
1730
+ return this.foreign.has(conversationId) || this.options.isClaimedElsewhere(conversationId);
1731
+ }
1732
+ /** The held conversation to settle when the hold area is full. */
1733
+ evictionCandidate() {
1734
+ for (const id of this.held.keys()) {
1735
+ if (this.looksForeign(id)) return id;
1736
+ }
1737
+ return this.held.keys().next().value;
1738
+ }
1739
+ /** Only the exact line our turn start named uses its voucher (NaN: step unknown, any line). */
1740
+ tokenNames(token, step) {
1741
+ return Number.isNaN(token.step) || step === token.step;
1742
+ }
1743
+ takeTurnToken(conversationId, step) {
1744
+ const token = this.turnTokens.get(conversationId);
1745
+ if (token === void 0 || !this.tokenNames(token, step)) return null;
1746
+ this.turnTokens.delete(conversationId);
1747
+ return token;
1748
+ }
1749
+ /**
1750
+ * Release held events from our named turn line on. What came before it
1751
+ * belongs to earlier turns: released with it only if we owned the
1752
+ * conversation before this turn, dropped otherwise (Stage 2 R3-F1).
1753
+ */
1754
+ releaseFromTurnLine(conversationId, step, earlierOwned) {
1755
+ const entry = this.held.get(conversationId);
1756
+ if (!entry) return;
1757
+ const named = (item) => {
1758
+ const itemStep = this.turnStepOf(item);
1759
+ return itemStep !== null && (step === null || itemStep === step);
1760
+ };
1761
+ let index = entry.items.findIndex(named);
1762
+ if (step !== null) {
1763
+ for (let i = entry.items.length - 1; i > index; i -= 1) {
1764
+ if (named(entry.items[i])) {
1765
+ index = i;
1766
+ break;
1767
+ }
1768
+ }
1769
+ }
1770
+ if (index > 0 && !earlierOwned) {
1771
+ this.options.onLog?.("Dropping agy events held from an earlier turn of another session", {
1772
+ conversationId,
1773
+ dropped: index
1774
+ });
1775
+ entry.items = entry.items.slice(index);
1776
+ }
1777
+ this.releaseHeld(conversationId, "process");
1778
+ }
1779
+ turnStepOf(item) {
1780
+ return this.options.turnStep?.(item) ?? null;
1781
+ }
1782
+ hasTurnLine(entry) {
1783
+ return entry.items.some((item) => this.turnStepOf(item) !== null);
1784
+ }
1785
+ heldTurnSteps(conversationId) {
1786
+ const entry = this.held.get(conversationId);
1787
+ if (!entry) return [];
1788
+ return entry.items.map((item) => this.turnStepOf(item)).filter((step) => step !== null);
1789
+ }
1790
+ /** A turn line waiting for its owner (until PENDING_TURN_MS if the conversation was shared at any time while held; see Turn lines). */
1791
+ keepPendingTurn(conversationId, entry) {
1792
+ entry.parked = true;
1793
+ if (!entry.shared && this.isShared(conversationId)) entry.shared = true;
1794
+ if (!entry.moved && this.movedElsewhere(conversationId)) entry.moved = true;
1795
+ this.armPendingTurnDeadline(conversationId, entry);
1796
+ this.options.onLog?.("Waiting for the PreInvocation that owns this agy turn", {
1797
+ conversationId,
1798
+ held: entry.items.length
1799
+ });
1800
+ }
1801
+ /**
1802
+ * (Re)starts the deadline of a kept turn. It drops the batch only if another
1803
+ * live session shared the conversation at any time while the batch was held,
1804
+ * the only way a step can repeat (H23); a sharer that exits meanwhile does not
1805
+ * make its never-run input ours (Stage 1 R14-F1). Otherwise it checks again
1806
+ * later, so our own very late hook on an unshared conversation still finds its
1807
+ * line (Stage 1 R13-F1).
1808
+ */
1809
+ armPendingTurnDeadline(conversationId, entry) {
1810
+ if (entry.timer) clearTimeout(entry.timer);
1811
+ const timer = setTimeout(() => {
1812
+ if (this.held.get(conversationId) !== entry) return;
1813
+ if (!entry.moved && this.movedElsewhere(conversationId)) entry.moved = true;
1814
+ if (!entry.shared && this.isShared(conversationId)) entry.shared = true;
1815
+ if (!entry.shared) {
1816
+ this.armPendingTurnDeadline(conversationId, entry);
1817
+ return;
1818
+ }
1819
+ this.options.onLog?.("Dropping an agy turn that no PreInvocation of this session claimed in time", {
1820
+ conversationId,
1821
+ dropped: entry.items.length,
1822
+ pendingTurnMs: PENDING_TURN_MS
1823
+ });
1824
+ this.releaseHeld(conversationId, null);
1825
+ }, PENDING_TURN_MS);
1826
+ timer.unref?.();
1827
+ entry.timer = timer;
1828
+ }
1829
+ /** Another live session claims or has invoked this conversation. */
1830
+ isShared(conversationId) {
1831
+ return this.options.isClaimedElsewhere(conversationId) || this.options.otherInvokedAt(conversationId) !== null;
1832
+ }
1833
+ /** Another live session invoked this conversation more recently than we did. */
1834
+ movedElsewhere(conversationId) {
1835
+ const other = this.options.otherInvokedAt(conversationId);
1836
+ if (other === null) return false;
1837
+ const own = this.lastInvoked.get(conversationId);
1838
+ return own === void 0 || other > own;
1839
+ }
1840
+ classify(report, previous) {
1841
+ const firstType = this.options.readTranscriptFirstType(report.conversationId);
1842
+ if (firstType === "USER_INPUT") return "main";
1843
+ if (firstType === "SYSTEM_MESSAGE") return "subagent";
1844
+ if (previous) return previous;
1845
+ return report.invocationNum === 0 && (report.initialNumSteps ?? 0) >= 1 ? "main" : "subagent";
1846
+ }
1847
+ markHookReceived() {
1848
+ if (this.hookReceived) return;
1849
+ this.hookReceived = true;
1850
+ this.legacy.clear();
1851
+ }
1852
+ markForeign(conversationId, reason) {
1853
+ if (this.foreign.has(conversationId)) return;
1854
+ this.foreign.add(conversationId);
1855
+ this.options.onLog?.("Ignoring agy conversation from another session", { conversationId, reason });
1856
+ }
1857
+ startTimer(conversationId) {
1858
+ const timer = setTimeout(() => this.expire(conversationId), this.holdMs);
1859
+ timer.unref?.();
1860
+ return timer;
1861
+ }
1862
+ /** Why kept events can no longer be ours, or null. */
1863
+ parkedDropReason(conversationId, entry, item) {
1864
+ if (this.options.isModelOutput?.(item)) return "the model ran without this session's PreInvocation";
1865
+ if (entry.items.length >= this.maxParkedPerConversation) return "more events than precede a model call";
1866
+ if (!this.roles.has(conversationId) && !this.hasTurnLine(entry) && this.options.isClaimedElsewhere(conversationId)) {
1867
+ return "claimed by another CodeVibe session";
1868
+ }
1869
+ return null;
1870
+ }
1871
+ dropParked(conversationId, reason) {
1872
+ this.unparkable.add(conversationId);
1873
+ this.options.onLog?.("Dropping agy events kept for a late hook", {
1874
+ conversationId,
1875
+ kept: this.heldCount(conversationId),
1876
+ reason
1877
+ });
1878
+ this.releaseHeld(conversationId, null);
1879
+ }
1880
+ /**
1881
+ * Foreign so far — no hook of ours, or another session invoked it more
1882
+ * recently. Keep its events for a late PreInvocation of ours unless they
1883
+ * already show an invocation that ran without it (Stage 2 R1-F3).
1884
+ */
1885
+ settleForeign(conversationId, reason) {
1886
+ const entry = this.held.get(conversationId);
1887
+ if (!entry) return;
1888
+ const isModelOutput = this.options.isModelOutput;
1889
+ if (isModelOutput && entry.items.some((item) => isModelOutput(item))) {
1890
+ this.unparkable.add(conversationId);
1891
+ }
1892
+ if (!this.unparkable.has(conversationId) && entry.items.length <= this.maxParkedPerConversation) {
1893
+ this.markForeign(conversationId, `${reason}; keeping its first events in case this session's hook is late`);
1894
+ if (entry.timer) clearTimeout(entry.timer);
1895
+ entry.timer = null;
1896
+ entry.parked = true;
1897
+ return;
1898
+ }
1899
+ this.markForeign(conversationId, reason);
1900
+ this.releaseHeld(conversationId, null);
1901
+ }
1902
+ /** Free a slot when the hold area is full. */
1903
+ evict(conversationId) {
1904
+ const entry = this.held.get(conversationId);
1905
+ if (!entry) return;
1906
+ if (!entry.parked) this.expire(conversationId);
1907
+ if (this.held.get(conversationId)?.parked) {
1908
+ this.options.onLog?.("Hold area full; dropping agy events kept for a late hook", {
1909
+ conversationId,
1910
+ kept: this.heldCount(conversationId)
1911
+ });
1912
+ this.releaseHeld(conversationId, null);
1913
+ }
1914
+ }
1915
+ releaseHeld(conversationId, decision) {
1916
+ const entry = this.held.get(conversationId);
1917
+ if (!entry) return;
1918
+ if (entry.timer) clearTimeout(entry.timer);
1919
+ this.held.delete(conversationId);
1920
+ if (entry.overflowed > 0) {
1921
+ this.options.onLog?.("Held agy events overflowed; oldest dropped", {
1922
+ conversationId,
1923
+ dropped: entry.overflowed
1924
+ });
1925
+ }
1926
+ if (decision) this.options.onRelease(entry.items, decision);
1927
+ }
1928
+ expire(conversationId) {
1929
+ const entry = this.held.get(conversationId);
1930
+ if (!entry || entry.parked) return;
1931
+ const token = this.turnTokens.get(conversationId);
1932
+ if (entry.behindVoucher && token && this.roles.get(conversationId) === "main") {
1933
+ if (token.earlierOwned) {
1934
+ this.releaseHeld(conversationId, "process");
1935
+ } else {
1936
+ this.options.onLog?.("Dropping agy events held from an earlier turn of another session", {
1937
+ conversationId,
1938
+ dropped: entry.items.length
1939
+ });
1940
+ this.releaseHeld(conversationId, null);
1941
+ }
1942
+ return;
1943
+ }
1944
+ if (this.hasTurnLine(entry) && this.options.hooksHealthy()) {
1945
+ const isModel = (item) => this.options.isModelOutput?.(item) ?? false;
1946
+ const turnIndexes = entry.items.flatMap((item, index) => this.turnStepOf(item) !== null ? [index] : []);
1947
+ const first = turnIndexes[0];
1948
+ let tailStart = turnIndexes[turnIndexes.length - 1];
1949
+ for (let k = turnIndexes.length - 2; k >= 0; k -= 1) {
1950
+ if (entry.items.slice(turnIndexes[k], tailStart).some(isModel)) break;
1951
+ tailStart = turnIndexes[k];
1952
+ }
1953
+ const earlier = entry.items.slice(0, first);
1954
+ const ranEarlier = entry.items.slice(first, tailStart);
1955
+ entry.items = entry.items.slice(tailStart);
1956
+ if (earlier.length > 0) {
1957
+ if (this.roles.get(conversationId) === "main" && !this.movedElsewhere(conversationId) && !entry.moved) {
1958
+ this.options.onRelease(earlier, "process");
1959
+ } else {
1960
+ this.options.onLog?.("Dropping agy events held from an earlier turn of another session", {
1961
+ conversationId,
1962
+ dropped: earlier.length
1963
+ });
1964
+ }
1965
+ }
1966
+ if (ranEarlier.length > 0) {
1967
+ this.options.onLog?.("Dropping earlier agy turns that ran without this session's PreInvocation", {
1968
+ conversationId,
1969
+ dropped: ranEarlier.length
1970
+ });
1971
+ }
1972
+ if (entry.items.some(isModel)) {
1973
+ this.markForeign(conversationId, "the model ran this turn without this session's PreInvocation");
1974
+ this.releaseHeld(conversationId, null);
1975
+ } else {
1976
+ this.keepPendingTurn(conversationId, entry);
1977
+ }
1978
+ return;
1979
+ }
1980
+ const role = this.roles.get(conversationId);
1981
+ if (role === "main") {
1982
+ if (this.movedElsewhere(conversationId) || entry.moved) {
1983
+ this.settleForeign(conversationId, "another session invoked it more recently");
1984
+ } else {
1985
+ this.releaseHeld(conversationId, "process");
1986
+ }
1987
+ return;
1988
+ }
1989
+ if (role === "subagent") {
1990
+ this.releaseHeld(conversationId, null);
1991
+ return;
1992
+ }
1993
+ if (this.options.isClaimedElsewhere(conversationId)) {
1994
+ this.markForeign(conversationId, "claimed by another CodeVibe session");
1995
+ this.releaseHeld(conversationId, null);
1996
+ return;
1997
+ }
1998
+ if (this.options.hooksHealthy()) {
1999
+ this.settleForeign(conversationId, `no hook from this session within ${this.holdMs} ms`);
2000
+ return;
2001
+ }
2002
+ this.legacy.add(conversationId);
2003
+ this.options.onLog?.("No agy hook seen; routing conversation by the CLI log (legacy)", {
2004
+ conversationId,
2005
+ held: entry.items.length
2006
+ });
2007
+ this.releaseHeld(conversationId, "legacy");
2008
+ }
2009
+ publishClaims() {
2010
+ if (!this.options.onClaimsChanged) return;
2011
+ const claims = /* @__PURE__ */ new Map();
2012
+ for (const [id, role] of this.roles) {
2013
+ const lastInvokedMs = this.lastInvoked.get(id);
2014
+ claims.set(id, lastInvokedMs === void 0 ? { role } : { role, lastInvokedMs });
2015
+ }
2016
+ for (const id of this.unclassified) claims.set(id, { role: "unknown" });
2017
+ this.options.onClaimsChanged(claims);
2018
+ }
2019
+ };
2020
+
2021
+ // src/claim-registry.ts
1394
2022
  var fs3 = __toESM(require("fs"));
1395
- var os3 = __toESM(require("os"));
1396
- var path3 = __toESM(require("path"));
2023
+ var os4 = __toESM(require("os"));
2024
+ var path4 = __toESM(require("path"));
2025
+ var CLAIMS_FILENAME = "claims.json";
2026
+ var SCAN_CACHE_MS = 1e3;
2027
+ var MAX_CLAIMS_FILE_BYTES = 256 * 1024;
2028
+ var MAX_CLAIMED_CONVERSATIONS = 500;
2029
+ var BOOT_TIME_SLACK_MS = 6e4;
2030
+ function isPidAlive(pid) {
2031
+ try {
2032
+ process.kill(pid, 0);
2033
+ return true;
2034
+ } catch (error) {
2035
+ return error.code === "EPERM";
2036
+ }
2037
+ }
2038
+ var ClaimRegistry = class {
2039
+ constructor(options) {
2040
+ this.cache = null;
2041
+ this.runtimeDir = options.runtimeDir;
2042
+ this.wrapperPid = options.wrapperPid;
2043
+ this.daemonPid = options.daemonPid ?? process.pid;
2044
+ this.isPidAlive = options.isPidAlive ?? isPidAlive;
2045
+ this.now = options.now ?? Date.now;
2046
+ this.bootTimeMs = options.bootTimeMs ?? (() => Date.now() - os4.uptime() * 1e3);
2047
+ }
2048
+ isEnabled() {
2049
+ return this.runtimeDir !== null && this.wrapperPid !== null;
2050
+ }
2051
+ /** Replace this daemon's claims file with `conversations` (best-effort). */
2052
+ writeOwn(conversations) {
2053
+ if (!this.runtimeDir || this.wrapperPid === null) return;
2054
+ const entries = [...conversations.entries()].slice(-MAX_CLAIMED_CONVERSATIONS);
2055
+ const file = {
2056
+ version: 1,
2057
+ wrapperPid: this.wrapperPid,
2058
+ daemonPid: this.daemonPid,
2059
+ conversations: entries.map(([id, claim]) => claim.lastInvokedMs === void 0 ? { id, role: claim.role } : { id, role: claim.role, lastInvokedMs: claim.lastInvokedMs }),
2060
+ updatedAt: new Date(this.now()).toISOString()
2061
+ };
2062
+ const dest = path4.join(this.runtimeDir, CLAIMS_FILENAME);
2063
+ const tmp = `${dest}.tmp.${process.pid}`;
2064
+ try {
2065
+ fs3.writeFileSync(tmp, JSON.stringify(file), { mode: 384 });
2066
+ fs3.renameSync(tmp, dest);
2067
+ } catch (error) {
2068
+ try {
2069
+ fs3.unlinkSync(tmp);
2070
+ } catch {
2071
+ }
2072
+ logger.warn("Could not write conversation claims (other sessions may route by the CLI log)", {
2073
+ error: String(error)
2074
+ });
2075
+ }
2076
+ }
2077
+ /** Remove this daemon's claims file (on stop). */
2078
+ removeOwn() {
2079
+ if (!this.runtimeDir) return;
2080
+ try {
2081
+ fs3.unlinkSync(path4.join(this.runtimeDir, CLAIMS_FILENAME));
2082
+ } catch {
2083
+ }
2084
+ }
2085
+ /** True when another live daemon has claimed this conversation. */
2086
+ isClaimedElsewhere(conversationId) {
2087
+ return this.scan()?.claimed.has(conversationId) ?? false;
2088
+ }
2089
+ /** Latest time another live daemon's agy invoked this conversation, or null. */
2090
+ otherInvokedAt(conversationId) {
2091
+ return this.scan()?.invokedAt.get(conversationId) ?? null;
2092
+ }
2093
+ scan() {
2094
+ if (!this.runtimeDir) return null;
2095
+ const now = this.now();
2096
+ if (!this.cache || now - this.cache.atMs >= SCAN_CACHE_MS) {
2097
+ this.cache = { atMs: now, ...this.scanOthers() };
2098
+ }
2099
+ return this.cache;
2100
+ }
2101
+ /** Drop the scan cache (tests, and right after a foreign claim is suspected). */
2102
+ invalidate() {
2103
+ this.cache = null;
2104
+ }
2105
+ scanOthers() {
2106
+ const claimed = /* @__PURE__ */ new Set();
2107
+ const invokedAt = /* @__PURE__ */ new Map();
2108
+ if (!this.runtimeDir) return { claimed, invokedAt };
2109
+ const base = path4.dirname(this.runtimeDir);
2110
+ const own = path4.basename(this.runtimeDir);
2111
+ const uid = process.getuid?.();
2112
+ let names;
2113
+ try {
2114
+ names = fs3.readdirSync(base);
2115
+ } catch {
2116
+ return { claimed, invokedAt };
2117
+ }
2118
+ const bootedAt = this.bootTimeMs() - BOOT_TIME_SLACK_MS;
2119
+ for (const name of names) {
2120
+ if (name === own || !/^[0-9]{1,10}$/.test(name)) continue;
2121
+ const file = path4.join(base, name, CLAIMS_FILENAME);
2122
+ let parsed;
2123
+ try {
2124
+ const stat = fs3.lstatSync(file);
2125
+ if (!stat.isFile() || stat.size > MAX_CLAIMS_FILE_BYTES) continue;
2126
+ if (uid !== void 0 && stat.uid !== uid) continue;
2127
+ if (stat.mtimeMs < bootedAt) continue;
2128
+ parsed = JSON.parse(fs3.readFileSync(file, "utf8"));
2129
+ } catch {
2130
+ continue;
2131
+ }
2132
+ if (!parsed || typeof parsed !== "object") continue;
2133
+ const claims = parsed;
2134
+ if (claims.version !== 1 || claims.wrapperPid !== Number(name)) continue;
2135
+ if (!Number.isInteger(claims.daemonPid) || !Array.isArray(claims.conversations)) continue;
2136
+ if (!this.isPidAlive(claims.daemonPid)) continue;
2137
+ for (const entry of claims.conversations) {
2138
+ if (!entry || typeof entry.id !== "string") continue;
2139
+ claimed.add(entry.id);
2140
+ const at = entry.lastInvokedMs;
2141
+ if (entry.role === "main" && typeof at === "number" && Number.isFinite(at)) {
2142
+ invokedAt.set(entry.id, Math.max(at, invokedAt.get(entry.id) ?? 0));
2143
+ }
2144
+ }
2145
+ }
2146
+ return { claimed, invokedAt };
2147
+ }
2148
+ };
2149
+
2150
+ // src/installer.ts
2151
+ var fs4 = __toESM(require("fs"));
2152
+ var path5 = __toESM(require("path"));
2153
+ var HOOK_TIMEOUT_SECONDS = 5;
2154
+ function shellQuote(value) {
2155
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2156
+ }
2157
+ function buildHookEntry(hooksFolder) {
2158
+ const shell = shellQuote(path5.join(hooksFolder, HOOK_SHELL_BASENAME));
2159
+ return {
2160
+ PreInvocation: [
2161
+ { type: "command", command: `${shell} PreInvocation`, timeout: HOOK_TIMEOUT_SECONDS }
2162
+ ],
2163
+ PostToolUse: [
2164
+ {
2165
+ matcher: "*",
2166
+ hooks: [
2167
+ { type: "command", command: `${shell} PostToolUse`, timeout: HOOK_TIMEOUT_SECONDS }
2168
+ ]
2169
+ }
2170
+ ]
2171
+ };
2172
+ }
2173
+ function readHooksConfigSync(hooksConfigPath) {
2174
+ let stat;
2175
+ try {
2176
+ stat = fs4.lstatSync(hooksConfigPath);
2177
+ } catch (err) {
2178
+ if (err.code === "ENOENT") return { kind: "absent" };
2179
+ return { kind: "unsupported", reason: `cannot stat: ${err.message}` };
2180
+ }
2181
+ if (stat.isSymbolicLink()) return { kind: "unsupported", reason: "is a symlink" };
2182
+ if (!stat.isFile()) return { kind: "unsupported", reason: "is not a regular file" };
2183
+ let raw;
2184
+ try {
2185
+ raw = fs4.readFileSync(hooksConfigPath, "utf8");
2186
+ } catch (err) {
2187
+ return { kind: "unsupported", reason: `cannot read: ${err.message}` };
2188
+ }
2189
+ if (raw.trim().length === 0) return { kind: "object", config: {}, mode: stat.mode & 511 };
2190
+ let parsed;
2191
+ try {
2192
+ parsed = JSON.parse(raw);
2193
+ } catch {
2194
+ return { kind: "unsupported", reason: "is not valid JSON" };
2195
+ }
2196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2197
+ return { kind: "unsupported", reason: "is not a JSON object" };
2198
+ }
2199
+ return { kind: "object", config: parsed, mode: stat.mode & 511 };
2200
+ }
2201
+ function sameJson(a, b) {
2202
+ return JSON.stringify(a) === JSON.stringify(b);
2203
+ }
2204
+ function isUserDisabled(entry) {
2205
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
2206
+ const named = entry;
2207
+ if (named.enabled === false) return true;
2208
+ const visit = (value) => {
2209
+ if (Array.isArray(value)) return value.some(visit);
2210
+ if (!value || typeof value !== "object") return false;
2211
+ const obj = value;
2212
+ return obj.enabled === false || visit(obj.hooks);
2213
+ };
2214
+ return Object.entries(named).some(([key, value]) => key !== "enabled" && visit(value));
2215
+ }
2216
+ function inspectHookRegistration(paths) {
2217
+ const read = readHooksConfigSync(paths.hooksConfigPath);
2218
+ if (read.kind === "unsupported") return "unsupported";
2219
+ if (read.kind === "absent") return "missing";
2220
+ if (isUserDisabled(read.config[HOOK_ENTRY_NAME])) return "missing";
2221
+ if (!sameJson(read.config[HOOK_ENTRY_NAME], buildHookEntry(paths.hooksDir))) return "missing";
2222
+ for (const name of [HOOK_SHELL_BASENAME, HOOK_SCRIPT_BASENAME]) {
2223
+ try {
2224
+ if (!fs4.statSync(path5.join(paths.hooksDir, name)).isFile()) return "missing";
2225
+ } catch {
2226
+ return "missing";
2227
+ }
2228
+ }
2229
+ return "registered";
2230
+ }
2231
+
2232
+ // src/tool-prompt-match.ts
2233
+ var TOOL_COMMAND_KEYS = ["CommandLine", "Command", "command"];
2234
+ var TOOL_PATH_KEYS = [
2235
+ "AbsolutePath",
2236
+ "TargetFile",
2237
+ "FilePath",
2238
+ "file_path",
2239
+ "filePath",
2240
+ "path",
2241
+ "DirectoryPath"
2242
+ ];
2243
+ var EDIT_TOOLS = /* @__PURE__ */ new Set(["WRITE_TO_FILE", "REPLACE_FILE_CONTENT", "MULTI_REPLACE_FILE_CONTENT"]);
2244
+ function stripWrappingQuotes(value) {
2245
+ return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
2246
+ }
2247
+ function collapsePlainSpacing(value) {
2248
+ return /['"`$\\\r\n]/.test(value) ? value : value.replace(/[ \t]+/g, " ");
2249
+ }
2250
+ function normalizeCommand(value) {
2251
+ if (!value) return null;
2252
+ const normalized = collapsePlainSpacing(value.trim());
2253
+ return normalized.length > 0 ? normalized : null;
2254
+ }
2255
+ function normalizePath(value) {
2256
+ if (!value) return null;
2257
+ const normalized = stripWrappingQuotes(value.trim());
2258
+ return normalized.length > 0 ? normalized : null;
2259
+ }
2260
+ function fileLinks(text) {
2261
+ if (!text) return [];
2262
+ const found = [];
2263
+ for (const match of text.matchAll(/file:\/\/([^\s)\]]+)/g)) {
2264
+ try {
2265
+ found.push(decodeURIComponent(match[1]));
2266
+ } catch {
2267
+ found.push(match[1]);
2268
+ }
2269
+ }
2270
+ return found;
2271
+ }
2272
+ function isFileAccessPrompt(state) {
2273
+ return /Allow access to this file\?/i.test(state.paneDisplayHeader ?? "");
2274
+ }
2275
+ function promptMatchesCompletedTool(state, toolName, toolArgs) {
2276
+ if (!toolArgs || state.kind !== "approval") return false;
2277
+ const promptCommand = normalizeCommand(state.pendingCall?.command);
2278
+ if (promptCommand) {
2279
+ for (const key of TOOL_COMMAND_KEYS) {
2280
+ if (normalizeCommand(toolArgs[key]) === promptCommand) return true;
2281
+ }
2282
+ return false;
2283
+ }
2284
+ if (!isFileAccessPrompt(state) && !EDIT_TOOLS.has((toolName ?? "").toUpperCase())) return false;
2285
+ const promptPaths = /* @__PURE__ */ new Set();
2286
+ const declared = normalizePath(state.pendingCall?.filePath);
2287
+ if (declared) promptPaths.add(declared);
2288
+ for (const link of fileLinks(state.paneDisplayHeader)) promptPaths.add(link);
2289
+ if (promptPaths.size === 0) return false;
2290
+ for (const key of TOOL_PATH_KEYS) {
2291
+ const toolPath = normalizePath(toolArgs[key]);
2292
+ if (toolPath && promptPaths.has(toolPath)) return true;
2293
+ }
2294
+ return false;
2295
+ }
2296
+
2297
+ // src/tmux-pane-observer.ts
2298
+ var fs5 = __toESM(require("fs"));
2299
+ var os5 = __toESM(require("os"));
2300
+ var path6 = __toESM(require("path"));
1397
2301
  var import_crypto = require("crypto");
1398
2302
  var import_child_process = require("child_process");
1399
2303
  var import_events2 = require("events");
@@ -1439,7 +2343,7 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1439
2343
  * start()/stop() lifecycle and doesn't distinguish debounce
1440
2344
  * cycles. (Stage 2 iter-4 R1 HIGH finding 2026-05-21.) */
1441
2345
  this.debounceCycleId = 0;
1442
- this.pipeFilePath = options.pipeFilePath ?? path3.join(os3.tmpdir(), `codevibe-agy-pane-${process.pid}.log`);
2346
+ this.pipeFilePath = options.pipeFilePath ?? path6.join(os5.tmpdir(), `codevibe-agy-pane-${process.pid}.log`);
1443
2347
  this.skipTmuxCommands = options.skipTmuxCommands ?? false;
1444
2348
  this.captureFn = options.captureFn ?? null;
1445
2349
  }
@@ -1512,8 +2416,8 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1512
2416
  this.approvalUIActive = false;
1513
2417
  this.currentApprovalHeader = null;
1514
2418
  try {
1515
- fs3.mkdirSync(path3.dirname(this.pipeFilePath), { recursive: true });
1516
- fs3.writeFileSync(this.pipeFilePath, "");
2419
+ fs5.mkdirSync(path6.dirname(this.pipeFilePath), { recursive: true });
2420
+ fs5.writeFileSync(this.pipeFilePath, "");
1517
2421
  if (!this.skipTmuxCommands) {
1518
2422
  await this.enablePipePane();
1519
2423
  }
@@ -1566,7 +2470,7 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1566
2470
  this.watcher = null;
1567
2471
  }
1568
2472
  try {
1569
- fs3.unlinkSync(this.pipeFilePath);
2473
+ fs5.unlinkSync(this.pipeFilePath);
1570
2474
  } catch {
1571
2475
  }
1572
2476
  logger.info("Tmux pane observer stopped", { sessionName: this.sessionName });
@@ -1668,7 +2572,7 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1668
2572
  await execAsync(cmd);
1669
2573
  }
1670
2574
  startFileWatcher() {
1671
- this.watcher = fs3.watch(this.pipeFilePath, (eventType) => {
2575
+ this.watcher = fs5.watch(this.pipeFilePath, (eventType) => {
1672
2576
  if (eventType !== "change") return;
1673
2577
  void this.processFileChanges();
1674
2578
  });
@@ -1888,17 +2792,17 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1888
2792
  }
1889
2793
  }
1890
2794
  readAppendedChunk() {
1891
- const stat = fs3.statSync(this.pipeFilePath);
2795
+ const stat = fs5.statSync(this.pipeFilePath);
1892
2796
  if (stat.size <= this.filePosition) return "";
1893
- const fd = fs3.openSync(this.pipeFilePath, "r");
2797
+ const fd = fs5.openSync(this.pipeFilePath, "r");
1894
2798
  try {
1895
2799
  const length = stat.size - this.filePosition;
1896
2800
  const buffer = Buffer.alloc(length);
1897
- fs3.readSync(fd, buffer, 0, length, this.filePosition);
2801
+ fs5.readSync(fd, buffer, 0, length, this.filePosition);
1898
2802
  this.filePosition = stat.size;
1899
2803
  return buffer.toString("utf-8");
1900
2804
  } finally {
1901
- fs3.closeSync(fd);
2805
+ fs5.closeSync(fd);
1902
2806
  }
1903
2807
  }
1904
2808
  };
@@ -2687,6 +3591,7 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
2687
3591
  body: candidate.body,
2688
3592
  paneOptions: candidate.options,
2689
3593
  paneSemanticKey: buildApprovalSemanticKey(candidate),
3594
+ kind: candidate.kind,
2690
3595
  emittedAt: Date.now(),
2691
3596
  ttlMs: this.promptTtlMs,
2692
3597
  // E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
@@ -2877,6 +3782,7 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
2877
3782
  body: candidate.body,
2878
3783
  paneOptions: candidate.options,
2879
3784
  paneSemanticKey: buildApprovalSemanticKey(candidate),
3785
+ kind: candidate.kind,
2880
3786
  emittedAt: Date.now(),
2881
3787
  ttlMs: this.promptTtlMs
2882
3788
  };
@@ -2902,19 +3808,37 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
2902
3808
  * fresh-emit. Without this, mobile prompt state dangles until TTL
2903
3809
  * AND the next identical approval is forever-suppressed by stale
2904
3810
  * dedup. (Stage 2 R1 MED finding 2026-05-21.)
3811
+ *
3812
+ * `emittedBeforeMs` (F1, Stage 2 R1-F2): a desktop input that waited in the
3813
+ * routing hold proves only that prompts raised BEFORE it was typed are gone.
3814
+ * A prompt emitted after that moment stays pending, and so does its dedup
3815
+ * hash (clearing it would re-emit the still-visible chooser).
2905
3816
  */
2906
- onPanePromptCleared() {
3817
+ onPanePromptCleared(emittedBeforeMs) {
2907
3818
  if (this.pendingPrompts.size === 0 && this.paneOnlyEmittedHashes.size === 0) {
2908
3819
  return [];
2909
3820
  }
2910
3821
  const now = Date.now();
2911
3822
  const cleared = [];
2912
- for (const [promptId] of this.pendingPrompts.entries()) {
3823
+ let retained = false;
3824
+ for (const [promptId, state] of this.pendingPrompts.entries()) {
3825
+ if (emittedBeforeMs !== void 0 && state.emittedAt >= emittedBeforeMs) {
3826
+ retained = true;
3827
+ continue;
3828
+ }
2913
3829
  this.resolvedPrompts.set(promptId, now);
2914
3830
  cleared.push(promptId);
2915
3831
  }
2916
- this.pendingPrompts.clear();
2917
- this.paneOnlyEmittedHashes.clear();
3832
+ if (!retained) {
3833
+ this.pendingPrompts.clear();
3834
+ this.paneOnlyEmittedHashes.clear();
3835
+ } else {
3836
+ for (const promptId of cleared) {
3837
+ const state = this.pendingPrompts.get(promptId);
3838
+ if (state?.paneHash) this.paneOnlyEmittedHashes.delete(state.paneHash);
3839
+ this.pendingPrompts.delete(promptId);
3840
+ }
3841
+ }
2918
3842
  if (cleared.length > 0) {
2919
3843
  logger.info("Pane prompt cleared \u2014 resolved pending prompts", {
2920
3844
  count: cleared.length
@@ -3441,10 +4365,10 @@ var MobilePromptDeduper = class {
3441
4365
 
3442
4366
  // src/daemon-telemetry.ts
3443
4367
  var crypto2 = __toESM(require("crypto"));
3444
- var fs4 = __toESM(require("fs"));
4368
+ var fs6 = __toESM(require("fs"));
3445
4369
  var https = __toESM(require("https"));
3446
- var os4 = __toESM(require("os"));
3447
- var path4 = __toESM(require("path"));
4370
+ var os6 = __toESM(require("os"));
4371
+ var path7 = __toESM(require("path"));
3448
4372
  var MEASUREMENT_ID = "G-GS74YEQTB8";
3449
4373
  var API_SECRET = "lAfOF6OxRzSQ-NsLBRjhAg";
3450
4374
  var GA4_HOSTNAME = "www.google-analytics.com";
@@ -3452,8 +4376,8 @@ var GA4_PATH = `/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SE
3452
4376
  var BEACON_TOTAL_TIMEOUT_MS = 800;
3453
4377
  function readPluginVersion() {
3454
4378
  try {
3455
- const pkgPath = path4.resolve(__dirname, "..", "package.json");
3456
- const raw = fs4.readFileSync(pkgPath, "utf-8");
4379
+ const pkgPath = path7.resolve(__dirname, "..", "package.json");
4380
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
3457
4381
  const pkg = JSON.parse(raw);
3458
4382
  if (typeof pkg.version === "string" && pkg.version.length > 0 && pkg.version.length < 30) {
3459
4383
  return pkg.version;
@@ -3465,14 +4389,14 @@ function readPluginVersion() {
3465
4389
  var PLUGIN_VERSION = readPluginVersion();
3466
4390
  function getClientId() {
3467
4391
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
3468
- return crypto2.createHash("sha256").update(`${os4.hostname()}-${uid}`).digest("hex").substring(0, 36);
4392
+ return crypto2.createHash("sha256").update(`${os6.hostname()}-${uid}`).digest("hex").substring(0, 36);
3469
4393
  }
3470
4394
  function sanitizeErrorMessage(msg) {
3471
4395
  if (!msg) return "";
3472
- const homedir4 = os4.homedir();
4396
+ const homedir5 = os6.homedir();
3473
4397
  let out = msg.replace(/[\n\r\t]/g, " ");
3474
- if (homedir4 && homedir4.length > 0) {
3475
- const safeHome = homedir4.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4398
+ if (homedir5 && homedir5.length > 0) {
4399
+ const safeHome = homedir5.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3476
4400
  out = out.replace(new RegExp(safeHome, "g"), "~");
3477
4401
  }
3478
4402
  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, "");
@@ -3765,7 +4689,7 @@ function extractCommandFromArgs(toolName, args) {
3765
4689
  for (const key of ["CommandLine", "Command", "command", "cmd", "exec"]) {
3766
4690
  const v = a[key];
3767
4691
  if (typeof v === "string" && v.trim().length > 0) {
3768
- return stripWrappingQuotes(v.trim());
4692
+ return stripWrappingQuotes2(v.trim());
3769
4693
  }
3770
4694
  }
3771
4695
  return void 0;
@@ -3785,7 +4709,7 @@ function extractFilePathFromArgs(toolName, args) {
3785
4709
  ]) {
3786
4710
  const v = a[key];
3787
4711
  if (typeof v === "string" && v.trim().length > 0) {
3788
- return stripWrappingQuotes(v.trim());
4712
+ return stripWrappingQuotes2(v.trim());
3789
4713
  }
3790
4714
  }
3791
4715
  return void 0;
@@ -3796,12 +4720,12 @@ function extractCwdFromArgs(args) {
3796
4720
  for (const key of ["Cwd", "cwd", "workingDirectory", "WorkingDirectory"]) {
3797
4721
  const v = a[key];
3798
4722
  if (typeof v === "string" && v.trim().length > 0) {
3799
- return stripWrappingQuotes(v.trim());
4723
+ return stripWrappingQuotes2(v.trim());
3800
4724
  }
3801
4725
  }
3802
4726
  return void 0;
3803
4727
  }
3804
- function stripWrappingQuotes(s) {
4728
+ function stripWrappingQuotes2(s) {
3805
4729
  if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
3806
4730
  return s.slice(1, -1);
3807
4731
  }
@@ -4106,6 +5030,53 @@ var StoredAuthFailureError = class extends Error {
4106
5030
  this.name = "StoredAuthFailureError";
4107
5031
  }
4108
5032
  };
5033
+ var HOOK_REGISTRATION_CACHE_MS = 5e3;
5034
+ var TRANSCRIPT_FIRST_LINE_MAX_BYTES = 256 * 1024;
5035
+ var CONVERSATION_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
5036
+ function defaultHookRouting() {
5037
+ if (process.env.JEST_WORKER_ID) return null;
5038
+ return {
5039
+ hooksDir: hooksDir(),
5040
+ hooksConfigPath: globalHooksConfigPath(),
5041
+ brainRoot: path8.join(os7.homedir(), ".gemini", "antigravity-cli", "brain")
5042
+ };
5043
+ }
5044
+ function commandDigest(command) {
5045
+ const normalized = normalizeCommand(command);
5046
+ return normalized ? crypto3.createHash("sha256").update(normalized).digest("hex").slice(0, 12) : null;
5047
+ }
5048
+ function readTranscriptFirstType(transcriptPath) {
5049
+ let fd = null;
5050
+ try {
5051
+ fd = fs7.openSync(transcriptPath, "r");
5052
+ const buffer = Buffer.alloc(TRANSCRIPT_FIRST_LINE_MAX_BYTES);
5053
+ let filled = 0;
5054
+ while (filled < buffer.length) {
5055
+ const read = fs7.readSync(fd, buffer, filled, buffer.length - filled, filled);
5056
+ if (read === 0) break;
5057
+ filled += read;
5058
+ if (buffer.subarray(0, filled).includes(10)) break;
5059
+ }
5060
+ const newline = buffer.subarray(0, filled).indexOf(10);
5061
+ if (newline < 0) {
5062
+ if (filled < buffer.length) return null;
5063
+ const head = buffer.subarray(0, 4096).toString("utf8");
5064
+ const m = head.match(/^\{[^{}]*?"type"\s*:\s*"(USER_INPUT|SYSTEM_MESSAGE)"/);
5065
+ return m ? m[1] : null;
5066
+ }
5067
+ const first = JSON.parse(buffer.subarray(0, newline).toString("utf8"));
5068
+ return typeof first?.type === "string" ? first.type : null;
5069
+ } catch {
5070
+ return null;
5071
+ } finally {
5072
+ if (fd !== null) {
5073
+ try {
5074
+ fs7.closeSync(fd);
5075
+ } catch {
5076
+ }
5077
+ }
5078
+ }
5079
+ }
4109
5080
  var MOBILE_PROMPT_FLOOR_RECENCY_MS = 12e4;
4110
5081
  var LAUNCH_SETTLE_TIMEOUT_MS = 3e3;
4111
5082
  var TMUX_LIFECYCLE_POLL_MS = 1e3;
@@ -4152,6 +5123,10 @@ var McpServer = class _McpServer {
4152
5123
  this.nativeSources = /* @__PURE__ */ new Map();
4153
5124
  this.nativeToolStart = null;
4154
5125
  this.nativeAdmissionChain = Promise.resolve();
5126
+ /** When each transcript event first reached the router (F1, Stage 2 R1-F2):
5127
+ * a desktop input released from the hold only clears prompts raised before
5128
+ * it was typed. */
5129
+ this.transcriptObservedAt = /* @__PURE__ */ new WeakMap();
4155
5130
  this.toolActivityKeyRefresh = null;
4156
5131
  // E1 (§4/§6a-4) — per-promptId durable raise entries for the agy producer.
4157
5132
  // Keyed by the E1 promptId (= derive(ownerToken)); each holds the SECRET
@@ -4226,7 +5201,8 @@ var McpServer = class _McpServer {
4226
5201
  this.ensureLaunchInFlight = null;
4227
5202
  /** Registry of agy conv UUIDs the wrapper has observed this lifecycle.
4228
5203
  * Populated from handleConversationDiscovered + handleTranscriptEmit
4229
- * (after isMainConversation passes). Used by:
5204
+ * (after routing accepts the conversation) and from a PreInvocation hook
5205
+ * that classifies one as main (F1). Used by:
4230
5206
  * (a) handlePromptCandidate — approvalDetector keys candidates by
4231
5207
  * agy conv UUID, not backend session ID;
4232
5208
  * (b) getSessionByConversation — defensive membership check before
@@ -4235,6 +5211,7 @@ var McpServer = class _McpServer {
4235
5211
  * causes harmless extra candidate-buffer entries in approvalDetector
4236
5212
  * bounded by TTL. (Codex Stage 2 R1 v12 HIGH.) */
4237
5213
  this.observedMainConversationIds = /* @__PURE__ */ new Set();
5214
+ this.hookRegistrationCache = null;
4238
5215
  this.started = false;
4239
5216
  /** Monotonically-increasing counter incremented on every start(). Captured
4240
5217
  * by async callbacks at entry; mismatch after an await means stop()→start()
@@ -4347,15 +5324,33 @@ var McpServer = class _McpServer {
4347
5324
  paneObserver: this.paneObserver,
4348
5325
  detector: this.approvalDetector
4349
5326
  });
4350
- const portFilePath = this.runtimeDir ? path5.join(this.runtimeDir, "conn.json") : void 0;
5327
+ const portFilePath = this.runtimeDir ? path8.join(this.runtimeDir, "conn.json") : void 0;
4351
5328
  this.httpApi = options.httpApi ?? new HttpApi({
4352
5329
  bearerToken: this.bearerToken,
4353
5330
  preferredPort: this.preferredPort,
4354
5331
  portFilePath
4355
5332
  });
5333
+ this.hookRouting = options.hookRouting !== void 0 ? options.hookRouting : defaultHookRouting();
5334
+ this.claims = new ClaimRegistry({ runtimeDir: this.runtimeDir, wrapperPid: this.wrapperPid });
5335
+ this.router = new ConversationRouter({
5336
+ hooksArmed: () => this.router.hasReceivedHook() || this.isHookRegistrationIntact(),
5337
+ hooksHealthy: () => this.router.hasReceivedHook() && (this.hookRouting === null || this.isHookRegistrationIntact()),
5338
+ isClaimedElsewhere: (conversationId) => this.claims.isClaimedElsewhere(conversationId),
5339
+ otherInvokedAt: (conversationId) => this.claims.otherInvokedAt(conversationId),
5340
+ readTranscriptFirstType: (conversationId) => this.readConversationFirstType(conversationId),
5341
+ onRelease: (emits, decision) => {
5342
+ for (const emit of emits) this.dispatchTranscriptEmit(emit, decision);
5343
+ },
5344
+ isModelOutput: (emit) => emit.event?.source === "MODEL",
5345
+ turnStep: (emit) => emit.event?.type === "USER_INPUT" ? typeof emit.event.step_index === "number" ? emit.event.step_index : -1 : null,
5346
+ onClaimsChanged: (claimed) => this.claims.writeOwn(claimed),
5347
+ onLog: (message, data) => logger.info(message, data),
5348
+ holdMs: options.conversationHoldMs
5349
+ });
4356
5350
  this.httpApi.setHandlers({
4357
5351
  pushEvent: (p) => this.handleHttpPushEvent(p),
4358
- getActiveSession: () => this.getAnySession()
5352
+ getActiveSession: () => this.getAnySession(),
5353
+ onAgyHook: (delivery) => this.handleAgyHook(delivery)
4359
5354
  });
4360
5355
  }
4361
5356
  static {
@@ -4453,14 +5448,7 @@ var McpServer = class _McpServer {
4453
5448
  this.addListener(this.transcriptTailer, "error", () => {
4454
5449
  });
4455
5450
  this.addListener(this.transcriptTailer, "event", (emit) => {
4456
- const task = this.handleTranscriptEmit(emit).catch((err) => {
4457
- logger.error("handleTranscriptEmit failed", { error: String(err) });
4458
- });
4459
- this.transcriptHandlers.add(task);
4460
- void task.then(
4461
- () => this.transcriptHandlers.delete(task),
4462
- () => this.transcriptHandlers.delete(task)
4463
- );
5451
+ this.dispatchTranscriptEmit(emit);
4464
5452
  });
4465
5453
  this.addListener(this.transcriptTailer, "conversation-discovered", (conversationId) => {
4466
5454
  void this.handleConversationDiscovered(conversationId).catch((err) => {
@@ -4558,6 +5546,8 @@ var McpServer = class _McpServer {
4558
5546
  }
4559
5547
  }
4560
5548
  this.listenerHandles = [];
5549
+ this.router.reset();
5550
+ this.claims.removeOwn();
4561
5551
  await Promise.allSettled([...this.transcriptHandlers]);
4562
5552
  await this.transcriptTailer.waitForReads();
4563
5553
  await this.nativeAdmissionChain;
@@ -4591,6 +5581,7 @@ var McpServer = class _McpServer {
4591
5581
  await this.boundedTeardownStep("paneObserver.stop", () => this.paneObserver.stop());
4592
5582
  await this.boundedTeardownStep("approvalDetector.stop", () => this.approvalDetector.stop());
4593
5583
  await this.boundedTeardownStep("httpApi.stop", () => this.httpApi.stop());
5584
+ this.removeOrphanedRuntimeDir();
4594
5585
  try {
4595
5586
  this.appSyncClient.cleanupSubscriptions();
4596
5587
  } catch (err) {
@@ -4629,8 +5620,10 @@ var McpServer = class _McpServer {
4629
5620
  if (this.sessionDisabled) return;
4630
5621
  await this.ensureLaunchSession();
4631
5622
  if (!this.session) return;
4632
- if (!this.isMainConversation(conversationId)) {
4633
- logger.debug("Ignoring discovery for non-main conversation (subagent)", { conversationId });
5623
+ const decision = this.router.route(conversationId);
5624
+ if (decision === "hold") this.registerHeldConversationBeforeFirstHook(conversationId);
5625
+ if (decision !== "process" && !(decision === "legacy" && this.legacyAcceptsConversation(conversationId))) {
5626
+ logger.debug("Ignoring discovery for a conversation not (yet) in this session", { conversationId, decision });
4634
5627
  return;
4635
5628
  }
4636
5629
  this.observedMainConversationIds.add(conversationId);
@@ -4874,19 +5867,180 @@ var McpServer = class _McpServer {
4874
5867
  if (this.ensureLaunchInFlight === attempt) this.ensureLaunchInFlight = null;
4875
5868
  }
4876
5869
  }
4877
- isMainConversation(conversationId) {
5870
+ /**
5871
+ * The pre-F1 rule, now only the fallback for conversations no hook has
5872
+ * claimed when hooks do not fire (see conversation-router.ts): the newest
5873
+ * agy CLI log names the active conversation. F1 amendment: a conversation
5874
+ * another live CodeVibe daemon has claimed is never "the active one" here,
5875
+ * so another session's busy log cannot make us drop our own turn.
5876
+ */
5877
+ legacyAcceptsConversation(conversationId) {
4878
5878
  if (process.env.JEST_WORKER_ID && !this.cliLogPath) {
4879
5879
  return true;
4880
5880
  }
4881
- const cliLogPath = this.cliLogPath ?? path5.join(os5.homedir(), ".gemini", "antigravity-cli", "cli.log");
5881
+ if (this.claims.isClaimedElsewhere(conversationId)) {
5882
+ return false;
5883
+ }
5884
+ const cliLogPath = this.cliLogPath ?? path8.join(os7.homedir(), ".gemini", "antigravity-cli", "cli.log");
4882
5885
  const activeId = getActiveConversationFromCliLog(cliLogPath);
4883
- if (!activeId) {
5886
+ if (!activeId || this.claims.isClaimedElsewhere(activeId)) {
4884
5887
  return true;
4885
5888
  }
4886
5889
  return activeId === conversationId;
4887
5890
  }
5891
+ /**
5892
+ * F1 moved the runtime dir under ~/.codevibe, so it outlives reboots. The
5893
+ * wrapper's EXIT trap removes it, except when the outer terminal closed while
5894
+ * agy kept running in tmux (the wrapper is gone; this daemon outlived it).
5895
+ * Then remove it here — only if empty: conn.json and claims.json are already
5896
+ * gone, and anything else is left alone.
5897
+ */
5898
+ removeOrphanedRuntimeDir() {
5899
+ if (!this.runtimeDir || this.wrapperPid === null || isPidAlive(this.wrapperPid)) return;
5900
+ try {
5901
+ fs7.rmdirSync(this.runtimeDir);
5902
+ logger.info("Removed runtime dir left by a closed terminal", { runtimeDir: this.runtimeDir });
5903
+ } catch (error) {
5904
+ const code = error.code;
5905
+ if (code !== "ENOENT") {
5906
+ logger.warn("Could not remove runtime dir", { runtimeDir: this.runtimeDir, code: code ?? String(error) });
5907
+ }
5908
+ }
5909
+ }
5910
+ isHookRegistrationIntact() {
5911
+ if (!this.hookRouting) return false;
5912
+ const now = Date.now();
5913
+ if (!this.hookRegistrationCache || now - this.hookRegistrationCache.atMs >= HOOK_REGISTRATION_CACHE_MS) {
5914
+ this.hookRegistrationCache = {
5915
+ atMs: now,
5916
+ registered: inspectHookRegistration(this.hookRouting) === "registered"
5917
+ };
5918
+ }
5919
+ return this.hookRegistrationCache.registered;
5920
+ }
5921
+ readConversationFirstType(conversationId) {
5922
+ if (!this.hookRouting || !CONVERSATION_ID_PATTERN.test(conversationId)) return null;
5923
+ return readTranscriptFirstType(path8.join(
5924
+ this.hookRouting.brainRoot,
5925
+ conversationId,
5926
+ ".system_generated",
5927
+ "logs",
5928
+ "transcript.jsonl"
5929
+ ));
5930
+ }
5931
+ // ─── agy hooks (F1) ─────────────────────────────────────────────────────
5932
+ /**
5933
+ * One hook delivery from OUR agy (agy-hook.js only posts here when the
5934
+ * launcher's inline CODEVIBE_AGY_HOOK_OWNER names this daemon's runtime
5935
+ * dir). Updates routing state synchronously; network work runs afterwards so
5936
+ * the HTTP reply — which agy is waiting for — goes out at once.
5937
+ */
5938
+ handleAgyHook(delivery) {
5939
+ if (!this.started) return;
5940
+ const { conversationId } = delivery;
5941
+ if (delivery.event === "PreInvocation") {
5942
+ const previousBound = this.router.getBound();
5943
+ const { role, bound } = this.router.onPreInvocation({
5944
+ conversationId,
5945
+ invocationNum: delivery.invocationNum,
5946
+ initialNumSteps: delivery.initialNumSteps
5947
+ });
5948
+ if (role === "main") {
5949
+ this.observedMainConversationIds.add(conversationId);
5950
+ } else {
5951
+ this.observedMainConversationIds.delete(conversationId);
5952
+ }
5953
+ if (bound !== previousBound) {
5954
+ logger.info("agy session bound to conversation (hook)", {
5955
+ conversationId: bound,
5956
+ previousConversationId: previousBound,
5957
+ launchSessionId: this.session?.sessionId ?? null
5958
+ });
5959
+ }
5960
+ return;
5961
+ }
5962
+ this.router.onPostToolUse(conversationId);
5963
+ void this.resolvePromptsForCompletedTool(delivery).catch((error) => {
5964
+ logger.warn("PostToolUse prompt resolution failed", { error: String(error) });
5965
+ });
5966
+ }
5967
+ /**
5968
+ * A tool our agy just ran was blocked on its approval prompt, so a pending
5969
+ * mobile card for that same command or file is answered: resolve it the way
5970
+ * a mobile reply does. Identity match only (tool-prompt-match.ts).
5971
+ */
5972
+ async resolvePromptsForCompletedTool(delivery) {
5973
+ const session = this.session;
5974
+ if (!session) return;
5975
+ const pending = this.approvalDetector.getPendingPromptsForConversation();
5976
+ const matched = pending.filter((state) => promptMatchesCompletedTool(state, delivery.toolName, delivery.toolArgs));
5977
+ logger.info("PostToolUse checked pending prompts", {
5978
+ conversationId: delivery.conversationId,
5979
+ toolName: delivery.toolName ?? null,
5980
+ commandDigest: commandDigest(delivery.toolArgs?.CommandLine),
5981
+ pending: pending.length,
5982
+ matched: matched.length
5983
+ });
5984
+ for (const state of matched) {
5985
+ const stillPending = this.approvalDetector.resolvePrompt(state.promptId) !== null;
5986
+ const ownedByE1 = this.e1PromptRaises.has(state.promptId);
5987
+ const resolved = await this.resolveE1PluginApproval(session.sessionId, state.promptId);
5988
+ logger.info("Resolved interactive prompt answered on the desktop (PostToolUse)", {
5989
+ promptId: state.promptId,
5990
+ toolName: delivery.toolName ?? null,
5991
+ stillPending,
5992
+ ownedByE1,
5993
+ backendAcknowledged: resolved
5994
+ });
5995
+ }
5996
+ }
4888
5997
  // ─── Transcript flow ────────────────────────────────────────────────────
4889
- async handleTranscriptEmit(emit, admission) {
5998
+ /**
5999
+ * Route one transcript event synchronously, at arrival, so held events and
6000
+ * later ones keep their order. `released` carries the router's decision for
6001
+ * events leaving the hold buffer.
6002
+ */
6003
+ dispatchTranscriptEmit(emit, released) {
6004
+ if (!this.started) return;
6005
+ if (!this.transcriptObservedAt.has(emit)) this.transcriptObservedAt.set(emit, Date.now());
6006
+ const decision = released ?? this.router.route(emit.conversationId, emit);
6007
+ if (decision === "drop") {
6008
+ logger.debug("Ignoring transcript event from a conversation outside this session", {
6009
+ conversationId: emit.conversationId
6010
+ });
6011
+ return;
6012
+ }
6013
+ if (decision === "hold") {
6014
+ this.router.hold(emit.conversationId, emit);
6015
+ this.registerHeldConversationBeforeFirstHook(emit.conversationId);
6016
+ return;
6017
+ }
6018
+ const task = this.handleTranscriptEmit(emit, decision).catch((err) => {
6019
+ logger.error("handleTranscriptEmit failed", { error: String(err) });
6020
+ });
6021
+ this.transcriptHandlers.add(task);
6022
+ void task.then(
6023
+ () => this.transcriptHandlers.delete(task),
6024
+ () => this.transcriptHandlers.delete(task)
6025
+ );
6026
+ }
6027
+ /**
6028
+ * Until this daemon's first hook arrives it cannot tell "hooks will claim
6029
+ * this" from "hooks never fire here" (older agy, or an account where agy's
6030
+ * hook gate is off). Keep the pre-F1 registration for a held conversation the
6031
+ * legacy rule accepts, so an approval prompt raised during the hold still has
6032
+ * an owning conversation and reaches the phone with its options. Registration
6033
+ * only makes the conversation eligible for prompt ownership; its events still
6034
+ * wait for the router. (Stage 1 R1-F2.)
6035
+ */
6036
+ registerHeldConversationBeforeFirstHook(conversationId) {
6037
+ if (this.router.hasReceivedHook()) return;
6038
+ if (this.observedMainConversationIds.has(conversationId)) return;
6039
+ if (this.claims.isClaimedElsewhere(conversationId)) return;
6040
+ if (!this.legacyAcceptsConversation(conversationId)) return;
6041
+ this.observedMainConversationIds.add(conversationId);
6042
+ }
6043
+ async handleTranscriptEmit(emit, decision, admission) {
4890
6044
  if (!this.started) return;
4891
6045
  const { conversationId, byteOffset, event } = emit;
4892
6046
  if (this.sessionDisabled) {
@@ -4896,7 +6050,7 @@ var McpServer = class _McpServer {
4896
6050
  if (!this.session) {
4897
6051
  return;
4898
6052
  }
4899
- if (!this.isMainConversation(conversationId)) {
6053
+ if (decision === "legacy" && !this.legacyAcceptsConversation(conversationId)) {
4900
6054
  logger.debug("Ignoring transcript emit for non-main conversation (subagent)", { conversationId });
4901
6055
  return;
4902
6056
  }
@@ -4951,8 +6105,9 @@ var McpServer = class _McpServer {
4951
6105
  }
4952
6106
  }
4953
6107
  if (input.type === import_codevibe_core6.EventType.USER_PROMPT && input.source === import_codevibe_core6.EventSource.DESKTOP) {
4954
- const clearedPromptIds = this.approvalDetector.onPanePromptCleared();
4955
- await this.resolveAcceptedInputPrompts(session, clearedPromptIds);
6108
+ const inputObservedAtMs = this.transcriptObservedAt.get(emit);
6109
+ const clearedPromptIds = this.approvalDetector.onPanePromptCleared(inputObservedAtMs);
6110
+ await this.resolveAcceptedInputPrompts(session, clearedPromptIds, inputObservedAtMs);
4956
6111
  const dedupe = admission ? null : this.mobileDeduper.consumeIfDuplicate(session.sessionId, input.content);
4957
6112
  if (dedupe) {
4958
6113
  if (admission) admission.echoContent = input.content;
@@ -4998,10 +6153,13 @@ var McpServer = class _McpServer {
4998
6153
  if (!this.started || this.sessionDisabled) throw Error("SYNC_SOURCE_STOPPED");
4999
6154
  await this.ensureLaunchSession();
5000
6155
  const session = this.session;
5001
- if (!session || !this.isMainConversation(source.conversationId)) throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
6156
+ if (!session) throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
5002
6157
  const selection = await this.selectNativeSource(session);
5003
6158
  if (!this.started || this.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
5004
6159
  if (selection.protocol === "LEGACY") return null;
6160
+ if (!this.legacyAcceptsConversation(source.conversationId) || this.router.roleOf(source.conversationId) === "subagent") {
6161
+ throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
6162
+ }
5005
6163
  if (!this.toolActivity?.isActive(session.sessionId)) {
5006
6164
  if (this.nativeToolStart && this.nativeToolStart.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
5007
6165
  if (!this.nativeToolStart) {
@@ -5058,7 +6216,7 @@ var McpServer = class _McpServer {
5058
6216
  this.assertNativeOwner(owner);
5059
6217
  if ((await owner.cursor.open(0)).byteOffset >= source.endOffset) return;
5060
6218
  const admission = { outputs: [null, null] };
5061
- await this.handleTranscriptEmit(emit, admission);
6219
+ await this.handleTranscriptEmit(emit, "legacy", admission);
5062
6220
  this.assertNativeOwner(owner);
5063
6221
  const echoFrame = {
5064
6222
  streamSha256: owner.streamSha256,
@@ -5277,7 +6435,7 @@ var McpServer = class _McpServer {
5277
6435
  agentType: "ANTIGRAVITY",
5278
6436
  ...title !== void 0 && { title }
5279
6437
  });
5280
- this.e1PromptRaises.set(record.promptId, { record });
6438
+ this.e1PromptRaises.set(record.promptId, { record, raisedAtMs: Date.now() });
5281
6439
  if (!this.e1TtlRefreshTimer) this.startE1TtlRefresh();
5282
6440
  return record;
5283
6441
  }
@@ -5337,10 +6495,12 @@ var McpServer = class _McpServer {
5337
6495
  }
5338
6496
  }
5339
6497
  }
5340
- async resolveAcceptedInputPrompts(session, observedPromptIds = []) {
6498
+ async resolveAcceptedInputPrompts(session, observedPromptIds = [], inputObservedAtMs) {
5341
6499
  const promptIds = new Set(observedPromptIds);
5342
6500
  for (const entry of this.e1PromptRaises.values()) {
5343
- if (entry.record.sessionId === session.sessionId) promptIds.add(entry.record.promptId);
6501
+ if (entry.record.sessionId !== session.sessionId) continue;
6502
+ if (inputObservedAtMs !== void 0 && (entry.raisedAtMs ?? 0) >= inputObservedAtMs) continue;
6503
+ promptIds.add(entry.record.promptId);
5344
6504
  }
5345
6505
  for (const promptId of promptIds) {
5346
6506
  await this.resolveE1PluginApproval(session.sessionId, promptId);
@@ -5437,6 +6597,7 @@ var McpServer = class _McpServer {
5437
6597
  snapshotOpenE1(sessionId) {
5438
6598
  return [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sessionId).map((e) => ({
5439
6599
  record: { ...e.record },
6600
+ ...e.raisedAtMs !== void 0 && { raisedAtMs: e.raisedAtMs },
5440
6601
  ...e.contentPlain !== void 0 && { contentPlain: e.contentPlain },
5441
6602
  ...e.metadataPlain !== void 0 && { metadataPlain: e.metadataPlain }
5442
6603
  }));
@@ -5458,6 +6619,7 @@ var McpServer = class _McpServer {
5458
6619
  for (const snap of snapshot) {
5459
6620
  const entry = {
5460
6621
  record: { ...snap.record, sessionId: replacementSession.sessionId },
6622
+ ...snap.raisedAtMs !== void 0 && { raisedAtMs: snap.raisedAtMs },
5461
6623
  ...snap.contentPlain !== void 0 && { contentPlain: snap.contentPlain },
5462
6624
  ...snap.metadataPlain !== void 0 && { metadataPlain: snap.metadataPlain }
5463
6625
  };
@@ -5831,6 +6993,11 @@ var McpServer = class _McpServer {
5831
6993
  if (cand.snapshotHash) {
5832
6994
  this.e1ContentKeyIndex.set(`${session.sessionId}::${cand.snapshotHash}`, e1rec.promptId);
5833
6995
  }
6996
+ logger.info("Pane prompt identity", {
6997
+ promptId: e1rec.promptId,
6998
+ conversationId: activeConvId,
6999
+ commandDigest: commandDigest(parsed.command)
7000
+ });
5834
7001
  } else {
5835
7002
  this.e1PromptRaises.delete(e1rec.promptId);
5836
7003
  }
@@ -5838,13 +7005,16 @@ var McpServer = class _McpServer {
5838
7005
  /**
5839
7006
  * Pick the agy conversation UUID that owns the current approval UI.
5840
7007
  * Priority:
5841
- * 1. cli.log says it's a specific conv → return that one if observed.
5842
- * 2. Otherwise, return the most recently observed main conv UUID.
5843
- * 3. Otherwise null (no active conv).
7008
+ * 1. F1: the conversation our PreInvocation hook last bound.
7009
+ * 2. cli.log says it's a specific conv → return that one if observed.
7010
+ * 3. Otherwise, return the most recently observed main conv UUID.
7011
+ * 4. Otherwise null (no active conv).
5844
7012
  */
5845
7013
  pickActiveConversationForPrompt() {
7014
+ const bound = this.router.getBound();
7015
+ if (bound && this.observedMainConversationIds.has(bound)) return bound;
5846
7016
  if (this.observedMainConversationIds.size === 0) return null;
5847
- const cliLogPath = this.cliLogPath ?? path5.join(os5.homedir(), ".gemini", "antigravity-cli", "cli.log");
7017
+ const cliLogPath = this.cliLogPath ?? path8.join(os7.homedir(), ".gemini", "antigravity-cli", "cli.log");
5848
7018
  const cliActive = getActiveConversationFromCliLog(cliLogPath);
5849
7019
  if (cliActive && this.observedMainConversationIds.has(cliActive)) {
5850
7020
  return cliActive;
@@ -5982,13 +7152,13 @@ var McpServer = class _McpServer {
5982
7152
  } else if (shouldDecrypt && !sessionKey) {
5983
7153
  logger.warn("Cannot decrypt attachment - no session key available", { id: attachment.id });
5984
7154
  }
5985
- const attachmentsDir = path5.join(this.workingDirectory, ".codevibe-attachments");
5986
- if (!fs5.existsSync(attachmentsDir)) {
5987
- fs5.mkdirSync(attachmentsDir, { recursive: true });
7155
+ const attachmentsDir = path8.join(this.workingDirectory, ".codevibe-attachments");
7156
+ if (!fs7.existsSync(attachmentsDir)) {
7157
+ fs7.mkdirSync(attachmentsDir, { recursive: true });
5988
7158
  }
5989
7159
  let extension = "";
5990
7160
  if (attachment.filename) {
5991
- const ext = path5.extname(attachment.filename);
7161
+ const ext = path8.extname(attachment.filename);
5992
7162
  if (ext) extension = ext;
5993
7163
  }
5994
7164
  if (!extension) {
@@ -6003,8 +7173,8 @@ var McpServer = class _McpServer {
6003
7173
  extension = mimeToExt[attachment.type] || ".bin";
6004
7174
  }
6005
7175
  const filename = `attachment-${attachment.id}${extension}`;
6006
- const absolutePath = path5.join(attachmentsDir, filename);
6007
- fs5.writeFileSync(absolutePath, buffer);
7176
+ const absolutePath = path8.join(attachmentsDir, filename);
7177
+ fs7.writeFileSync(absolutePath, buffer);
6008
7178
  const relativePath = `./.codevibe-attachments/${filename}`;
6009
7179
  logger.info("Attachment saved to working directory", {
6010
7180
  id: attachment.id,
@@ -6520,7 +7690,7 @@ function getActiveConversationFromCliLog(cliLogPath) {
6520
7690
  try {
6521
7691
  let initialStat;
6522
7692
  try {
6523
- initialStat = fs5.lstatSync(cliLogPath);
7693
+ initialStat = fs7.lstatSync(cliLogPath);
6524
7694
  } catch {
6525
7695
  return null;
6526
7696
  }
@@ -6532,19 +7702,19 @@ function getActiveConversationFromCliLog(cliLogPath) {
6532
7702
  if (cached && now - cached.lastScanMs < CLI_LOG_RESOLVE_TTL_MS) {
6533
7703
  resolvedPath = cached.resolvedPath;
6534
7704
  } else {
6535
- const logDir = path5.join(path5.dirname(cliLogPath), "log");
7705
+ const logDir = path8.join(path8.dirname(cliLogPath), "log");
6536
7706
  let dirOk = false;
6537
7707
  try {
6538
- dirOk = fs5.statSync(logDir).isDirectory();
7708
+ dirOk = fs7.statSync(logDir).isDirectory();
6539
7709
  } catch {
6540
7710
  }
6541
7711
  if (dirOk) {
6542
7712
  const entries = [];
6543
- for (const name of fs5.readdirSync(logDir)) {
7713
+ for (const name of fs7.readdirSync(logDir)) {
6544
7714
  if (!/^cli-.*\.log$/.test(name)) continue;
6545
- const full = path5.join(logDir, name);
7715
+ const full = path8.join(logDir, name);
6546
7716
  try {
6547
- const s = fs5.statSync(full);
7717
+ const s = fs7.statSync(full);
6548
7718
  if (s.isFile()) entries.push({ full, mtimeMs: s.mtimeMs });
6549
7719
  } catch {
6550
7720
  }
@@ -6563,7 +7733,7 @@ function getActiveConversationFromCliLog(cliLogPath) {
6563
7733
  resolvedPath = initialStat.isSymbolicLink() ? null : cliLogPath;
6564
7734
  }
6565
7735
  if (resolvedPath === null) return null;
6566
- const content = fs5.readFileSync(resolvedPath, "utf8");
7736
+ const content = fs7.readFileSync(resolvedPath, "utf8");
6567
7737
  const regex = /(?:Created conversation|Streaming conversation|Starting conversation update stream for)\s+([0-9a-fA-F-]{36})/g;
6568
7738
  const matches = Array.from(content.matchAll(regex));
6569
7739
  if (matches.length === 0) return null;
@@ -6765,8 +7935,10 @@ var __testing = {
6765
7935
  __testing,
6766
7936
  classifyEntrypoint,
6767
7937
  classifyTmuxHasSessionError,
7938
+ commandDigest,
6768
7939
  generateLaunchSessionId,
6769
7940
  getActiveConversationFromCliLog,
6770
7941
  parseMaybeJson,
7942
+ readTranscriptFirstType,
6771
7943
  runForeignSpawnIdle
6772
7944
  });