@quantiya/codevibe-antigravity-plugin 2.0.11 → 2.0.12

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
@@ -32,7 +32,9 @@ var server_exports = {};
32
32
  __export(server_exports, {
33
33
  McpServer: () => McpServer,
34
34
  SessionNotFoundError: () => SessionNotFoundError,
35
+ TERMINAL_SHUTDOWN_SIGNALS: () => TERMINAL_SHUTDOWN_SIGNALS,
35
36
  __testing: () => __testing,
37
+ classifyTmuxHasSessionError: () => classifyTmuxHasSessionError,
36
38
  generateLaunchSessionId: () => generateLaunchSessionId,
37
39
  getActiveConversationFromCliLog: () => getActiveConversationFromCliLog,
38
40
  parseMaybeJson: () => parseMaybeJson
@@ -42,6 +44,8 @@ var crypto3 = __toESM(require("crypto"));
42
44
  var path5 = __toESM(require("path"));
43
45
  var fs5 = __toESM(require("fs"));
44
46
  var os5 = __toESM(require("os"));
47
+ var import_child_process3 = require("child_process");
48
+ var import_util3 = require("util");
45
49
  var import_codevibe_core4 = require("@quantiya/codevibe-core");
46
50
 
47
51
  // src/logger.ts
@@ -2977,6 +2981,27 @@ function truncate(s, maxBytes) {
2977
2981
  // src/server.ts
2978
2982
  var MOBILE_PROMPT_FLOOR_RECENCY_MS = 12e4;
2979
2983
  var LAUNCH_SETTLE_TIMEOUT_MS = 3e3;
2984
+ var TMUX_LIFECYCLE_POLL_MS = 1e3;
2985
+ var TMUX_LIFECYCLE_INIT_GRACE_MS = 1e4;
2986
+ var SESSION_RETIRE_MAX_ATTEMPTS = 3;
2987
+ var SESSION_RETIRE_RETRY_MS = 100;
2988
+ var execFileAsync = (0, import_util3.promisify)(import_child_process3.execFile);
2989
+ var TERMINAL_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
2990
+ function commandErrorText(error, field) {
2991
+ const value = error?.[field];
2992
+ if (typeof value === "string") return value;
2993
+ if (Buffer.isBuffer(value)) return value.toString("utf8");
2994
+ return "";
2995
+ }
2996
+ function classifyTmuxHasSessionError(error) {
2997
+ if (typeof error?.code !== "number") return "unknown";
2998
+ const output = `${commandErrorText(error, "stderr")}
2999
+ ${commandErrorText(error, "stdout")}`;
3000
+ if (/^can't find session:/m.test(output) || /^no server running on /m.test(output) || /^error connecting to .* \((?:No such file or directory|Connection refused)\)$/m.test(output)) {
3001
+ return "absent";
3002
+ }
3003
+ return "unknown";
3004
+ }
2980
3005
  var McpServer = class _McpServer {
2981
3006
  constructor(options) {
2982
3007
  /** Per-session causal floor for transcript-event timestamps emitted right
@@ -3117,6 +3142,11 @@ var McpServer = class _McpServer {
3117
3142
  this.signalsRegistered = false;
3118
3143
  this.boundSigintHandler = null;
3119
3144
  this.boundSigtermHandler = null;
3145
+ this.boundSighupHandler = null;
3146
+ this.tmuxLifecycleTimer = null;
3147
+ this.tmuxLifecycleCheckInFlight = false;
3148
+ this.tmuxLifecycleObservedAlive = false;
3149
+ this.tmuxLifecycleInitDeadlineMs = Number.POSITIVE_INFINITY;
3120
3150
  if (!options.bearerToken || options.bearerToken.length < 16) {
3121
3151
  throw new Error("McpServer requires a non-trivial bearerToken");
3122
3152
  }
@@ -3156,6 +3186,8 @@ var McpServer = class _McpServer {
3156
3186
  async start() {
3157
3187
  if (this.started) throw new Error("McpServer.start() called twice");
3158
3188
  this.stopPromise = null;
3189
+ this.stopTmuxLifecycleMonitor();
3190
+ this.tmuxLifecycleObservedAlive = false;
3159
3191
  await fireDaemonBeacon("daemon_init_start", {
3160
3192
  step: "init",
3161
3193
  outcome: "ok",
@@ -3176,6 +3208,7 @@ var McpServer = class _McpServer {
3176
3208
  this.lifecycleGen++;
3177
3209
  this.stopRequestedDuringCreate = false;
3178
3210
  this.registerSignalHandlers();
3211
+ this.startTmuxLifecycleMonitor();
3179
3212
  await (0, import_codevibe_core4.registerDeviceEncryptionKey)(this.appSyncClient, logger);
3180
3213
  if (!this.started) return { httpPort: 0 };
3181
3214
  (0, import_codevibe_core4.startDeviceKeyWatcher)(this.appSyncClient, logger);
@@ -3259,15 +3292,18 @@ var McpServer = class _McpServer {
3259
3292
  }
3260
3293
  async doStop() {
3261
3294
  this.started = false;
3262
- if (this.launchSessionPromise) {
3263
- const settled = this.launchSessionPromise.catch(() => void 0);
3295
+ this.stopTmuxLifecycleMonitor();
3296
+ const launchSettlement = this.launchSessionPromise?.catch(() => void 0) ?? null;
3297
+ let mustAwaitLaunchSettlement = false;
3298
+ let hostedRetirementError = null;
3299
+ if (launchSettlement) {
3264
3300
  let timer;
3265
3301
  const timeout = new Promise((resolve3) => {
3266
3302
  timer = setTimeout(resolve3, this.launchSettleTimeoutMs);
3267
3303
  timer.unref?.();
3268
3304
  });
3269
3305
  try {
3270
- await Promise.race([settled, timeout]);
3306
+ await Promise.race([launchSettlement, timeout]);
3271
3307
  } finally {
3272
3308
  if (timer) clearTimeout(timer);
3273
3309
  }
@@ -3279,8 +3315,9 @@ var McpServer = class _McpServer {
3279
3315
  if (deactivated) {
3280
3316
  if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
3281
3317
  } else {
3318
+ mustAwaitLaunchSettlement = launchSettlement !== null;
3282
3319
  logger.error(
3283
- "doStop: INACTIVE write for the pending launch row FAILED (row may not exist yet \u2014 create still in flight). Keeping the settlement obligation: if the create resolves in-process its tail deactivates the row; if the process exits first, an ACTIVE row may leak (SIGKILL-class residual) (#638 H4)",
3320
+ "doStop: INACTIVE write for the pending launch row FAILED (row may not exist yet \u2014 create still in flight). Keeping the settlement obligation: if the create resolves in-process its tail deactivates the row; the process remains alive so the delayed create tail can settle it (#638 H4)",
3284
3321
  { sessionId: pendingId }
3285
3322
  );
3286
3323
  }
@@ -3304,16 +3341,11 @@ var McpServer = class _McpServer {
3304
3341
  this.appSyncClient.stopHeartbeat(this.session.sessionId);
3305
3342
  } catch {
3306
3343
  }
3307
- try {
3308
- await this.appSyncClient.updateSession({
3309
- sessionId: this.session.sessionId,
3310
- status: "INACTIVE"
3311
- });
3312
- } catch (err) {
3313
- logger.warn("updateSession INACTIVE failed during shutdown", {
3314
- sessionId: this.session.sessionId,
3315
- error: String(err)
3316
- });
3344
+ const retired = await this.deactivateLaunchRow(this.session.sessionId);
3345
+ if (!retired) {
3346
+ hostedRetirementError = new Error(
3347
+ `Failed to retire hosted Antigravity session ${this.session.sessionId}`
3348
+ );
3317
3349
  }
3318
3350
  }
3319
3351
  this.unregisterSignalHandlers();
@@ -3349,10 +3381,25 @@ var McpServer = class _McpServer {
3349
3381
  this.launchKeyUnavailable = false;
3350
3382
  this.ensureLaunchInFlight = null;
3351
3383
  this.observedMainConversationIds.clear();
3384
+ if (mustAwaitLaunchSettlement && launchSettlement) {
3385
+ await launchSettlement;
3386
+ if (this.pendingLaunchSessionId) {
3387
+ const pendingId = this.pendingLaunchSessionId;
3388
+ const retired = await this.deactivateLaunchRow(pendingId);
3389
+ if (retired) {
3390
+ if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
3391
+ } else {
3392
+ hostedRetirementError = new Error(
3393
+ `Failed to settle delayed Antigravity launch session ${pendingId}`
3394
+ );
3395
+ }
3396
+ }
3397
+ }
3352
3398
  await fireDaemonBeacon("daemon_init_step", {
3353
3399
  step: "shutdown",
3354
- outcome: "ok"
3400
+ outcome: hostedRetirementError ? "fail" : "ok"
3355
3401
  });
3402
+ if (hostedRetirementError) throw hostedRetirementError;
3356
3403
  }
3357
3404
  async handleConversationDiscovered(conversationId) {
3358
3405
  if (!this.started) return;
@@ -3387,16 +3434,30 @@ var McpServer = class _McpServer {
3387
3434
  async deactivateLaunchRow(sessionId) {
3388
3435
  try {
3389
3436
  this.appSyncClient.stopHeartbeat(sessionId);
3390
- await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
3391
- logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
3392
- return true;
3393
- } catch (err) {
3394
- logger.warn("Failed to mark superseded/interrupted launch session INACTIVE", {
3437
+ } catch (error) {
3438
+ logger.warn("Failed to stop heartbeat while retiring Antigravity session", {
3395
3439
  sessionId,
3396
- error: String(err)
3440
+ error: error instanceof Error ? error.message : String(error)
3397
3441
  });
3398
- return false;
3399
3442
  }
3443
+ for (let attempt = 1; attempt <= SESSION_RETIRE_MAX_ATTEMPTS; attempt += 1) {
3444
+ try {
3445
+ await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
3446
+ logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
3447
+ return true;
3448
+ } catch (err) {
3449
+ logger.warn("Failed to mark superseded/interrupted launch session INACTIVE", {
3450
+ sessionId,
3451
+ attempt,
3452
+ maxAttempts: SESSION_RETIRE_MAX_ATTEMPTS,
3453
+ error: String(err)
3454
+ });
3455
+ if (attempt < SESSION_RETIRE_MAX_ATTEMPTS) {
3456
+ await new Promise((resolve3) => setTimeout(resolve3, SESSION_RETIRE_RETRY_MS * attempt));
3457
+ }
3458
+ }
3459
+ }
3460
+ return false;
3400
3461
  }
3401
3462
  /**
3402
3463
  * v9 launch-session: create the wrapper-lifetime backend session at
@@ -4575,27 +4636,117 @@ var McpServer = class _McpServer {
4575
4636
  return this.session.sessionId === backendSessionId ? this.session : null;
4576
4637
  }
4577
4638
  // ─── Signal handling ────────────────────────────────────────────────────
4639
+ /** Preserve a supported detach, but retire the daemon when its observed tmux owner ends. */
4640
+ startTmuxLifecycleMonitor() {
4641
+ if (this.tmuxLifecycleTimer || !this.tmuxTarget) return;
4642
+ const tmuxTarget = this.tmuxTarget;
4643
+ this.tmuxLifecycleTimer = setInterval(() => {
4644
+ void this.checkTmuxLifecycle(tmuxTarget);
4645
+ }, TMUX_LIFECYCLE_POLL_MS);
4646
+ this.tmuxLifecycleInitDeadlineMs = Date.now() + TMUX_LIFECYCLE_INIT_GRACE_MS;
4647
+ this.tmuxLifecycleTimer.unref?.();
4648
+ void this.checkTmuxLifecycle(tmuxTarget);
4649
+ }
4650
+ async tmuxSessionState(tmuxTarget) {
4651
+ try {
4652
+ await execFileAsync("tmux", ["has-session", "-t", tmuxTarget]);
4653
+ return "alive";
4654
+ } catch (error) {
4655
+ const state = classifyTmuxHasSessionError(error);
4656
+ if (state === "absent") return state;
4657
+ logger.warn("Could not inspect native Antigravity tmux session; preserving daemon", {
4658
+ tmuxTarget,
4659
+ error: error instanceof Error ? error.message : String(error)
4660
+ });
4661
+ return "unknown";
4662
+ }
4663
+ }
4664
+ async checkTmuxLifecycle(tmuxTarget) {
4665
+ if (!this.started || this.tmuxLifecycleCheckInFlight) return;
4666
+ this.tmuxLifecycleCheckInFlight = true;
4667
+ try {
4668
+ const state = await this.tmuxSessionState(tmuxTarget);
4669
+ if (state === "alive") {
4670
+ this.tmuxLifecycleObservedAlive = true;
4671
+ return;
4672
+ }
4673
+ const ownershipEstablished = this.tmuxLifecycleObservedAlive || Date.now() >= this.tmuxLifecycleInitDeadlineMs;
4674
+ if (state === "unknown" || !ownershipEstablished || !this.started) return;
4675
+ this.stopTmuxLifecycleMonitor();
4676
+ logger.info("Native Antigravity tmux session ended; stopping companion daemon", {
4677
+ tmuxTarget
4678
+ });
4679
+ void this.stop().then(
4680
+ () => process.exit(0),
4681
+ (error) => {
4682
+ logger.error("Failed to stop companion daemon after native session end", {
4683
+ error: error instanceof Error ? error.message : String(error)
4684
+ });
4685
+ process.exit(1);
4686
+ }
4687
+ );
4688
+ } finally {
4689
+ this.tmuxLifecycleCheckInFlight = false;
4690
+ }
4691
+ }
4692
+ stopTmuxLifecycleMonitor() {
4693
+ if (this.tmuxLifecycleTimer) {
4694
+ clearInterval(this.tmuxLifecycleTimer);
4695
+ this.tmuxLifecycleTimer = null;
4696
+ }
4697
+ }
4578
4698
  registerSignalHandlers() {
4579
4699
  if (this.signalsRegistered) return;
4580
4700
  this.signalsRegistered = true;
4581
4701
  const onSignal = (sig) => {
4582
- logger.info(`${sig} received \u2014 shutting down`);
4583
- void this.stop().then(() => process.exit(0)).catch((err) => {
4702
+ logger.info(`${sig} received \u2014 evaluating terminal shutdown`);
4703
+ void this.stopForTerminalSignal(sig).then((stopped) => {
4704
+ if (stopped) process.exit(0);
4705
+ }).catch((err) => {
4584
4706
  logger.error("shutdown error", { error: String(err) });
4585
4707
  process.exit(1);
4586
4708
  });
4587
4709
  };
4588
4710
  this.boundSigintHandler = () => onSignal("SIGINT");
4589
4711
  this.boundSigtermHandler = () => onSignal("SIGTERM");
4590
- process.on("SIGINT", this.boundSigintHandler);
4591
- process.on("SIGTERM", this.boundSigtermHandler);
4712
+ this.boundSighupHandler = () => onSignal("SIGHUP");
4713
+ const handlers = {
4714
+ SIGINT: this.boundSigintHandler,
4715
+ SIGTERM: this.boundSigtermHandler,
4716
+ SIGHUP: this.boundSighupHandler
4717
+ };
4718
+ for (const signal of TERMINAL_SHUTDOWN_SIGNALS) {
4719
+ process.on(signal, handlers[signal]);
4720
+ }
4721
+ }
4722
+ /**
4723
+ * A tmux client detach can hang up the wrapper PTY while Antigravity remains
4724
+ * alive in the bound tmux session. Only treat SIGHUP as terminal when tmux
4725
+ * authoritatively reports that target absent; an unknown result preserves the
4726
+ * daemon until the lifecycle monitor retries.
4727
+ */
4728
+ async stopForTerminalSignal(signal) {
4729
+ if (signal === "SIGHUP" && this.tmuxTarget) {
4730
+ const state = await this.tmuxSessionState(this.tmuxTarget);
4731
+ if (state !== "absent") {
4732
+ logger.info("Ignoring SIGHUP while native Antigravity tmux session remains available", {
4733
+ tmuxTarget: this.tmuxTarget,
4734
+ state
4735
+ });
4736
+ return false;
4737
+ }
4738
+ }
4739
+ await this.stop();
4740
+ return true;
4592
4741
  }
4593
4742
  unregisterSignalHandlers() {
4594
4743
  if (!this.signalsRegistered) return;
4595
4744
  if (this.boundSigintHandler) process.off("SIGINT", this.boundSigintHandler);
4596
4745
  if (this.boundSigtermHandler) process.off("SIGTERM", this.boundSigtermHandler);
4746
+ if (this.boundSighupHandler) process.off("SIGHUP", this.boundSighupHandler);
4597
4747
  this.boundSigintHandler = null;
4598
4748
  this.boundSigtermHandler = null;
4749
+ this.boundSighupHandler = null;
4599
4750
  this.signalsRegistered = false;
4600
4751
  }
4601
4752
  // ─── Lookups (test surface) ────────────────────────────────────────────
@@ -4835,7 +4986,9 @@ var __testing = {
4835
4986
  0 && (module.exports = {
4836
4987
  McpServer,
4837
4988
  SessionNotFoundError,
4989
+ TERMINAL_SHUTDOWN_SIGNALS,
4838
4990
  __testing,
4991
+ classifyTmuxHasSessionError,
4839
4992
  generateLaunchSessionId,
4840
4993
  getActiveConversationFromCliLog,
4841
4994
  parseMaybeJson
@@ -201,6 +201,27 @@ log() {
201
201
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
202
202
  }
203
203
 
204
+ tmux_session_state() {
205
+ local target="$1"
206
+ local output
207
+ local rc
208
+ output="$(tmux has-session -t "$target" 2>&1)"
209
+ rc=$?
210
+ if [ "$rc" -eq 0 ]; then
211
+ printf '%s\n' "alive"
212
+ return
213
+ fi
214
+ case "$output" in
215
+ "can't find session:"*|"no server running on "*|"error connecting to "*" (No such file or directory)"|"error connecting to "*" (Connection refused)")
216
+ printf '%s\n' "absent"
217
+ ;;
218
+ *)
219
+ log "WARN: tmux inspection failed for $target; preserving daemon: $output"
220
+ printf '%s\n' "unknown"
221
+ ;;
222
+ esac
223
+ }
224
+
204
225
  # ─── Reject unsupported invocations ───────────────────────────────────
205
226
  # `--print` / `-p` mode bypasses the interactive TUI we depend on. Bail
206
227
  # out with a clear message rather than silently producing no mobile sync.
@@ -326,6 +347,22 @@ cleanup() {
326
347
  local wrapper_exit_code=$?
327
348
  log "Cleanup triggered"
328
349
 
350
+ # Closing or detaching the outer terminal must not disconnect an agy
351
+ # process that is still alive inside tmux. The daemon monitors this exact
352
+ # tmux target and owns full cleanup after the native session disappears.
353
+ if [ "${_CV_TMUX_STARTED:-false}" = "true" ]; then
354
+ local tmux_state
355
+ tmux_state="$(tmux_session_state "$SESSION_NAME")"
356
+ if [ "$tmux_state" = "alive" ]; then
357
+ log "Tmux client detached while Antigravity remains active; leaving daemon running"
358
+ return
359
+ fi
360
+ if [ "$tmux_state" = "unknown" ]; then
361
+ log "Tmux state is ambiguous; preserving daemon and native session"
362
+ return
363
+ fi
364
+ fi
365
+
329
366
  # Fire wrapper_exited telemetry BEFORE killing the server so MCP
330
367
  # logs are intact. cv_failed sets _CV_EXITED on pre-flight failures,
331
368
  # so this block won't double-fire.
@@ -358,21 +395,12 @@ cleanup() {
358
395
  cv_telem "wrapper_exited" "\"exit_code\":$wrapper_exit_code,\"lifetime_seconds\":$lifetime,\"agy_exit_code\":\"$agy_exit\",\"agy_lifetime_seconds\":$agy_lifetime,\"tmux_session_started\":$_CV_TMUX_STARTED,\"agent_invoked\":$_CV_AGENT_INVOKED,\"terminal_outcome\":\"$outcome\""
359
396
  fi
360
397
 
361
- # Stop the MCP server graceful first, then SIGKILL fallback.
362
- # The server's SIGTERM handler marks the session INACTIVE.
398
+ # Stop the MCP server gracefully and wait for product-owned cleanup. Never
399
+ # force-kill here: stop() must finish its hosted INACTIVE obligation before
400
+ # the process exits, even when that takes longer than the old 3s bound.
363
401
  if [ -n "$MCP_PID" ] && kill -0 "$MCP_PID" 2>/dev/null; then
364
402
  log "Stopping MCP server (PID: $MCP_PID)"
365
403
  kill -TERM "$MCP_PID" 2>/dev/null || true
366
- # Wait up to 3s for graceful shutdown.
367
- local i=0
368
- while [ $i -lt 30 ] && kill -0 "$MCP_PID" 2>/dev/null; do
369
- sleep 0.1
370
- i=$((i + 1))
371
- done
372
- if kill -0 "$MCP_PID" 2>/dev/null; then
373
- log "MCP server did not exit cleanly; sending SIGKILL"
374
- kill -KILL "$MCP_PID" 2>/dev/null || true
375
- fi
376
404
  wait "$MCP_PID" 2>/dev/null || true
377
405
  fi
378
406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "description": "Control Antigravity CLI from your iPhone and Android — real-time sync, approve file edits, send prompts by voice. Part of CodeVibe.",
5
5
  "main": "dist/server.js",
6
6
  "codevibe": {