@quantiya/codevibe-antigravity-plugin 2.0.6 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -1,7 +1,7 @@
1
- # Gemini Companion Plugin Configuration
1
+ # CodeVibe Antigravity Companion Plugin Configuration
2
2
  # ======================================
3
3
  # NOTE: You typically don't need to edit this file manually.
4
- # Configuration is auto-set after running 'codevibe-gemini login'.
4
+ # Configuration is auto-set after running 'codevibe login'.
5
5
  #
6
6
  # Copy this file to .env.production or .env.development and fill in values.
7
7
 
@@ -19,10 +19,11 @@ COGNITO_USER_POOL_ID=us-east-1_xxxxxxxxx
19
19
  COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
20
20
  COGNITO_DOMAIN=your-domain.auth.us-east-1.amazoncognito.com
21
21
 
22
- # Gemini Configuration
23
- GEMINI_COMMAND=gemini
24
- GEMINI_TIMEOUT=60000
22
+ # Antigravity CLI Configuration
23
+ # Optional absolute path to an executable agy binary. Invalid values fall back
24
+ # to an executable agy found on PATH.
25
+ # AGY_PATH=/absolute/path/to/agy
25
26
 
26
27
  # Logging Configuration
27
- LOG_FILE=/tmp/codevibe-gemini-mcp.log
28
+ LOG_FILE=/tmp/codevibe-agy-mcp.log
28
29
  LOG_LEVEL=info
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Control Google Antigravity CLI from your iPhone and Android.** See your agent's work, approve file edits, dictate prompts by voice — all from your phone, anywhere you are.
4
4
 
5
- 🌐 **[quantiya.ai/codevibe](https://quantiya.ai/codevibe)** — landing page, demo video, and one-liner installer
5
+ 🌐 **[quantiya.ai/codevibe](https://quantiya.ai/codevibe)** — landing page, demo video, and stable 1.x installer
6
6
 
7
7
  📱 **[Download on the App Store](https://apps.apple.com/app/id6756500217)** · **[Get it on Google Play](https://play.google.com/store/apps/details?id=ai.quantiya.app.codevibe)**
8
8
 
@@ -19,38 +19,35 @@
19
19
  - **🔔 Push notifications** — get notified when your agent needs input
20
20
  - **🔒 End-to-end encrypted** — AES-256-GCM with ECDH key exchange
21
21
  - **🔓 Locked screen support** — works even when your computer screen is locked (via tmux)
22
- - **🧠 Multi-agent ready** — Antigravity sessions appear alongside Claude, Gemini, and Codex sessions in the same app
22
+ - **🧠 Multi-agent ready** — Antigravity sessions appear alongside Claude and Codex sessions in the same app
23
23
 
24
- ## Install in 30 seconds
24
+ ## Install the 2.x candidate
25
25
 
26
26
  ```bash
27
- curl -fsSL https://quantiya.ai/codevibe/install.sh | bash
28
- ```
29
-
30
- Download the **[iOS app](https://apps.apple.com/app/id6756500217)** or **[Android app](https://play.google.com/store/apps/details?id=ai.quantiya.app.codevibe)**, sign in with the same Apple or Google account, and run:
31
-
32
- ```bash
33
- codevibe-agy
27
+ npm install -g @quantiya/codevibe@next
28
+ codevibe login
29
+ codevibe --agent agy
34
30
  ```
35
31
 
36
- Your session appears on your phone automatically.
32
+ Install Node.js 22+, tmux, and Antigravity CLI first. Then download the **[iOS app](https://apps.apple.com/app/id6756500217)** or **[Android app](https://play.google.com/store/apps/details?id=ai.quantiya.app.codevibe)** and sign in with the same Apple or Google account. Your session appears on your phone automatically.
37
33
 
38
- ### Manual install
34
+ ### Stable 1.x installer
39
35
 
40
36
  ```bash
41
- npm install -g @quantiya/codevibe
42
- codevibe login
43
- codevibe-agy
37
+ curl -fsSL https://quantiya.ai/codevibe/install.sh | bash
44
38
  ```
45
39
 
40
+ The hosted installer remains on stable 1.x while 2.x is validated. It does not
41
+ install the unified `codevibe --agent` interface yet.
42
+
46
43
  ## Requirements
47
44
 
48
45
  - **macOS, Linux, or WSL Ubuntu** — Windows without WSL is not supported
49
- - **Node.js** 18.0.0+
46
+ - **Node.js** 22.0.0+
50
47
  - **tmux** — `brew install tmux` on macOS, `apt install tmux` on Linux/WSL
51
48
  - **Antigravity CLI (`agy`)** — install from [Google's documentation](https://antigravity.google/docs)
52
49
 
53
- > Antigravity CLI is distributed by Google as a binary; it is not on npm. After installing `agy`, run `codevibe-agy` and the plugin handles the rest.
50
+ > Antigravity CLI is distributed by Google as a binary; it is not on npm. After installing `agy`, run `codevibe --agent agy` and the plugin handles the rest.
54
51
 
55
52
  ## How it works
56
53
 
@@ -70,10 +67,10 @@ A single launch session covers the entire wrapper lifetime, so `/resume` command
70
67
  ## CLI commands
71
68
 
72
69
  ```bash
73
- codevibe-agy # Start agy with mobile sync
74
- codevibe-agy login # Authenticate via browser OAuth
75
- codevibe-agy status # Check auth status
76
- codevibe-agy logout # Sign out
70
+ codevibe --agent agy # Start agy with mobile sync
71
+ codevibe login # Authenticate via browser OAuth
72
+ codevibe status # Check auth status
73
+ codevibe logout # Sign out
77
74
  ```
78
75
 
79
76
  ## Troubleshooting
@@ -104,7 +101,6 @@ codevibe update
104
101
  - **[@quantiya/codevibe](https://www.npmjs.com/package/@quantiya/codevibe)** — meta-package (install this, not this plugin directly)
105
102
  - **[@quantiya/codevibe-core](https://www.npmjs.com/package/@quantiya/codevibe-core)** — shared library used by all plugins
106
103
  - **[@quantiya/codevibe-claude-plugin](https://www.npmjs.com/package/@quantiya/codevibe-claude-plugin)** — Claude Code support
107
- - **[@quantiya/codevibe-gemini-plugin](https://www.npmjs.com/package/@quantiya/codevibe-gemini-plugin)** — Gemini CLI support
108
104
  - **[@quantiya/codevibe-codex-plugin](https://www.npmjs.com/package/@quantiya/codevibe-codex-plugin)** — Codex CLI support
109
105
 
110
106
  ## License
package/dist/server.js CHANGED
@@ -378,7 +378,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
378
378
  this.watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
379
379
  this.watcher.on("error", (error) => {
380
380
  logger.error("Chokidar error", { error });
381
- this.emit("error", error instanceof Error ? error : new Error(String(error)));
381
+ this.emitError(error);
382
382
  });
383
383
  this.watcher.on("ready", () => {
384
384
  logger.info("Transcript tailer ready (chokidar initial scan complete)");
@@ -388,6 +388,26 @@ var TranscriptTailer = class extends import_events.EventEmitter {
388
388
  if (this.flushTimer.unref) this.flushTimer.unref();
389
389
  this.isWatching = true;
390
390
  }
391
+ /**
392
+ * (#638 G4) SAFE 'error' emission. `EventEmitter.emit('error', …)` with NO
393
+ * registered listener THROWS synchronously — inside a chokidar callback that
394
+ * becomes an uncaughtException, and inside the async read chain it becomes a
395
+ * rejection; either way the F6 symmetric process guard can classify it fatal
396
+ * and kill the daemon over a non-fatal tailing fault (over-death). The server
397
+ * attaches an 'error' listener before start() (primary path); this guard
398
+ * covers the residual windows — the doStop teardown detaches listeners
399
+ * BEFORE the tailer stops, and any embedder that never attached one.
400
+ */
401
+ emitError(error) {
402
+ const err = error instanceof Error ? error : new Error(String(error));
403
+ if (this.listenerCount("error") > 0) {
404
+ this.emit("error", err);
405
+ } else {
406
+ logger.error("Transcript tailer error (no listener attached \u2014 logged, non-fatal)", {
407
+ error: String(err)
408
+ });
409
+ }
410
+ }
391
411
  /**
392
412
  * Stop watching. Flushes offsets one last time before closing chokidar.
393
413
  * Idempotent.
@@ -594,7 +614,13 @@ var TranscriptTailer = class extends import_events.EventEmitter {
594
614
  return inflight;
595
615
  }
596
616
  const generation = this.readGeneration;
597
- const read = this.drainReadQueue(filePath, conversationId, generation);
617
+ const read = this.drainReadQueue(filePath, conversationId, generation).catch((error) => {
618
+ logger.error("Transcript read chain failed (non-fatal) \u2014 next change event retries", {
619
+ filePath,
620
+ conversationId,
621
+ error: String(error)
622
+ });
623
+ });
598
624
  this.activeReads.set(filePath, read);
599
625
  void read.finally(() => {
600
626
  if (this.activeReads.get(filePath) === read) {
@@ -651,7 +677,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
651
677
  });
652
678
  } catch (error) {
653
679
  logger.error("createReadStream failed", { filePath, error });
654
- this.emit("error", error instanceof Error ? error : new Error(String(error)));
680
+ this.emitError(error);
655
681
  return;
656
682
  }
657
683
  let buffered = entry.carryover;
@@ -684,7 +710,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
684
710
  }
685
711
  } catch (error) {
686
712
  logger.error("Error reading transcript stream", { filePath, error });
687
- this.emit("error", error instanceof Error ? error : new Error(String(error)));
713
+ this.emitError(error);
688
714
  return;
689
715
  }
690
716
  if (generation !== this.readGeneration) return;
@@ -2813,6 +2839,16 @@ var McpServer = class {
2813
2839
  * collapses v8's per-conv disabledConversations Set because there's
2814
2840
  * only ONE backend session per wrapper. */
2815
2841
  this.sessionDisabled = false;
2842
+ /** (#638 G2) Terminal fail-closed-by-absence marker: createLaunchSession hit
2843
+ * ENCRYPTED_SESSION_NO_KEY for this identity. Retrying cannot derive a key
2844
+ * this device does not have, so ensureLaunchSession must NOT re-bootstrap —
2845
+ * the no-session/no-plaintext outcome is the correct terminal state. Logged
2846
+ * once at the catch; reset on doStop() alongside sessionDisabled. */
2847
+ this.launchKeyUnavailable = false;
2848
+ /** (#638 G2) Single-flight guard for ensureLaunchSession(): concurrent
2849
+ * activity-triggered re-bootstraps share ONE createLaunchSession attempt
2850
+ * instead of double-creating backend rows. Null when no attempt in flight. */
2851
+ this.ensureLaunchInFlight = null;
2816
2852
  /** Registry of agy conv UUIDs the wrapper has observed this lifecycle.
2817
2853
  * Populated from handleConversationDiscovered + handleTranscriptEmit
2818
2854
  * (after isMainConversation passes). Used by:
@@ -2850,6 +2886,15 @@ var McpServer = class {
2850
2886
  * out mid-cleanup — separating the bounded "wait for create" from the
2851
2887
  * always-awaited "mark INACTIVE". (#142 Stage 2 R1 iter-2 HIGH.) */
2852
2888
  this.pendingLaunchSessionId = null;
2889
+ /** (#638 H4) Set by doStop() when a stop runs while a createLaunchSession may
2890
+ * still be in flight (the bounded Phase-0 settle can expire mid-create). The
2891
+ * create's post-await tail includes this in its bail check, so a create that
2892
+ * resolves AFTER doStop's settle bound still deactivates its just-created row
2893
+ * itself — the settlement obligation survives until the create CONCLUSIVELY
2894
+ * finishes, instead of doStop "completing" it with a missing-row INACTIVE
2895
+ * write the backend rejected. Reset on the next start() (a stale prior-
2896
+ * lifecycle create is additionally caught by the gen mismatch). */
2897
+ this.stopRequestedDuringCreate = false;
2853
2898
  /** Listener references kept so doStop() can detach them. Without this,
2854
2899
  * a server restart (test mode or future hot-reload) accumulates
2855
2900
  * listeners on the shared module instances. (Stage 2 HIGH finding
@@ -2907,11 +2952,12 @@ var McpServer = class {
2907
2952
  error_message: "no stored OAuth tokens"
2908
2953
  });
2909
2954
  throw new Error(
2910
- "codevibe-antigravity-plugin: no OAuth tokens \u2014 run `codevibe-agy login` first"
2955
+ "codevibe-antigravity-plugin: no OAuth tokens \u2014 run `codevibe login` first"
2911
2956
  );
2912
2957
  }
2913
2958
  this.started = true;
2914
2959
  this.lifecycleGen++;
2960
+ this.stopRequestedDuringCreate = false;
2915
2961
  this.registerSignalHandlers();
2916
2962
  await (0, import_codevibe_core4.registerDeviceEncryptionKey)(this.appSyncClient, logger);
2917
2963
  if (!this.started) return { httpPort: 0 };
@@ -2946,6 +2992,9 @@ var McpServer = class {
2946
2992
  if (!this.started) {
2947
2993
  return { httpPort: 0 };
2948
2994
  }
2995
+ this.addListener(this.transcriptTailer, "error", (err) => {
2996
+ logger.error("transcript tailer error (non-fatal)", { error: String(err) });
2997
+ });
2949
2998
  this.addListener(this.transcriptTailer, "event", (emit) => {
2950
2999
  void this.handleTranscriptEmit(emit).catch((err) => {
2951
3000
  logger.error("handleTranscriptEmit failed", { error: String(err) });
@@ -3008,8 +3057,16 @@ var McpServer = class {
3008
3057
  }
3009
3058
  if (this.pendingLaunchSessionId) {
3010
3059
  const pendingId = this.pendingLaunchSessionId;
3011
- this.pendingLaunchSessionId = null;
3012
- await this.deactivateLaunchRow(pendingId);
3060
+ this.stopRequestedDuringCreate = true;
3061
+ const deactivated = await this.deactivateLaunchRow(pendingId);
3062
+ if (deactivated) {
3063
+ if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
3064
+ } else {
3065
+ logger.error(
3066
+ "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)",
3067
+ { sessionId: pendingId }
3068
+ );
3069
+ }
3013
3070
  }
3014
3071
  if (this.subscription) {
3015
3072
  try {
@@ -3070,6 +3127,8 @@ var McpServer = class {
3070
3127
  }
3071
3128
  this.session = null;
3072
3129
  this.sessionDisabled = false;
3130
+ this.launchKeyUnavailable = false;
3131
+ this.ensureLaunchInFlight = null;
3073
3132
  this.observedMainConversationIds.clear();
3074
3133
  await fireDaemonBeacon("daemon_init_step", {
3075
3134
  step: "shutdown",
@@ -3079,6 +3138,7 @@ var McpServer = class {
3079
3138
  async handleConversationDiscovered(conversationId) {
3080
3139
  if (!this.started) return;
3081
3140
  if (this.sessionDisabled) return;
3141
+ await this.ensureLaunchSession();
3082
3142
  if (!this.session) return;
3083
3143
  if (!this.isMainConversation(conversationId)) {
3084
3144
  logger.debug("Ignoring discovery for non-main conversation (subagent)", { conversationId });
@@ -3098,17 +3158,25 @@ var McpServer = class {
3098
3158
  * can't clobber or resurrect ACTIVE. (Separate from the ACTIVE-vs-INACTIVE
3099
3159
  * per-session ordering that core 1.0.29's status-write chain handles.)
3100
3160
  * (#142 Stage 2 R1 iter-2.)
3161
+ *
3162
+ * (#638 H4) Returns whether the INACTIVE write actually SUCCEEDED. A swallowed
3163
+ * failure here is NOT a settled obligation — the row may not exist yet (a
3164
+ * create still in flight) or the network may be down — so callers must keep
3165
+ * their settlement marker/obligation alive on `false` instead of treating the
3166
+ * attempt as completion. The log stays inside so no failure is ever silent.
3101
3167
  */
3102
3168
  async deactivateLaunchRow(sessionId) {
3103
3169
  try {
3104
3170
  this.appSyncClient.stopHeartbeat(sessionId);
3105
3171
  await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
3106
3172
  logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
3173
+ return true;
3107
3174
  } catch (err) {
3108
3175
  logger.warn("Failed to mark superseded/interrupted launch session INACTIVE", {
3109
3176
  sessionId,
3110
3177
  error: String(err)
3111
3178
  });
3179
+ return false;
3112
3180
  }
3113
3181
  }
3114
3182
  /**
@@ -3143,7 +3211,7 @@ var McpServer = class {
3143
3211
  );
3144
3212
  sessionKey = result.sessionKey ?? null;
3145
3213
  } catch (err) {
3146
- this.pendingLaunchSessionId = null;
3214
+ if (this.pendingLaunchSessionId === sessionId) this.pendingLaunchSessionId = null;
3147
3215
  const msg = String(err);
3148
3216
  if (msg.includes("session-limit-exceeded")) {
3149
3217
  logger.warn("Free-tier session limit reached \u2014 mobile sync disabled for this wrapper", { sessionId });
@@ -3151,12 +3219,25 @@ var McpServer = class {
3151
3219
  this.sessionDisabled = true;
3152
3220
  return;
3153
3221
  }
3154
- logger.error("createLaunchSession failed (non-fatal)", { sessionId, error: msg });
3222
+ if (err?.code === "ENCRYPTED_SESSION_NO_KEY") {
3223
+ logger.error("createLaunchSession: ENCRYPTED_SESSION_NO_KEY \u2014 no key for the encrypted session; no session created, mobile sync fails closed (no plaintext) (#638)", { sessionId });
3224
+ if (gen !== this.lifecycleGen || !this.started) return;
3225
+ this.launchKeyUnavailable = true;
3226
+ return;
3227
+ }
3228
+ logger.error("createLaunchSession failed (non-fatal; next activity re-attempts via ensureLaunchSession \u2014 #638 G2)", { sessionId, error: msg });
3155
3229
  return;
3156
3230
  }
3157
- if (gen !== this.lifecycleGen || !this.started) {
3158
- await this.deactivateLaunchRow(sessionId);
3159
- if (this.pendingLaunchSessionId === sessionId) this.pendingLaunchSessionId = null;
3231
+ if (gen !== this.lifecycleGen || !this.started || this.stopRequestedDuringCreate) {
3232
+ const deactivated = await this.deactivateLaunchRow(sessionId);
3233
+ if (deactivated) {
3234
+ if (this.pendingLaunchSessionId === sessionId) this.pendingLaunchSessionId = null;
3235
+ } else {
3236
+ logger.error(
3237
+ "createLaunchSession bail: INACTIVE write for the just-created row FAILED \u2014 settlement obligation remains open (marker kept); the row may leak ACTIVE if nothing retries before exit (#638 H4)",
3238
+ { sessionId }
3239
+ );
3240
+ }
3160
3241
  return;
3161
3242
  }
3162
3243
  this.session = {
@@ -3169,7 +3250,14 @@ var McpServer = class {
3169
3250
  createdAt: /* @__PURE__ */ new Date(),
3170
3251
  subscriptionActive: false,
3171
3252
  metadata: { wrapperPid: this.wrapperPid ?? void 0, launch: true },
3172
- sessionKey
3253
+ sessionKey,
3254
+ // (#638) A null key here means a genuinely unencrypted/legacy session
3255
+ // (resumeOrCreateSession THROWS ENCRYPTED_SESSION_NO_KEY for an encrypted
3256
+ // session this device can't key, handled in the catch above — no session
3257
+ // is created there). So `!!sessionKey` correctly classifies this row; if a
3258
+ // later path ever nulls the key on an encrypted session, encryptOutbound
3259
+ // fails closed instead of leaking plaintext.
3260
+ sessionIsEncrypted: !!sessionKey
3173
3261
  };
3174
3262
  this.pendingLaunchSessionId = null;
3175
3263
  try {
@@ -3206,6 +3294,43 @@ var McpServer = class {
3206
3294
  });
3207
3295
  logger.info("Launch session created", { sessionId, userId });
3208
3296
  }
3297
+ /**
3298
+ * (#638 G2) Single-flight re-bootstrap of the launch session on first
3299
+ * subsequent activity. createLaunchSession() runs once from start(); before
3300
+ * this fix a transient failure there (slow/absent network at daemon start —
3301
+ * the incident) left `this.session = null` forever: every emit site guards
3302
+ * `if (!this.session) return` with no re-bootstrap trigger, so the daemon
3303
+ * lived but never synced (permanently headless).
3304
+ *
3305
+ * Called from the emit-relevant entry points (transcript event handling /
3306
+ * prompt handling / mobile-event handling) BEFORE their `!this.session`
3307
+ * guards. Semantics preserved:
3308
+ * - session present → returns immediately (fast path, no work);
3309
+ * - session-limit-exceeded → sessionDisabled stays TERMINAL (no retry);
3310
+ * - ENCRYPTED_SESSION_NO_KEY → launchKeyUnavailable stays TERMINAL for this
3311
+ * identity (fail-closed by absence, logged once);
3312
+ * - ONLY generic/transient errors are retried, single-flight per activity
3313
+ * arrival (inherently rate-bounded — one attempt per incoming event, and
3314
+ * concurrent arrivals share one attempt);
3315
+ * - lifecycleGen/started guards are honored by createLaunchSession itself
3316
+ * (gen captured at entry, rechecked after every await).
3317
+ */
3318
+ async ensureLaunchSession() {
3319
+ if (this.session) return;
3320
+ if (!this.started || this.sessionDisabled || this.launchKeyUnavailable) return;
3321
+ if (this.ensureLaunchInFlight) {
3322
+ await this.ensureLaunchInFlight;
3323
+ return;
3324
+ }
3325
+ const attempt = this.createLaunchSession();
3326
+ this.ensureLaunchInFlight = attempt;
3327
+ this.launchSessionPromise = attempt;
3328
+ try {
3329
+ await attempt;
3330
+ } finally {
3331
+ if (this.ensureLaunchInFlight === attempt) this.ensureLaunchInFlight = null;
3332
+ }
3333
+ }
3209
3334
  isMainConversation(conversationId) {
3210
3335
  if (process.env.JEST_WORKER_ID && !this.cliLogPath) {
3211
3336
  return true;
@@ -3224,6 +3349,7 @@ var McpServer = class {
3224
3349
  if (this.sessionDisabled) {
3225
3350
  return;
3226
3351
  }
3352
+ await this.ensureLaunchSession();
3227
3353
  if (!this.session) {
3228
3354
  return;
3229
3355
  }
@@ -3271,7 +3397,9 @@ var McpServer = class {
3271
3397
  }
3272
3398
  }
3273
3399
  try {
3274
- await this.appSyncClient.createEvent(this.encryptOutbound(session, input));
3400
+ const outbound = this.encryptOutbound(session, input);
3401
+ if (!outbound) continue;
3402
+ await this.appSyncClient.createEvent(outbound);
3275
3403
  } catch (err) {
3276
3404
  logger.error("createEvent failed", {
3277
3405
  sessionId: session.sessionId,
@@ -3282,37 +3410,39 @@ var McpServer = class {
3282
3410
  }
3283
3411
  }
3284
3412
  /**
3285
- * If the session has an E2E sessionKey, encrypt the event's content +
3286
- * metadata and stamp isEncrypted=true. Returns a new CreateEventInput;
3287
- * never mutates the input. When no key is set, returns input unchanged.
3288
- * (Codex Stage 2 HIGH finding 2026-05-20.)
3413
+ * (#638) THE single encrypt-or-fail-closed decision for EVERY mobile-visible
3414
+ * emit, delegated to @quantiya/codevibe-core's encryptForEmit so all three
3415
+ * companion agents share ONE chokepoint. Returns a new CreateEventInput
3416
+ * (never mutates the input), or NULL when the session IS encrypted but has no
3417
+ * derivable key — the caller MUST DROP the event (fail-closed). Previously
3418
+ * this method returned `input` unchanged for ANY keyless session, leaking
3419
+ * cleartext for an encrypted-but-keyless session; the `sessionIsEncrypted`
3420
+ * flag now distinguishes a genuinely unencrypted session (plaintext OK) from
3421
+ * an encrypted keyless one (drop).
3289
3422
  */
3290
3423
  encryptOutbound(session, input) {
3291
- if (!session.sessionKey) return input;
3292
- try {
3293
- const encryptedContent = import_codevibe_core4.cryptoService.encryptContent(input.content, session.sessionKey);
3294
- let encryptedMetadata = input.metadata;
3295
- if (input.metadata) {
3296
- const encryptedMetaStr = import_codevibe_core4.cryptoService.encryptMetadata(
3297
- input.metadata,
3298
- session.sessionKey
3299
- );
3300
- encryptedMetadata = { encrypted: encryptedMetaStr };
3301
- }
3302
- return {
3303
- ...input,
3304
- content: encryptedContent,
3305
- metadata: encryptedMetadata,
3306
- isEncrypted: true
3307
- };
3308
- } catch (err) {
3309
- logger.error("Outbound encryption failed \u2014 DROPPING event to avoid plaintext leak", {
3424
+ const enc = (0, import_codevibe_core4.encryptForEmit)(
3425
+ session.sessionKey ?? null,
3426
+ session.sessionIsEncrypted ?? false,
3427
+ input.content,
3428
+ input.metadata
3429
+ );
3430
+ if (enc === null) {
3431
+ logger.error("No session key for ENCRYPTED session \u2014 DROPPING event to avoid plaintext leak (#638)", {
3310
3432
  sessionId: session.sessionId,
3311
- type: input.type,
3312
- error: String(err)
3433
+ type: input.type
3313
3434
  });
3314
- throw err;
3435
+ return null;
3436
+ }
3437
+ if (!enc.isEncrypted) {
3438
+ return input;
3315
3439
  }
3440
+ return {
3441
+ ...input,
3442
+ content: enc.content,
3443
+ metadata: enc.metadata,
3444
+ isEncrypted: true
3445
+ };
3316
3446
  }
3317
3447
  /**
3318
3448
  * If the inbound mobile event is marked isEncrypted, decrypt content +
@@ -3357,6 +3487,7 @@ var McpServer = class {
3357
3487
  async handlePromptCandidate(cand) {
3358
3488
  if (!this.started) return;
3359
3489
  if (this.sessionDisabled) return;
3490
+ await this.ensureLaunchSession();
3360
3491
  if (!this.session) return;
3361
3492
  const parsed = parseApprovalSnapshot(cand.snapshot);
3362
3493
  if (!parsed) return;
@@ -3446,7 +3577,9 @@ var McpServer = class {
3446
3577
  })
3447
3578
  };
3448
3579
  try {
3449
- await this.appSyncClient.createEvent(this.encryptOutbound(session, input));
3580
+ const outbound = this.encryptOutbound(session, input);
3581
+ if (!outbound) return;
3582
+ await this.appSyncClient.createEvent(outbound);
3450
3583
  } catch (err) {
3451
3584
  logger.error("createEvent INTERACTIVE_PROMPT failed", {
3452
3585
  promptId: state.promptId,
@@ -3542,6 +3675,7 @@ var McpServer = class {
3542
3675
  async handleMobileEvent(evt) {
3543
3676
  if (!this.started) return;
3544
3677
  if (this.sessionDisabled) return;
3678
+ await this.ensureLaunchSession();
3545
3679
  if (!this.session) return;
3546
3680
  const session = this.session;
3547
3681
  if (evt.source !== import_codevibe_core4.EventSource.MOBILE) return;
@@ -3705,6 +3839,11 @@ var McpServer = class {
3705
3839
  timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: payload.sessionId })
3706
3840
  };
3707
3841
  const finalInput = this.encryptOutbound(session, input);
3842
+ if (!finalInput) {
3843
+ throw new Error(
3844
+ `fail-closed: encrypted session ${payload.sessionId} has no session key \u2014 event dropped (#638)`
3845
+ );
3846
+ }
3708
3847
  const created = await this.appSyncClient.createEvent(finalInput);
3709
3848
  return { eventId: created.eventId };
3710
3849
  }
@@ -3846,6 +3985,11 @@ async function main() {
3846
3985
  tmuxTarget: process.env.CODEVIBE_AGY_TMUX_TARGET ?? void 0,
3847
3986
  wrapperPid: process.env.CODEVIBE_AGY_WRAPPER_PID ? parseInt(process.env.CODEVIBE_AGY_WRAPPER_PID, 10) : void 0
3848
3987
  });
3988
+ (0, import_codevibe_core4.installDaemonProcessGuards)(logger, {
3989
+ onFatal: () => {
3990
+ void server.stop().finally(() => process.exit(1));
3991
+ }
3992
+ });
3849
3993
  try {
3850
3994
  const { httpPort } = await server.start();
3851
3995
  logger.info("codevibe-antigravity-plugin ready", { httpPort });
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
  #
3
- # codevibe-agy wrapper that runs Antigravity CLI (agy) inside a tmux
3
+ # Private CodeVibe Companion launcher for Antigravity CLI (agy), using a tmux
4
4
  # session bridged to the CodeVibe MCP server for mobile sync.
5
5
  #
6
6
  # Architecture (DESIGN.md §3 + §5.2):
@@ -34,19 +34,15 @@
34
34
  # server's signal handler), kill MCP server, remove runtime dir.
35
35
  #
36
36
  # Usage:
37
- # codevibe-agy [agy-args...] # Start agy session
38
- # codevibe-agy login # Sign in via browser
39
- # codevibe-agy logout # Sign out
40
- # codevibe-agy status # Show auth status
41
- # codevibe-agy reset-device # Reset device key (re-pair)
37
+ # codevibe --agent agy [-- agy-args...]
42
38
  #
43
39
  # Environment:
44
40
  # ENVIRONMENT Set to 'production' (default) or 'development'
45
41
  # AGY_PATH Path to agy binary (default: $(command -v agy))
46
42
  #
47
- # v1.0 limitations:
43
+ # Companion limitations:
48
44
  # - Requires tmux. Print mode (`--print` / `-p`) is NOT supported.
49
- # - Requires Node.js 18 for the MCP server.
45
+ # - Requires Node.js 22 or newer for the MCP server.
50
46
  #
51
47
 
52
48
  set -e
@@ -62,14 +58,6 @@ case "${ENVIRONMENT:-}" in
62
58
  esac
63
59
  CODEVIBE_TMPDIR="${TMPDIR:-/tmp}"
64
60
 
65
- # ─── Config dir (agent-detection marker) ──────────────────────────────
66
- # Ensure ~/.codevibe-antigravity exists on EVERY invocation (including the
67
- # fast `--version` path and normal sessions). The CodeVibe 2.0 companion
68
- # agent-detection probe marks agy "healthy" only when this dir exists
69
- # (matching the claude/codex convention where the config dir signals
70
- # "set up"). Non-fatal — a mkdir failure must never break the wrapper.
71
- mkdir -p "$HOME/.codevibe-antigravity" 2>/dev/null || true
72
-
73
61
  # Resolve plugin dir via symlink-aware traversal (npm globals symlink
74
62
  # bin/ into /usr/local/bin/).
75
63
  SOURCE="${BASH_SOURCE[0]}"
@@ -84,8 +72,8 @@ PLUGIN_DIR="$(dirname "$SCRIPT_DIR")"
84
72
  # ─── PATH augmentation ───────────────────────────────────────────────
85
73
  # When the install one-liner runs in a fresh terminal, Homebrew's
86
74
  # installer writes the shellenv eval into ~/.zprofile (etc.) but the
87
- # user's current shell hasn't sourced it yet. Subsequent codevibe-*
88
- # runs in that same terminal then fail tmux/node/agy discovery because
75
+ # user's current shell hasn't sourced it yet. CodeVibe Companion launches
76
+ # in that terminal then fail tmux/node/agy discovery because
89
77
  # /opt/homebrew/bin isn't on PATH. Prepend common locations so the
90
78
  # wrapper recovers without forcing a new terminal.
91
79
  _CV_NEW_PATHS=""
@@ -145,38 +133,45 @@ cv_failed() {
145
133
  cv_telem "wrapper_failed" "\"reason\":\"$1\",\"lifetime_seconds\":$(( $(date +%s) - _CV_STARTED_AT ))"
146
134
  }
147
135
 
148
- # ─── Auth + version commands: short-circuit BEFORE any heavy boot ──────
149
- case "$1" in
150
- version|--version|-v)
151
- # Fast, guaranteed-non-fatal version probe. Must NOT boot the MCP
152
- # server, run the tmux pre-flight, or spawn anything heavy — the
153
- # CodeVibe 2.0 companion agent-detection probe runs `codevibe-agy
154
- # --version` and only requires exit 0. Print the plugin version;
155
- # also surface the underlying `agy` version when it's on PATH.
156
- _CV_VER="$(node -p "require('$PLUGIN_DIR/package.json').version" 2>/dev/null || echo unknown)"
157
- echo "codevibe-agy $_CV_VER"
158
- _CV_AGY_BIN="${AGY_PATH:-$(command -v agy 2>/dev/null || true)}"
159
- if [ -n "$_CV_AGY_BIN" ] && [ -x "$_CV_AGY_BIN" ]; then
160
- _CV_AGY_RAW="$("$_CV_AGY_BIN" --version 2>/dev/null | head -1)"
161
- [ -n "$_CV_AGY_RAW" ] && echo "agy $_CV_AGY_RAW"
162
- fi
163
- exit 0
164
- ;;
165
- login|logout|status|reset-device)
166
- CORE_CLI="$PLUGIN_DIR/node_modules/@quantiya/codevibe-core/bin/codevibe.js"
167
- # Also check hoisted location (when installed via @quantiya/codevibe meta-package).
168
- if [ ! -f "$CORE_CLI" ]; then
169
- CORE_CLI="$PLUGIN_DIR/../codevibe-core/bin/codevibe.js"
170
- fi
171
- if [ -f "$CORE_CLI" ]; then
172
- cv_telem "wrapper_started" "\"invocation\":\"auth_$1\",\"os\":\"$(uname -s | cv_sanitize)\",\"arch\":\"$(uname -m | cv_sanitize)\""
173
- exec node "$CORE_CLI" "$1"
174
- else
175
- echo "Error: codevibe-core not found. Try reinstalling: npm install -g @quantiya/codevibe"
176
- cv_failed "core_not_found"
177
- sleep 1
178
- exit 1
136
+ # AGY_PATH is an optional preferred executable, not a poison pill. If it is
137
+ # unset or invalid, fall back to the same PATH lookup used by agent discovery.
138
+ # Requiring a regular executable file keeps this contract aligned with
139
+ # codevibe-core's isExecutableFile() check.
140
+ resolve_agy_bin() {
141
+ if [ -n "${AGY_PATH:-}" ] && [ -f "$AGY_PATH" ] && [ -x "$AGY_PATH" ]; then
142
+ printf '%s\n' "$AGY_PATH"
143
+ return 0
144
+ fi
145
+ command -v agy 2>/dev/null || true
146
+ }
147
+
148
+ # ─── Internal diagnostic: short-circuit before heavy boot ─────────────
149
+ # Keep this off the public argv surface so `codevibe --agent agy -- --version`
150
+ # reaches the vendor CLI unchanged.
151
+ if [ "${CODEVIBE_COMPANION_DIAGNOSTIC:-}" = "version" ]; then
152
+ _CV_VER="$(node -p "require('$PLUGIN_DIR/package.json').version" 2>/dev/null || echo unknown)"
153
+ echo "codevibe companion agy $_CV_VER"
154
+ _CV_AGY_BIN="$(resolve_agy_bin)"
155
+ if [ -n "$_CV_AGY_BIN" ] && [ -f "$_CV_AGY_BIN" ] && [ -x "$_CV_AGY_BIN" ]; then
156
+ _CV_AGY_RAW="$("$_CV_AGY_BIN" --version 2>/dev/null | head -1)"
157
+ [ -n "$_CV_AGY_RAW" ] && echo "agy $_CV_AGY_RAW"
158
+ fi
159
+ exit 0
160
+ fi
161
+
162
+ # Metadata commands are intentionally short-lived and do not create a
163
+ # Companion session. Run them directly in the foreground so a TTY invocation
164
+ # cannot race a detached tmux session that exits before attach. Arguments and
165
+ # the vendor exit status are preserved exactly.
166
+ case "${1:-}" in
167
+ --version|-v|--help|-h)
168
+ _CV_DIRECT_AGY_BIN="$(resolve_agy_bin)"
169
+ if [ -z "$_CV_DIRECT_AGY_BIN" ] || ! [ -f "$_CV_DIRECT_AGY_BIN" ] || ! [ -x "$_CV_DIRECT_AGY_BIN" ]; then
170
+ echo "Error: Antigravity CLI (`agy`) is not installed or not executable." >&2
171
+ echo "Install instructions: https://antigravity.google/docs" >&2
172
+ exit 127
179
173
  fi
174
+ exec "$_CV_DIRECT_AGY_BIN" "$@"
180
175
  ;;
181
176
  esac
182
177
 
@@ -187,7 +182,7 @@ command -v node >/dev/null 2>&1 && _CV_NODE_VER="$(node -v 2>/dev/null | cv_sani
187
182
  _CV_TMUX_VER="missing"
188
183
  command -v tmux >/dev/null 2>&1 && _CV_TMUX_VER="$(tmux -V 2>/dev/null | cv_sanitize)"
189
184
  [ -z "$_CV_TMUX_VER" ] && _CV_TMUX_VER="unknown"
190
- _CV_AGY_BIN="${AGY_PATH:-$(command -v agy 2>/dev/null || true)}"
185
+ _CV_AGY_BIN="$(resolve_agy_bin)"
191
186
  _CV_AGY_VER="missing"
192
187
  [ -n "$_CV_AGY_BIN" ] && _CV_AGY_VER="$("$_CV_AGY_BIN" --version 2>/dev/null | cv_sanitize)"
193
188
  [ -z "$_CV_AGY_VER" ] && _CV_AGY_VER="unknown"
@@ -213,7 +208,7 @@ log() {
213
208
  for arg in "$@"; do
214
209
  case "$arg" in
215
210
  --print|-p|--prompt|--print=*|--prompt=*|-p=*)
216
- echo "Error: codevibe-agy does not support --print / -p / --prompt in v1.0."
211
+ echo "Error: the Antigravity Companion route does not support --print / -p / --prompt."
217
212
  echo "Run \"agy ...\" directly for non-interactive use; mobile sync is interactive-only."
218
213
  cv_failed "print_mode_unsupported"
219
214
  sleep 1
@@ -231,7 +226,7 @@ if ! command -v tmux &> /dev/null; then
231
226
  exit 1
232
227
  fi
233
228
 
234
- if [ -z "$_CV_AGY_BIN" ] || ! [ -x "$_CV_AGY_BIN" ]; then
229
+ if [ -z "$_CV_AGY_BIN" ] || ! [ -f "$_CV_AGY_BIN" ] || ! [ -x "$_CV_AGY_BIN" ]; then
235
230
  echo "Error: Antigravity CLI (\`agy\`) is not installed or not executable."
236
231
  echo "Install instructions: https://antigravity.google/docs"
237
232
  cv_failed "agy_missing"
@@ -241,7 +236,7 @@ fi
241
236
 
242
237
  if ! command -v node &> /dev/null; then
243
238
  echo "Error: Node.js is required but not installed."
244
- echo "Install Node.js 18+ from: https://nodejs.org"
239
+ echo "Install Node.js 22+ from: https://nodejs.org"
245
240
  cv_failed "node_missing"
246
241
  sleep 1
247
242
  exit 1
@@ -459,8 +454,8 @@ while [ \$i -lt 100 ] && [ ! -f '$escaped_conn_file' ]; do
459
454
  i=\$((i + 1))
460
455
  done
461
456
  if [ ! -f '$escaped_conn_file' ]; then
462
- echo 'codevibe-agy: MCP server not ready after 10s; running agy without mobile sync.' >&2
463
- echo 'codevibe-agy: see $escaped_mcp_log for details.' >&2
457
+ echo 'CodeVibe Antigravity Companion: MCP server not ready after 10s; running agy without mobile sync.' >&2
458
+ echo 'CodeVibe Antigravity Companion: see $escaped_mcp_log for details.' >&2
464
459
  fi
465
460
  export ENVIRONMENT='$ENVIRONMENT'
466
461
  $AGY_CMD
@@ -517,7 +512,7 @@ if ! kill -0 "$MCP_PID" 2>/dev/null; then
517
512
  tail -3 "$MCP_LOG_FILE" 2>/dev/null | grep -v '^\[' | head -3
518
513
  echo ""
519
514
  echo "Server failed to start. Check $MCP_LOG_FILE for details."
520
- echo "Common causes: not signed in (\`codevibe-agy login\`), port in use, ENV mismatch."
515
+ echo "Common causes: not signed in (\`codevibe login\`), port in use, ENV mismatch."
521
516
  # tmux session is already created — destroy it so the user doesn't
522
517
  # attach to a degraded session that will time-out waiting for conn.json.
523
518
  tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
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
- "bin": {
7
- "codevibe-agy": "./bin/codevibe-agy"
6
+ "codevibe": {
7
+ "companionLauncher": "./libexec/companion-launcher"
8
8
  },
9
9
  "files": [
10
10
  "dist/**/*.js",
11
- "bin",
11
+ "libexec",
12
12
  "antigravity-plugin.json",
13
13
  "README.md",
14
14
  ".env.example"
@@ -48,7 +48,7 @@
48
48
  "node": ">=22.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@quantiya/codevibe-core": "2.0.6",
51
+ "@quantiya/codevibe-core": "2.0.7",
52
52
  "chokidar": "^5.0.0",
53
53
  "dotenv": "^16.6.1",
54
54
  "express": "^5.1.0",