mtrx-cli 0.1.26 → 0.1.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrx-cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -1 +1 @@
1
- __version__ = "0.1.26"
1
+ __version__ = "0.1.27"
@@ -302,21 +302,89 @@ def trust_ca_system(cert_path: Path | None = None) -> bool:
302
302
  """Attempt to trust the CA certificate in the OS certificate store.
303
303
 
304
304
  Returns True on success. May require elevated privileges.
305
+ Also persists NODE_EXTRA_CA_CERTS as a user environment variable so
306
+ Node.js processes trust the CA regardless of how they are launched.
305
307
  """
306
308
  cert_path = cert_path or ca_cert_path()
307
309
  system = platform.system()
310
+ ok = False
308
311
  try:
309
312
  if system == "Darwin":
310
- return _trust_ca_macos(cert_path)
311
- if system == "Linux":
312
- return _trust_ca_linux(cert_path)
313
- if system == "Windows":
314
- return _trust_ca_windows(cert_path)
313
+ ok = _trust_ca_macos(cert_path)
314
+ elif system == "Linux":
315
+ ok = _trust_ca_linux(cert_path)
316
+ elif system == "Windows":
317
+ ok = _trust_ca_windows(cert_path)
315
318
  except Exception as exc:
316
319
  logger.warning("CA trust failed: %s", exc)
320
+ if ok:
321
+ persist_node_extra_ca_certs(cert_path)
322
+ return ok
323
+
324
+
325
+ def persist_node_extra_ca_certs(cert_path: Path | None = None) -> bool:
326
+ """Persist NODE_EXTRA_CA_CERTS as a permanent user-level environment variable.
327
+
328
+ This ensures all Node.js processes — including Cursor sub-processes not
329
+ launched via the mtrx launcher — trust the MTRX CA certificate.
330
+
331
+ - Windows: ``setx NODE_EXTRA_CA_CERTS <path>`` writes to HKCU\\Environment;
332
+ all new user processes inherit it automatically.
333
+ - macOS: ``launchctl setenv`` for the current session; ``~/.zprofile`` for
334
+ persistence across reboots.
335
+ - Linux: ``~/.profile`` export line for login shells and display managers.
336
+
337
+ Returns True if the variable was persisted successfully.
338
+ """
339
+ cert_path = cert_path or ca_cert_path()
340
+ value = str(cert_path)
341
+ system = platform.system()
342
+ try:
343
+ if system == "Windows":
344
+ result = subprocess.run(
345
+ ["setx", "NODE_EXTRA_CA_CERTS", value],
346
+ capture_output=True,
347
+ timeout=15,
348
+ )
349
+ if result.returncode == 0:
350
+ logger.info("persist_node_extra_ca_certs: set via setx")
351
+ return True
352
+ logger.warning(
353
+ "persist_node_extra_ca_certs: setx failed: %s",
354
+ result.stderr.decode("utf-8", errors="replace").strip(),
355
+ )
356
+ return False
357
+
358
+ if system == "Darwin":
359
+ subprocess.run(
360
+ ["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", value],
361
+ capture_output=True,
362
+ timeout=10,
363
+ )
364
+ zprofile = Path.home() / ".zprofile"
365
+ _append_env_export(zprofile, "NODE_EXTRA_CA_CERTS", value)
366
+ logger.info("persist_node_extra_ca_certs: set via launchctl + ~/.zprofile")
367
+ return True
368
+
369
+ if system == "Linux":
370
+ profile = Path.home() / ".profile"
371
+ _append_env_export(profile, "NODE_EXTRA_CA_CERTS", value)
372
+ logger.info("persist_node_extra_ca_certs: set via ~/.profile")
373
+ return True
374
+
375
+ except Exception as exc:
376
+ logger.warning("persist_node_extra_ca_certs failed: %s", exc)
317
377
  return False
318
378
 
319
379
 
380
+ def _append_env_export(path: Path, name: str, value: str) -> None:
381
+ """Append ``export NAME="value"`` to a shell profile file if not already present."""
382
+ line = f'\nexport {name}="{value}" # added by mtrx\n'
383
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
384
+ if name not in text:
385
+ path.write_text(text + line, encoding="utf-8")
386
+
387
+
320
388
  def _trust_ca_macos(cert_path: Path) -> bool:
321
389
  result = subprocess.run(
322
390
  [
@@ -464,6 +464,8 @@ def configure_cursor_proxy_settings(
464
464
  previous = {
465
465
  "http.proxy": settings.get("http.proxy"),
466
466
  "http.proxyStrictSSL": settings.get("http.proxyStrictSSL"),
467
+ "http.proxySupport": settings.get("http.proxySupport"),
468
+ "http.systemCertificates": settings.get("http.systemCertificates"),
467
469
  "terminal.integrated.env.osx": settings.get("terminal.integrated.env.osx"),
468
470
  "terminal.integrated.env.linux": settings.get("terminal.integrated.env.linux"),
469
471
  "terminal.integrated.env.windows": settings.get("terminal.integrated.env.windows"),
@@ -471,6 +473,8 @@ def configure_cursor_proxy_settings(
471
473
 
472
474
  settings["http.proxy"] = proxy_url
473
475
  settings["http.proxyStrictSSL"] = False
476
+ settings["http.proxySupport"] = "override" # force all extensions through proxy agent
477
+ settings["http.systemCertificates"] = True # use OS store where our CA is already trusted
474
478
 
475
479
  # Inject NODE_EXTRA_CA_CERTS into integrated terminal env so Cursor's
476
480
  # Node.js runtime trusts our CA. Cursor itself reads this from the
@@ -501,7 +505,7 @@ def restore_cursor_proxy_settings(previous: dict) -> bool:
501
505
  """Restore Cursor's settings.json to pre-proxy values."""
502
506
  settings = _read_settings_json()
503
507
 
504
- for key in ("http.proxy", "http.proxyStrictSSL"):
508
+ for key in ("http.proxy", "http.proxyStrictSSL", "http.proxySupport", "http.systemCertificates"):
505
509
  old = previous.get(key)
506
510
  if old is None:
507
511
  settings.pop(key, None)
@@ -41,6 +41,8 @@ def main() -> int:
41
41
  matrx_base_url = config.get("matrx_base_url", "")
42
42
  host = config.get("host", "127.0.0.1")
43
43
  port = config.get("port", 8842)
44
+ agent_id = config.get("agent_id") or None
45
+ group_id = config.get("group_id") or None
44
46
 
45
47
  if not matrx_key or not matrx_base_url:
46
48
  print("Invalid proxy config: matrx_key and matrx_base_url required", file=sys.stderr)
@@ -56,6 +58,8 @@ def main() -> int:
56
58
  host=host,
57
59
  port=port,
58
60
  pid_file=pid_file,
61
+ agent_id=agent_id,
62
+ group_id=group_id,
59
63
  )
60
64
  return 0
61
65
 
@@ -19,14 +19,16 @@ Design choices (informed by cursor-tap):
19
19
  from __future__ import annotations
20
20
 
21
21
  import asyncio
22
+ import contextlib
22
23
  import logging
23
24
  import os
24
25
  import signal
25
26
  import ssl
27
+ import sys
26
28
  import time
27
29
  import uuid
28
30
  from pathlib import Path
29
- from typing import Any
31
+ from typing import TYPE_CHECKING, Any, AsyncGenerator
30
32
 
31
33
  import httpx
32
34
 
@@ -55,19 +57,61 @@ except ImportError:
55
57
 
56
58
  logger = logging.getLogger(__name__)
57
59
 
60
+
61
+ class _SuppressAsyncioNoise(logging.Filter):
62
+ """Suppress known-benign asyncio noise on Windows and SSL connections.
63
+
64
+ Two cases are filtered:
65
+
66
+ 1. SSL EOF warning — Python's asyncio SSL transport emits this at WARNING
67
+ level whenever a remote peer closes the connection. The return value
68
+ from eof_received() is silently ignored for SSL connections; harmless
69
+ and unfixable without rewriting asyncio's SSL transport layer.
70
+
71
+ 2. WinError 10054 in _call_connection_lost — Windows ProactorEventLoop
72
+ calls socket.shutdown(SHUT_RDWR) on already-reset sockets during
73
+ connection teardown. Cursor's CodebaseSnapshotService packfile uploads
74
+ close connections with TCP RST, triggering this path. The error is
75
+ logged at ERROR level by asyncio but indicates normal connection teardown;
76
+ the proxy's own logic is unaffected.
77
+ """
78
+
79
+ def filter(self, record: logging.LogRecord) -> bool:
80
+ msg = record.getMessage()
81
+ if "eof_received" in msg:
82
+ return False
83
+ if "_call_connection_lost" in msg and record.exc_info:
84
+ exc_type = record.exc_info[0]
85
+ if exc_type is not None and issubclass(exc_type, ConnectionResetError):
86
+ return False
87
+ return True
88
+
89
+
58
90
  _MAX_BODY_BYTES = 50 * 1024 * 1024 # 50 MB hard limit for buffered request bodies
59
91
 
60
92
  DEFAULT_PORT = 8842
61
93
  PROXY_HOST = "127.0.0.1"
62
94
  HEALTH_PATH = "/__mtrx_health__"
63
95
 
96
+
97
+ def _print_inbox_notification(from_agent_id: str, prompt_preview: str) -> None:
98
+ """Print a visible terminal notification when an A2A task arrives while idle."""
99
+ preview_display = f': "{prompt_preview}"' if prompt_preview else ""
100
+ print(
101
+ f"\n\033[1m[MTRX]\033[0m \U0001f4ec A2A task from {from_agent_id}{preview_display}"
102
+ "\n → Start a new turn to receive and process it.\n",
103
+ file=sys.stderr,
104
+ flush=True,
105
+ )
106
+
64
107
  # Domains whose TLS we intercept for observability.
65
108
  _INTERCEPT_DOMAINS = {
66
109
  "api2.cursor.sh",
67
110
  "api3.cursor.sh",
68
111
  "api4.cursor.sh",
69
112
  "api5.cursor.sh",
70
- "agentn.global.api5.cursor.sh",
113
+ "agent.api5.cursor.sh", # Cloud Agent (privacy mode)
114
+ "agentn.api5.cursor.sh", # Cloud Agent (non-privacy mode)
71
115
  "api.anthropic.com",
72
116
  "api.openai.com",
73
117
  }
@@ -77,6 +121,8 @@ _PREWARM_DOMAINS = (
77
121
  "api3.cursor.sh",
78
122
  "api4.cursor.sh",
79
123
  "api5.cursor.sh",
124
+ "agent.api5.cursor.sh",
125
+ "agentn.api5.cursor.sh",
80
126
  )
81
127
 
82
128
 
@@ -90,16 +136,21 @@ class MITMProxy:
90
136
  matrx_base_url: str,
91
137
  host: str = PROXY_HOST,
92
138
  port: int = DEFAULT_PORT,
139
+ agent_id: str | None = None,
140
+ group_id: str | None = None,
93
141
  ):
94
142
  self.matrx_key = matrx_key
95
143
  self.matrx_base_url = matrx_base_url.rstrip("/")
96
144
  self.host = host
97
145
  self.port = port
146
+ self._agent_id: str | None = agent_id
147
+ self._group_id: str | None = group_id
98
148
  self._server: asyncio.Server | None = None
99
149
  self._telemetry_client: httpx.AsyncClient | None = None
100
150
  self._cert_cache: CertCache | None = None
101
151
  self._request_count = 0
102
152
  self._connect_count = 0
153
+ self._inbox_poll_task: asyncio.Task | None = None
103
154
 
104
155
  async def start(self) -> None:
105
156
  ca_key, ca_cert = load_ca()
@@ -110,6 +161,16 @@ class MITMProxy:
110
161
  self._handle_client, self.host, self.port
111
162
  )
112
163
  logger.info("MITM proxy listening on %s:%d", self.host, self.port)
164
+ if self._agent_id and self._group_id:
165
+ self._inbox_poll_task = asyncio.create_task(
166
+ self._run_inbox_poll_loop(),
167
+ name="mtrx-inbox-poll",
168
+ )
169
+ logger.info(
170
+ "proxy: inbox poller started agent_id=%s group_id=%s",
171
+ self._agent_id,
172
+ self._group_id,
173
+ )
113
174
 
114
175
  async def serve_forever(self) -> None:
115
176
  if self._server is None:
@@ -119,6 +180,10 @@ class MITMProxy:
119
180
  await self._server.serve_forever()
120
181
 
121
182
  async def stop(self) -> None:
183
+ if self._inbox_poll_task and not self._inbox_poll_task.done():
184
+ self._inbox_poll_task.cancel()
185
+ with contextlib.suppress(asyncio.CancelledError):
186
+ await self._inbox_poll_task
122
187
  if self._server:
123
188
  self._server.close()
124
189
  await self._server.wait_closed()
@@ -129,6 +194,58 @@ class MITMProxy:
129
194
  def request_count(self) -> int:
130
195
  return self._request_count
131
196
 
197
+ # -----------------------------------------------------------------
198
+ # Inbox background poller
199
+ # -----------------------------------------------------------------
200
+
201
+ async def _run_inbox_poll_loop(self) -> None:
202
+ """Long-poll /v1/inbox/wait while the proxy is running.
203
+
204
+ When a directed work item arrives for this agent, prints a visible
205
+ terminal notification so the user knows to give the agent its next
206
+ turn. Actual task delivery still happens via the normal injection
207
+ path (claim_directed_work_for_injection) on the next proxy call —
208
+ this loop only provides the push notification.
209
+ """
210
+ url = f"{self.matrx_base_url}/v1/inbox/wait"
211
+ params: dict[str, str | int] = {
212
+ "group_id": str(self._group_id),
213
+ "timeout_s": 25,
214
+ }
215
+ if self._agent_id:
216
+ params["agent_id"] = self._agent_id
217
+ headers = {"X-Matrx-Key": self.matrx_key}
218
+
219
+ async with httpx.AsyncClient(timeout=httpx.Timeout(32.0, connect=5.0)) as client:
220
+ while True:
221
+ try:
222
+ resp = await client.get(url, params=params, headers=headers)
223
+ if resp.status_code == 200:
224
+ data = resp.json()
225
+ if data.get("has_pending"):
226
+ from_label = data.get("from_agent_id") or "external"
227
+ preview = (data.get("prompt_preview") or "").strip()
228
+ _print_inbox_notification(from_label, preview)
229
+ elif resp.status_code == 401:
230
+ logger.warning("proxy: inbox poller received 401 — stopping")
231
+ return
232
+ # Any other non-2xx: log at debug and retry after backoff
233
+ elif resp.status_code >= 400:
234
+ logger.debug(
235
+ "proxy: inbox poller got %s, retrying in 10s",
236
+ resp.status_code,
237
+ )
238
+ await asyncio.sleep(10)
239
+ except asyncio.CancelledError:
240
+ return
241
+ except (httpx.TimeoutException, httpx.ConnectError):
242
+ # Timeout is expected (server held 25s with no work).
243
+ # ConnectError happens briefly at startup or on network blip.
244
+ pass
245
+ except Exception:
246
+ logger.debug("proxy: inbox poller error", exc_info=True)
247
+ await asyncio.sleep(5)
248
+
132
249
  # -----------------------------------------------------------------
133
250
  # Connection handling
134
251
  # -----------------------------------------------------------------
@@ -397,9 +514,18 @@ class MITMProxy:
397
514
  success, resp_headers, resp_body, is_streaming = result
398
515
  if success and resp_body is not None:
399
516
  self._request_count += 1
400
- self._write_http_response(
401
- client_writer, 200, resp_headers, resp_body
402
- )
517
+ if hasattr(resp_body, "__aiter__"):
518
+ # Streaming generator: write chunked HTTP response
519
+ resp_body_size = await self._write_chunked_reroute_response(
520
+ client_writer, resp_headers, resp_body
521
+ )
522
+ else:
523
+ # Buffered bytes: write with content-length
524
+ self._write_http_response(
525
+ client_writer, 200, resp_headers, resp_body
526
+ )
527
+ await client_writer.drain()
528
+ resp_body_size = len(resp_body)
403
529
  asyncio.create_task(
404
530
  self._ship_telemetry(
405
531
  hostname=hostname,
@@ -407,7 +533,7 @@ class MITMProxy:
407
533
  path=path,
408
534
  status_code=200,
409
535
  req_body_size=len(req_body),
410
- resp_body_size=len(resp_body),
536
+ resp_body_size=resp_body_size,
411
537
  elapsed_ms=0,
412
538
  content_type=resp_headers.get("content-type", ""),
413
539
  is_streaming=is_streaming,
@@ -425,6 +551,7 @@ class MITMProxy:
425
551
  )
426
552
  body_to_forward = injected_body if injected_body is not None else req_body
427
553
  fwd_headers = dict(req_headers)
554
+ fwd_headers.pop("transfer-encoding", None) # remove chunked before setting content-length
428
555
  fwd_headers["content-length"] = str(len(body_to_forward))
429
556
  up_writer.write(req_line)
430
557
  self._write_headers(up_writer, fwd_headers)
@@ -494,19 +621,20 @@ class MITMProxy:
494
621
  elapsed_ms = int((time.monotonic() - started) * 1000)
495
622
  self._request_count += 1
496
623
 
497
- asyncio.create_task(
498
- self._ship_telemetry(
499
- hostname=hostname,
500
- method=method,
501
- path=path,
502
- status_code=status_code,
503
- req_body_size=req_body_size,
504
- resp_body_size=resp_body_size,
505
- elapsed_ms=elapsed_ms,
506
- content_type=content_type,
507
- is_streaming=is_streaming,
624
+ if _is_ai_req: # backend rejects telemetry for non-AI infrastructure paths
625
+ asyncio.create_task(
626
+ self._ship_telemetry(
627
+ hostname=hostname,
628
+ method=method,
629
+ path=path,
630
+ status_code=status_code,
631
+ req_body_size=req_body_size,
632
+ resp_body_size=resp_body_size,
633
+ elapsed_ms=elapsed_ms,
634
+ content_type=content_type,
635
+ is_streaming=is_streaming,
636
+ )
508
637
  )
509
- )
510
638
 
511
639
  conn_h = resp_headers.get("connection", "").lower()
512
640
  if "close" in conn_h:
@@ -842,6 +970,38 @@ class MITMProxy:
842
970
  writer.write(body)
843
971
  # Note: drain is caller's responsibility
844
972
 
973
+ async def _write_chunked_reroute_response(
974
+ self,
975
+ writer: asyncio.StreamWriter,
976
+ headers: dict[str, str],
977
+ frames: AsyncGenerator[bytes, None],
978
+ ) -> int:
979
+ """Write an HTTP/1.1 chunked-encoded response by iterating a Connect-frame generator.
980
+
981
+ Each Connect frame from the generator becomes one chunk. The response ends
982
+ with the mandatory zero-length chunk terminator. Returns total payload bytes
983
+ written (for telemetry).
984
+ """
985
+ writer.write(b"HTTP/1.1 200 OK\r\n")
986
+ merged = dict(headers)
987
+ merged["transfer-encoding"] = "chunked"
988
+ self._write_headers(writer, merged)
989
+ await writer.drain()
990
+
991
+ total = 0
992
+ async for chunk in frames:
993
+ if not chunk:
994
+ continue
995
+ writer.write(f"{len(chunk):x}\r\n".encode())
996
+ writer.write(chunk)
997
+ writer.write(b"\r\n")
998
+ await writer.drain()
999
+ total += len(chunk)
1000
+
1001
+ writer.write(b"0\r\n\r\n")
1002
+ await writer.drain()
1003
+ return total
1004
+
845
1005
  # -----------------------------------------------------------------
846
1006
  # Raw bidirectional pipe (for opaque tunnels)
847
1007
  # -----------------------------------------------------------------
@@ -888,7 +1048,7 @@ class MITMProxy:
888
1048
  content_type: str,
889
1049
  is_streaming: bool,
890
1050
  ) -> None:
891
- if self._telemetry_client is None:
1051
+ if self._telemetry_client is None or not self.matrx_key:
892
1052
  return
893
1053
 
894
1054
  payload = {
@@ -905,11 +1065,19 @@ class MITMProxy:
905
1065
  }
906
1066
  url = f"{self.matrx_base_url}/v1/telemetry/cursor"
907
1067
  try:
908
- await self._telemetry_client.post(
1068
+ resp = await self._telemetry_client.post(
909
1069
  url,
910
1070
  json=payload,
911
1071
  headers={"X-Matrx-Key": self.matrx_key},
912
1072
  )
1073
+ if resp.status_code >= 400:
1074
+ logger.warning(
1075
+ "telemetry: %s from %s (key=%s... path=%s)",
1076
+ resp.status_code,
1077
+ url,
1078
+ self.matrx_key[:8],
1079
+ path,
1080
+ )
913
1081
  except Exception:
914
1082
  logger.debug("telemetry ship failed", exc_info=True)
915
1083
 
@@ -925,8 +1093,16 @@ def run_proxy(
925
1093
  host: str = PROXY_HOST,
926
1094
  port: int = DEFAULT_PORT,
927
1095
  pid_file: Path | None = None,
1096
+ agent_id: str | None = None,
1097
+ group_id: str | None = None,
928
1098
  ) -> None:
929
1099
  """Run the MITM proxy (blocking). Intended for daemon/service use."""
1100
+ logging.getLogger("asyncio").addFilter(_SuppressAsyncioNoise())
1101
+
1102
+ # Allow agent/group identity to come from environment when not explicitly set
1103
+ agent_id = agent_id or os.environ.get("MTRX_AGENT_ID") or None
1104
+ group_id = group_id or os.environ.get("MTRX_GROUP_ID") or None
1105
+
930
1106
  if pid_file:
931
1107
  pid_file.parent.mkdir(parents=True, exist_ok=True)
932
1108
  pid_file.write_text(str(os.getpid()), encoding="utf-8")
@@ -936,6 +1112,8 @@ def run_proxy(
936
1112
  matrx_base_url=matrx_base_url,
937
1113
  host=host,
938
1114
  port=port,
1115
+ agent_id=agent_id,
1116
+ group_id=group_id,
939
1117
  )
940
1118
 
941
1119
  loop = asyncio.new_event_loop()
@@ -17,7 +17,7 @@ import asyncio
17
17
  import json
18
18
  import logging
19
19
  import re
20
- from typing import Any
20
+ from typing import Any, AsyncGenerator
21
21
 
22
22
  import httpx
23
23
 
@@ -27,7 +27,9 @@ logger = logging.getLogger(__name__)
27
27
  # Cursor uses aiserver.v1.AiServerService for all AI endpoints.
28
28
  _AI_PATH_PATTERNS = (
29
29
  r"AiServerService",
30
- r"AiService",
30
+ # r"AiService" intentionally omitted — too broad, matches non-inference endpoints like
31
+ # KnowledgeBaseList, UpdateVscodeProfile, GetDefaultModel. Actual inference methods
32
+ # on AiService are all covered by their specific method-level patterns below.
31
33
  r"ChatService",
32
34
  r"StreamUnifiedChat",
33
35
  r"StreamDiff",
@@ -51,7 +53,7 @@ _REROUTABLE_AI_PATH_PATTERNS = (
51
53
  )
52
54
  _AI_SERVICE_CANDIDATE_PATTERNS = (
53
55
  r"AiServerService",
54
- r"AiService",
56
+ # r"AiService" intentionally omitted — see note in _AI_PATH_PATTERNS above.
55
57
  r"AgentService",
56
58
  r"ChatService",
57
59
  r"CppService",
@@ -289,6 +291,85 @@ def _inject_memory_context_items(
289
291
  return len(injected_items)
290
292
 
291
293
 
294
+ # Pre-built Connect end-of-stream frame: flags=0x02, payload=b"{}"
295
+ # Frame format: [flags:1][length:4 BE][payload] → \x02 \x00\x00\x00\x02 {}
296
+ _EOS_FRAME = b"\x02\x00\x00\x00\x02{}"
297
+
298
+
299
+ async def _stream_rerouted_frames(
300
+ url: str,
301
+ payload: dict[str, Any],
302
+ headers: dict[str, str],
303
+ provider: str,
304
+ ) -> AsyncGenerator[bytes, None]:
305
+ """POST to MTRX and yield Connect-framed protobuf text deltas as SSE events arrive.
306
+
307
+ Parses each ``data: `` line from the SSE stream, extracts the text delta
308
+ (Anthropic ``content_block_delta`` or OpenAI ``choices[].delta.content``),
309
+ wraps it in a Connect data frame (flags=0x00), and yields it immediately so
310
+ Cursor sees tokens arrive incrementally.
311
+
312
+ Always terminates with a Connect end-of-stream frame (flags=0x02). Any error
313
+ causes a silent fallback: the generator yields only the EOS frame so Cursor
314
+ sees an empty stream rather than a broken connection.
315
+ """
316
+ try:
317
+ from matrx.cli.cursor_connect import build_connect_frame
318
+ from matrx.cli.cursor_proto import _PROTOS_AVAILABLE, server_chat_pb2 # type: ignore[import]
319
+ except Exception:
320
+ yield _EOS_FRAME
321
+ return
322
+
323
+ if not _PROTOS_AVAILABLE:
324
+ yield _EOS_FRAME
325
+ return
326
+
327
+ try:
328
+ async with httpx.AsyncClient(
329
+ timeout=httpx.Timeout(timeout=90.0, connect=5.0)
330
+ ) as client:
331
+ async with client.stream("POST", url, json=payload, headers=headers) as resp:
332
+ if resp.status_code >= 400:
333
+ logger.info(
334
+ "cursor_reroute: stream upstream returned %s", resp.status_code
335
+ )
336
+ yield _EOS_FRAME
337
+ return
338
+ async for raw_line in resp.aiter_lines():
339
+ if not raw_line.startswith("data: "):
340
+ continue
341
+ data_str = raw_line[6:].strip()
342
+ if data_str == "[DONE]":
343
+ break
344
+ try:
345
+ chunk = json.loads(data_str)
346
+ except json.JSONDecodeError:
347
+ continue
348
+
349
+ text = ""
350
+ if provider == "anthropic":
351
+ # Mirrors extract_from_anthropic_sse_response inner loop
352
+ if chunk.get("type") == "content_block_delta":
353
+ delta = chunk.get("delta") or {}
354
+ if delta.get("type") == "text_delta":
355
+ text = delta.get("text") or ""
356
+ else:
357
+ # Mirrors extract_from_openai_sse_response inner loop
358
+ for choice in chunk.get("choices") or []:
359
+ delta = choice.get("delta") or {}
360
+ text += delta.get("content") or ""
361
+
362
+ if text:
363
+ resp_msg = server_chat_pb2.StreamUnifiedChatWithToolsResponse()
364
+ resp_msg.content.text = text
365
+ yield build_connect_frame(0x00, resp_msg.SerializeToString())
366
+
367
+ except Exception:
368
+ logger.warning("cursor_reroute: streaming reroute error", exc_info=True)
369
+
370
+ yield _EOS_FRAME
371
+
372
+
292
373
  async def try_reroute_to_matrx(
293
374
  *,
294
375
  path: str,
@@ -300,13 +381,15 @@ async def try_reroute_to_matrx(
300
381
  session_id: str | None = None,
301
382
  group_id: str | None = None,
302
383
  project_id: str | None = None,
303
- ) -> tuple[bool, dict[str, str], bytes | None, bool] | None:
384
+ ) -> tuple[bool, dict[str, str], AsyncGenerator[bytes, None] | bytes | None, bool] | None:
304
385
  """
305
386
  Attempt to reroute a Cursor AI request through MTRX.
306
387
 
307
388
  Returns:
308
- (success, response_headers, response_body, is_streaming) if handled,
389
+ (success, response_headers, response_body_or_generator, is_streaming) if handled,
309
390
  None to fall back to normal forward.
391
+ response_body_or_generator is an AsyncGenerator[bytes, None] of Connect frames;
392
+ the proxy must iterate it using chunked transfer encoding.
310
393
  """
311
394
  classification = classify_ai_request(method, path, req_headers)
312
395
  if not classification["candidate"]:
@@ -319,8 +402,6 @@ async def try_reroute_to_matrx(
319
402
  from matrx.cli.cursor_connect import is_connect_proto_request, parse_connect_frame
320
403
  from matrx.cli.cursor_extraction import (
321
404
  _PROTOS_AVAILABLE,
322
- extract_from_anthropic_sse_response,
323
- extract_from_openai_sse_response,
324
405
  extract_from_request,
325
406
  parse_request_proto,
326
407
  ship_ai_telemetry,
@@ -372,52 +453,20 @@ async def try_reroute_to_matrx(
372
453
  headers["Authorization"] = f"Bearer {matrx_key}"
373
454
 
374
455
  url = f"{matrx_base_url.rstrip('/')}{upstream_path}"
375
- try:
376
- async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=90.0, connect=5.0)) as client:
377
- resp = await client.post(url, json={**payload, "stream": True}, headers=headers)
378
- except Exception:
379
- logger.warning("cursor_reroute: upstream request failed for %s", path, exc_info=True)
380
- return None
381
-
382
- if resp.status_code >= 400:
383
- logger.info(
384
- "cursor_reroute: upstream returned %s for %s; forwarding unchanged",
385
- resp.status_code,
386
- path,
387
- )
388
- return None
389
-
390
456
  provider = _detect_provider_from_model(str(payload.get("model", "")))
391
- if provider == "anthropic":
392
- frame_data = extract_from_anthropic_sse_response(resp.content)
393
- else:
394
- frame_data = extract_from_openai_sse_response(resp.content)
395
-
396
- text = frame_data.get("text", "")
397
- usage = frame_data.get("usage")
398
- response_body = _build_cursor_response_bytes(text=text, usage=usage)
399
- if response_body is None:
400
- return None
401
-
402
- response_telemetry = {
403
- "session_id": extracted.get("session_id") or session_id or "",
404
- "conversation_id": extracted.get("conversation_id") or "",
405
- "model": extracted.get("model") or "",
406
- "files": extracted.get("files", []),
407
- "edits": extracted.get("edits", []),
408
- "response_text": text,
409
- "tool_calls": [],
410
- "usage": usage,
411
- }
412
- asyncio.create_task(ship_ai_telemetry(response_telemetry, matrx_base_url, matrx_key))
413
-
457
+ gen = _stream_rerouted_frames(
458
+ url=url,
459
+ payload={**payload, "stream": True},
460
+ headers=headers,
461
+ provider=provider,
462
+ )
414
463
  return (
415
464
  True,
416
465
  {
417
466
  "content-type": req_headers.get("content-type", "application/connect+proto"),
418
467
  "connect-protocol-version": "1",
419
468
  },
420
- response_body,
469
+ gen,
421
470
  True,
422
471
  )
423
472
 
@@ -582,16 +582,21 @@ def _build_codex_env(
582
582
  env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
583
583
  session_id = str(uuid.uuid4())
584
584
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
585
+ codex_root = ensure_root_url(matrx.get("base_url"))
586
+ if not group_id:
587
+ group_id = _auto_resolve_default_group_id(codex_root, mx_key)
585
588
  runtime_agent_id = (
586
589
  (orchestration or {}).get("agent_id")
587
590
  or _runtime_agent_basename("codex")[0]
588
591
  )
592
+ workspace_fp = _compute_workspace_fingerprint(_workspace_cwd(env))
589
593
  header_parts = [
590
594
  f'"Authorization" = "Bearer {provider_bearer}"',
591
595
  f'"X-Matrx-Key" = "{mx_key}"',
592
596
  f'"X-Matrx-Agent-Id" = "{runtime_agent_id}"',
593
597
  '"X-Matrx-Provider" = "codex"',
594
598
  f'"X-Matrx-Session-Id" = "{session_id}"',
599
+ f'"X-Matrx-Workspace" = "{workspace_fp}"',
595
600
  ]
596
601
  if group_id:
597
602
  header_parts.append(f'"X-Matrx-Group" = "{group_id}"')
@@ -655,11 +660,14 @@ def _build_gemini_env(
655
660
  env.pop(key, None)
656
661
  env.pop("MTRX_KEY", None)
657
662
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
663
+ if not group_id:
664
+ group_id = _auto_resolve_default_group_id(proxy_root, mx_key)
658
665
  session_id = str(uuid.uuid4())
659
666
  runtime_agent_id = (
660
667
  (orchestration or {}).get("agent_id")
661
668
  or _runtime_agent_basename("gemini")[0]
662
669
  )
670
+ workspace_fp = _compute_workspace_fingerprint(_workspace_cwd(env))
663
671
  ctx_params: list[str] = []
664
672
  if project_id:
665
673
  ctx_params.append(f"mtrx_project={project_id}")
@@ -669,6 +677,7 @@ def _build_gemini_env(
669
677
  ctx_params.append(f"mtrx_session={session_id}")
670
678
  if runtime_agent_id:
671
679
  ctx_params.append(f"mtrx_agent={runtime_agent_id}")
680
+ ctx_params.append(f"mtrx_workspace={workspace_fp}")
672
681
  git_branch, git_commit = _capture_git_context(_workspace_cwd(env))
673
682
  git_repo_url = _capture_git_remote_url(_workspace_cwd(env))
674
683
  if git_branch:
@@ -686,6 +695,7 @@ def _build_gemini_env(
686
695
  f"x-matrx-agent-id: {runtime_agent_id}",
687
696
  "x-matrx-provider: gemini_code",
688
697
  f"x-matrx-session-id: {session_id}",
698
+ f"x-matrx-workspace: {workspace_fp}",
689
699
  ]
690
700
  if group_id:
691
701
  custom_headers.append(f"x-matrx-group: {group_id}")
@@ -767,6 +777,35 @@ def _build_gemini_env(
767
777
  return env, "missing_auth"
768
778
 
769
779
 
780
+ def _compute_workspace_fingerprint(cwd: str) -> str:
781
+ return hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:16]
782
+
783
+
784
+ def _auto_resolve_default_group_id(base_url: str, mx_key: str) -> str:
785
+ """Fetch the user's groups; return the sole/default group ID if unambiguous."""
786
+ if not base_url or not mx_key:
787
+ return ""
788
+ try:
789
+ with httpx.Client(timeout=5) as client:
790
+ resp = client.get(
791
+ f"{base_url.rstrip('/')}/v1/groups",
792
+ headers={"X-Matrx-Key": mx_key},
793
+ )
794
+ if resp.status_code != 200:
795
+ return ""
796
+ groups = resp.json().get("groups", [])
797
+ if not groups:
798
+ return ""
799
+ if len(groups) == 1:
800
+ return str(groups[0].get("id", ""))
801
+ for g in groups:
802
+ if g.get("is_default"):
803
+ return str(g.get("id", ""))
804
+ return ""
805
+ except (httpx.HTTPError, Exception):
806
+ return ""
807
+
808
+
770
809
  def _build_claude_env(
771
810
  state: dict,
772
811
  route: str,
@@ -793,12 +832,14 @@ def _build_claude_env(
793
832
  env["ANTHROPIC_BASE_URL"] = proxy_root
794
833
  env.pop("ANTHROPIC_API_KEY", None)
795
834
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
835
+ if not group_id:
836
+ group_id = _auto_resolve_default_group_id(proxy_root, mx_key)
796
837
  session_id = str(uuid.uuid4())
797
838
  runtime_agent_id = (
798
839
  (orchestration or {}).get("agent_id")
799
840
  or _runtime_agent_basename("claude")[0]
800
841
  )
801
- # Evolutionary scaffolding: env snapshot for AI context injection
842
+ workspace_fp = _compute_workspace_fingerprint(_workspace_cwd(env))
802
843
  env_snap = _capture_env_snapshot()
803
844
  env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
804
845
  custom_headers = "\n".join(
@@ -809,6 +850,7 @@ def _build_claude_env(
809
850
  f"x-matrx-session-id: {session_id}",
810
851
  ]
811
852
  )
853
+ custom_headers += f"\nx-matrx-workspace: {workspace_fp}"
812
854
  if group_id:
813
855
  custom_headers += f"\nx-matrx-group: {group_id}"
814
856
  if project_id:
@@ -1110,6 +1110,19 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
1110
1110
  except ValueError as exc:
1111
1111
  print(str(exc), file=sys.stderr)
1112
1112
  return 1
1113
+
1114
+ if effective_route == "matrx":
1115
+ try:
1116
+ from matrx.cli.indexer import trigger_background_index
1117
+
1118
+ trigger_background_index(
1119
+ state,
1120
+ _resolved_matrx_key(state, os.environ),
1121
+ cwd=os.getcwd(),
1122
+ )
1123
+ except Exception:
1124
+ pass
1125
+
1113
1126
  return launch(plan)
1114
1127
 
1115
1128
 
@@ -1305,6 +1318,13 @@ def _cmd_cursor(args) -> int:
1305
1318
  else:
1306
1319
  print("[warn] Could not find Cursor executable. Open Cursor manually.", file=sys.stderr)
1307
1320
 
1321
+ try:
1322
+ from matrx.cli.indexer import trigger_background_index
1323
+
1324
+ trigger_background_index(state, mx_key, cwd=os.getcwd())
1325
+ except Exception:
1326
+ pass
1327
+
1308
1328
  print()
1309
1329
  print("Cursor configured for MTRX — all traffic routed through MITM proxy.")
1310
1330
  print(f" proxy: {PROXY_HOST}:{DEFAULT_PORT}")
@@ -173,6 +173,10 @@ def normalize_matrx_key(value: str | None) -> str:
173
173
  return cleaned
174
174
 
175
175
 
176
+ def _normalize_binding_value(value: str | None) -> str:
177
+ return (value or "").strip()
178
+
179
+
176
180
  def ensure_v1_url(base_url: str | None) -> str:
177
181
  cleaned = _normalize_base_url(base_url).rstrip("/")
178
182
  if cleaned.endswith("/v1"):
@@ -320,6 +324,12 @@ def _normalize_state(state: dict) -> None:
320
324
  binding["matrx_key"] = matrx_key
321
325
  else:
322
326
  binding.pop("matrx_key", None)
327
+ for field in ("project_id", "group_id"):
328
+ cleaned = _normalize_binding_value(binding.get(field))
329
+ if cleaned:
330
+ binding[field] = cleaned
331
+ else:
332
+ binding.pop(field, None)
323
333
 
324
334
 
325
335
  def _normalize_base_url(base_url: str | None) -> str: